From a7375483c410d850a82464cfa8f9f86b28dec82f Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 14 Oct 2026 22:07:44 -0300 Subject: [PATCH 1/2] feat: Wait for the Flyway container and split the container lifecycle into segregated collaborators. --- composer.json | 7 +- phpstan.neon.dist | 62 +- src/ContainerStarted.php | 43 +- src/DisposableContainer.php | 26 + src/DockerClient.php | 35 + src/DockerCommand.php | 24 + src/DockerCommandWithTimeout.php | 20 + src/DockerContainer.php | 141 --- src/EnvironmentFlag.php | 28 - src/Exceptions/ContainerExitUnsuccessful.php | 20 + src/Exceptions/ContainerWaitTimeout.php | 41 + .../DockerCommandExecutionFailed.php | 38 + .../Exceptions/ImageNameEmpty.php | 5 +- src/FlywayContainer.php | 132 --- src/FlywayDockerContainer.php | 260 +++- src/GenericDockerContainer.php | 319 +++-- src/Internal/Client/Client.php | 24 - src/Internal/Client/CommandLine.php | 30 + .../Client/CommandLineDockerClient.php | 26 + src/Internal/Client/DockerClient.php | 32 - src/Internal/Client/StrictExecutor.php | 28 + .../CommandHandler/CommandHandler.php | 45 - .../ContainerCommandHandler.php | 87 -- src/Internal/Commands/Command.php | 22 - src/Internal/Commands/CommandWithTimeout.php | 18 - src/Internal/Commands/DockerCopy.php | 3 +- src/Internal/Commands/DockerCreate.php | 44 + src/Internal/Commands/DockerExecute.php | 3 +- src/Internal/Commands/DockerInspect.php | 3 +- src/Internal/Commands/DockerList.php | 5 +- .../Commands/DockerNetworkConnect.php | 11 +- src/Internal/Commands/DockerNetworkCreate.php | 11 +- src/Internal/Commands/DockerNetworkPrune.php | 6 +- src/Internal/Commands/DockerPull.php | 22 - src/Internal/Commands/DockerReaper.php | 30 +- src/Internal/Commands/DockerRemove.php | 3 +- src/Internal/Commands/DockerRun.php | 82 -- src/Internal/Commands/DockerStart.php | 25 + src/Internal/Commands/DockerStartAttached.php | 31 + src/Internal/Commands/DockerStop.php | 25 +- src/Internal/Containers/Address/Ports.php | 16 +- .../Containers/ContainerInspection.php | 49 +- src/Internal/Containers/ContainerLauncher.php | 50 + src/Internal/Containers/ContainerLookup.php | 26 +- src/Internal/Containers/ContainerReaper.php | 16 +- src/Internal/Containers/ContainerSharing.php | 30 + src/Internal/Containers/ContainerWaits.php | 47 + .../Definitions/ContainerDefinition.php | 66 +- .../Definitions/ContainerOption.php | 18 + .../Definitions/EnvironmentVariable.php | 2 +- .../Containers/Definitions/Network.php | 24 + .../Containers/Definitions/PortMapping.php | 2 +- .../Containers/Definitions/Removal.php | 19 + .../Containers/Definitions/VolumeMapping.php | 2 +- .../Containers/Drivers/MySQL/JdbcUrl.php | 41 + .../Drivers/MySQL/MySQLCommands.php | 26 +- .../Containers/Drivers/MySQL/MySQLShared.php | 64 + .../Containers/Drivers/MySQL/MySQLStarted.php | 37 +- .../Containers/Drivers/MySQL/MySQLStartup.php | 56 + src/Internal/Containers/Image.php | 2 +- src/Internal/Containers/Name.php | 9 +- .../Containers/NetworkProvisioner.php | 27 + src/Internal/Containers/OneShotContainer.php | 43 + .../Containers/RegisteredShutdownHook.php | 13 - .../Containers/{Reused.php => Shared.php} | 32 +- src/Internal/Containers/ShutdownHook.php | 18 - src/Internal/Containers/Started.php | 37 +- .../Exceptions/ContainerWaitTimeout.php | 17 - .../DockerCommandExecutionFailed.php | 35 - .../Exceptions/StopTimeoutOutOfRange.php | 17 - src/MySQLContainer.php | 67 -- src/MySQLDockerContainer.php | 340 ++++-- src/SharedContainer.php | 20 + src/Waits/Conditions/MySQL/MySQLReady.php | 9 + src/Waits/ContainerWait.php | 18 - src/Waits/ContainerWaitAfterStarted.php | 4 +- src/Waits/ContainerWaitBeforeStarted.php | 5 +- src/Waits/ContainerWaitForDependency.php | 57 +- src/Waits/ContainerWaitForTime.php | 24 +- .../V0000__Create_slow_table.sql | 9 + tests/Integration/DockerContainerTest.php | 191 ++- tests/Integration/Files/marker.txt | 1 + tests/Unit/ClientMock.php | 159 ++- tests/Unit/CommandMock.php | 4 +- tests/Unit/CommandWithTimeoutMock.php | 15 +- tests/Unit/EnvironmentFlagTest.php | 68 -- tests/Unit/FlywayDockerContainerTest.php | 744 +++++++----- tests/Unit/GenericDockerContainerTest.php | 1041 +++++++---------- .../Client/CommandLineDockerClientTest.php | 73 ++ .../Unit/Internal/Client/DockerClientTest.php | 72 -- tests/Unit/MySQLDockerContainerTest.php | 499 ++++---- tests/Unit/RegisteredShutdownHookTest.php | 31 - tests/Unit/RunningMySQLContainer.php | 13 +- tests/Unit/SharedMySQLContainer.php | 47 + tests/Unit/ShutdownHookMock.php | 22 - tests/Unit/TestableFlywayDockerContainer.php | 18 - tests/Unit/TestableGenericDockerContainer.php | 32 - tests/Unit/TestableMySQLDockerContainer.php | 28 - .../Waits/ContainerWaitForDependencyTest.php | 161 ++- tests/Unit/Waits/ContainerWaitForTimeTest.php | 37 +- 100 files changed, 3406 insertions(+), 3061 deletions(-) create mode 100644 src/DisposableContainer.php create mode 100644 src/DockerClient.php create mode 100644 src/DockerCommand.php create mode 100644 src/DockerCommandWithTimeout.php delete mode 100644 src/DockerContainer.php delete mode 100644 src/EnvironmentFlag.php create mode 100644 src/Exceptions/ContainerExitUnsuccessful.php create mode 100644 src/Exceptions/ContainerWaitTimeout.php create mode 100644 src/Exceptions/DockerCommandExecutionFailed.php rename src/{Internal => }/Exceptions/ImageNameEmpty.php (66%) delete mode 100644 src/FlywayContainer.php delete mode 100644 src/Internal/Client/Client.php create mode 100644 src/Internal/Client/CommandLine.php create mode 100644 src/Internal/Client/CommandLineDockerClient.php delete mode 100644 src/Internal/Client/DockerClient.php create mode 100644 src/Internal/Client/StrictExecutor.php delete mode 100644 src/Internal/CommandHandler/CommandHandler.php delete mode 100644 src/Internal/CommandHandler/ContainerCommandHandler.php delete mode 100644 src/Internal/Commands/Command.php delete mode 100644 src/Internal/Commands/CommandWithTimeout.php create mode 100644 src/Internal/Commands/DockerCreate.php delete mode 100644 src/Internal/Commands/DockerPull.php delete mode 100644 src/Internal/Commands/DockerRun.php create mode 100644 src/Internal/Commands/DockerStart.php create mode 100644 src/Internal/Commands/DockerStartAttached.php create mode 100644 src/Internal/Containers/ContainerLauncher.php create mode 100644 src/Internal/Containers/ContainerSharing.php create mode 100644 src/Internal/Containers/ContainerWaits.php create mode 100644 src/Internal/Containers/Definitions/ContainerOption.php create mode 100644 src/Internal/Containers/Definitions/Network.php create mode 100644 src/Internal/Containers/Definitions/Removal.php create mode 100644 src/Internal/Containers/Drivers/MySQL/JdbcUrl.php create mode 100644 src/Internal/Containers/Drivers/MySQL/MySQLShared.php create mode 100644 src/Internal/Containers/Drivers/MySQL/MySQLStartup.php create mode 100644 src/Internal/Containers/NetworkProvisioner.php create mode 100644 src/Internal/Containers/OneShotContainer.php delete mode 100644 src/Internal/Containers/RegisteredShutdownHook.php rename src/Internal/Containers/{Reused.php => Shared.php} (59%) delete mode 100644 src/Internal/Containers/ShutdownHook.php delete mode 100644 src/Internal/Exceptions/ContainerWaitTimeout.php delete mode 100644 src/Internal/Exceptions/DockerCommandExecutionFailed.php delete mode 100644 src/Internal/Exceptions/StopTimeoutOutOfRange.php delete mode 100644 src/MySQLContainer.php create mode 100644 src/SharedContainer.php delete mode 100644 src/Waits/ContainerWait.php create mode 100644 tests/Integration/Database/SlowMigrations/V0000__Create_slow_table.sql create mode 100644 tests/Integration/Files/marker.txt delete mode 100644 tests/Unit/EnvironmentFlagTest.php create mode 100644 tests/Unit/Internal/Client/CommandLineDockerClientTest.php delete mode 100644 tests/Unit/Internal/Client/DockerClientTest.php delete mode 100644 tests/Unit/RegisteredShutdownHookTest.php create mode 100644 tests/Unit/SharedMySQLContainer.php delete mode 100644 tests/Unit/ShutdownHookMock.php delete mode 100644 tests/Unit/TestableFlywayDockerContainer.php delete mode 100644 tests/Unit/TestableGenericDockerContainer.php delete mode 100644 tests/Unit/TestableMySQLDockerContainer.php diff --git a/composer.json b/composer.json index 324e5ff..6a6301b 100644 --- a/composer.json +++ b/composer.json @@ -25,11 +25,12 @@ "php": "^8.5", "symfony/process": "^8.1", "tiny-blocks/collection": "^2.6", - "tiny-blocks/ksuid": "^2.0" + "tiny-blocks/ksuid": "^2.0", + "tiny-blocks/time": "^2.7" }, "require-dev": { "ergebnis/composer-normalize": "^2.52", - "infection/infection": "^0.34", + "infection/infection": "^0.35", "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^13.2", "slevomat/coding-standard": "^8.31", @@ -73,7 +74,7 @@ "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", "tests": [ "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", - "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html" ] } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 50f7b8d..99a44b7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,32 +5,27 @@ parameters: - tests tmpDir: reports/phpstan ignoreErrors: - # Late static binding in extension-point container factories that consumers subclass. - - identifier: new.static - paths: - - src/FlywayDockerContainer.php - - src/GenericDockerContainer.php - - src/MySQLDockerContainer.php # tiny-blocks/collection generics carry a mixed TValue on Internal command and definition types. - identifier: missingType.generics paths: - src/Internal/Commands/DockerExecute.php - - src/Internal/Containers/ContainerInspection.php - - src/Internal/Commands/DockerRun.php - - src/Internal/Containers/Address/Ports.php + - src/Internal/Commands/DockerCreate.php + - src/Internal/Containers/ContainerWaits.php - src/Internal/Containers/Definitions/ContainerDefinition.php - src/Internal/Containers/Environment/EnvironmentVariables.php # Internal collaborators take raw config arrays at the boundary, where PHPDoc is prohibited. - identifier: missingType.iterableValue paths: + - src/Internal/Containers/ContainerLauncher.php + - src/Internal/Containers/OneShotContainer.php + - src/Internal/Containers/Drivers/MySQL/MySQLStartup.php - src/Internal/Commands/DockerExecute.php - - src/Internal/Commands/DockerRun.php + - src/Internal/Commands/DockerCreate.php - src/Internal/Containers/Address/Ports.php - src/Internal/Containers/ContainerInspection.php + - src/Internal/Containers/Definitions/ContainerDefinition.php - src/Internal/Containers/Definitions/CopyInstruction.php - - src/Internal/Containers/Definitions/EnvironmentVariable.php - - src/Internal/Containers/Definitions/PortMapping.php - - src/Internal/Containers/Definitions/VolumeMapping.php + - src/Internal/Containers/Drivers/MySQL/JdbcUrl.php - src/Internal/Containers/Drivers/MySQL/MySQLCommands.php - src/Internal/Containers/Environment/EnvironmentVariables.php # Internal command builders return argument lists assembled from collection mixed values. @@ -38,28 +33,32 @@ parameters: paths: - src/Internal/Commands/DockerCopy.php - src/Internal/Commands/DockerExecute.php - - src/Internal/Commands/DockerRun.php + - src/Internal/Commands/DockerCreate.php - src/Internal/Containers/Address/Ports.php # Collection each/reduce pass concrete closures against the mixed-typed variadic API. - identifier: argument.type paths: - - src/Internal/CommandHandler/ContainerCommandHandler.php - - src/Internal/Commands/DockerRun.php + - src/Internal/Containers/ContainerLauncher.php + - src/Internal/Containers/ContainerWaits.php - src/Internal/Containers/Address/Ports.php + - src/Internal/Containers/Definitions/ContainerDefinition.php + # ContainerSharing starts a new container through a plain Closure, whose result is mixed to phpstan. + - identifier: argument.type + path: src/Internal/Containers/ContainerSharing.php # docker inspect json_decode and sprintf feed mixed values across the system boundary. - identifier: argument.type paths: - src/Internal/Containers/ContainerInspection.php - src/Internal/Containers/Drivers/MySQL/MySQLCommands.php - # gethostname() returns string|false at the system boundary when the host name is unavailable. + # MySQLCommands assembles the setup argument list as a raw array, where PHPDoc is prohibited. - identifier: argument.type - path: src/Internal/Containers/ContainerReaper.php + path: src/Internal/Containers/Drivers/MySQL/MySQLStartup.php # docker inspect json_decode produces mixed offsets read in ContainerInspection. - identifier: offsetAccess.nonOffsetAccessible path: src/Internal/Containers/ContainerInspection.php - # docker inspect json_decode yields a mixed value cast to int at the boundary. + # docker inspect json_decode yields a mixed port binding cast to int at the boundary. - identifier: cast.int - path: src/Internal/Containers/ContainerInspection.php + path: src/Internal/Containers/Address/Ports.php # docker inspect json_decode yields a mixed value iterated at the boundary. - identifier: foreach.nonIterable path: src/Internal/Containers/ContainerInspection.php @@ -79,15 +78,28 @@ parameters: - tests/Unit/FlywayDockerContainerTest.php - tests/Unit/GenericDockerContainerTest.php - tests/Unit/MySQLDockerContainerTest.php - # ClientMock's match arms assemble output tuples from untyped recorded responses. - - identifier: return.type + # ClientMock destructures the untyped response tuple it answers a command with. + - identifier: offsetAccess.nonArray + path: tests/Unit/ClientMock.php + # ClientMock queues untyped response tuples under the command each one answers. + - identifier: offsetAccess.nonOffsetAccessible path: tests/Unit/ClientMock.php # ClientMock casts a mixed recorded value to string when building command output. - identifier: cast.string path: tests/Unit/ClientMock.php - # ClientMock indexes into a mixed recorded payload when replaying responses. - - identifier: offsetAccess.nonArray - path: tests/Unit/ClientMock.php + # Tests index into the untyped argument lists the ClientMock records. + - identifier: offsetAccess.nonOffsetAccessible + paths: + - tests/Unit/FlywayDockerContainerTest.php + - tests/Unit/GenericDockerContainerTest.php + # The shutdown spy records callbacks in an untyped global the test then invokes. + - identifier: callable.nonCallable + path: tests/Unit/GenericDockerContainerTest.php + # Immutability tests discard the copy a with* method returns, to show the receiver is untouched. + - identifier: method.resultUnused + paths: + - tests/Unit/FlywayDockerContainerTest.php + - tests/Unit/MySQLDockerContainerTest.php # MySQLRepository reads a mixed PDO result as an object at the system boundary. - identifier: method.nonObject path: tests/Integration/MySQLRepository.php diff --git a/src/ContainerStarted.php b/src/ContainerStarted.php index c726886..9bd6604 100644 --- a/src/ContainerStarted.php +++ b/src/ContainerStarted.php @@ -4,30 +4,27 @@ namespace TinyBlocks\DockerContainer; -use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; -use TinyBlocks\DockerContainer\Internal\Exceptions\StopTimeoutOutOfRange; +use TinyBlocks\DockerContainer\Exceptions\DockerCommandExecutionFailed; +use TinyBlocks\Time\Duration; /** * Represents a Docker container that has been started and is running. + * + *

A container this process started on its own is a {@see DisposableContainer}, which it may remove. A container + * shared among processes is a {@see SharedContainer}, which tells whether it was reused.

*/ interface ContainerStarted { - /** - * Default graceful period in whole seconds used when stopping the container. - * - *

After this period elapses, Docker forces the container to stop with SIGKILL.

- */ - public const int DEFAULT_TIMEOUT_IN_WHOLE_SECONDS = 10; - /** * Stops the running container gracefully. * - * @param int $timeoutInWholeSeconds The graceful period in seconds to wait before forcing the stop. + *

Docker forces the stop with SIGKILL once the grace period elapses.

+ * + * @param Duration $gracePeriod The time to wait before forcing the stop. * @return ExecutionCompleted The result of the stop command execution. - * @throws StopTimeoutOutOfRange If the timeout in whole seconds is negative. - * @throws DockerCommandExecutionFailed If the stop command fails. + * @throws DockerCommandExecutionFailed If the stop command cannot run. */ - public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted; + public function stop(Duration $gracePeriod): ExecutionCompleted; /** * Returns the unique identifier of the container. @@ -36,12 +33,6 @@ public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE */ public function getId(): string; - /** - * Forcefully removes the container and its anonymous volumes, then prunes - * unused networks created by the library. - */ - public function remove(): void; - /** * Returns the name assigned to the container. * @@ -49,13 +40,6 @@ public function remove(): void; */ public function getName(): string; - /** - * Tells whether the container was reused from an already-running instance. - * - * @return bool True when an existing container was reused, false when a new one was started. - */ - public function wasReused(): bool; - /** * Returns the network address of the container. * @@ -63,17 +47,12 @@ public function wasReused(): bool; */ public function getAddress(): Address; - /** - * Registers the container to be removed when the PHP process exits. - */ - public function stopOnShutdown(): void; - /** * Executes commands inside the running 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. + * @throws DockerCommandExecutionFailed If the command execution cannot run. */ public function executeAfterStarted(array $commands): ExecutionCompleted; diff --git a/src/DisposableContainer.php b/src/DisposableContainer.php new file mode 100644 index 0000000..f2f9c61 --- /dev/null +++ b/src/DisposableContainer.php @@ -0,0 +1,26 @@ +Every container factory defaults to a client that invokes the local docker executable. A custom + * client replaces it, for example to route the commands to another Docker-compatible engine, or to stand in for + * the engine in the unit tests of code that builds containers.

+ */ +interface DockerClient +{ + /** + * Executes a Docker command within the default timeout of the client and returns its outcome. + * + * @param DockerCommand $command The Docker command to execute. + * @return ExecutionCompleted The outcome of the command. + * @throws DockerCommandExecutionFailed If the command cannot run or exceeds the default timeout. + */ + public function execute(DockerCommand $command): ExecutionCompleted; + + /** + * Executes a Docker command within the timeout the command carries and returns its outcome. + * + * @param DockerCommandWithTimeout $command The Docker command to execute. + * @return ExecutionCompleted The outcome of the command. + * @throws DockerCommandExecutionFailed If the command cannot run or exceeds its timeout. + */ + public function executeWithTimeout(DockerCommandWithTimeout $command): ExecutionCompleted; +} diff --git a/src/DockerCommand.php b/src/DockerCommand.php new file mode 100644 index 0000000..a008864 --- /dev/null +++ b/src/DockerCommand.php @@ -0,0 +1,24 @@ +A command bounded by a timeout of its own is a {@see DockerCommandWithTimeout}. Any other command runs within + * the default timeout of the client.

+ */ +interface DockerCommand +{ + /** + * Returns the DockerCommand as an ordered argument list. + * + *

The first element is the executable and the remaining elements are its arguments. No shell interpretation + * happens between elements, so values are passed through verbatim.

+ * + * @return array The ordered argument list. + */ + public function toArguments(): array; +} diff --git a/src/DockerCommandWithTimeout.php b/src/DockerCommandWithTimeout.php new file mode 100644 index 0000000..c8d1880 --- /dev/null +++ b/src/DockerCommandWithTimeout.php @@ -0,0 +1,20 @@ + $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; - - /** - * Runs the container only when the gate predicate holds, reusing an existing one if present, - * then passes the started container to the callback. Does nothing when the gate does not hold. - * - * @param Closure(): bool $gate Decides whether the container should run. - * @param Closure(ContainerStarted): void $then Receives the started container when the gate holds. - * @param array $commands Commands to execute on container startup. - * @param ContainerWaitAfterStarted|null $waitAfterStarted Optional wait strategy applied after - * the container starts. - * @throws DockerCommandExecutionFailed If the run command fails. - */ - public function runWhen( - Closure $gate, - Closure $then, - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): void; - - /** - * Starts pulling the container image in the background. When run() or runIfNotExists() - * is called, it waits for the pull to complete before starting the container. - * Calling this method on multiple containers before running them enables parallel image pulls. - * - * @return static The current container instance for method chaining. - */ - public function pullImage(): static; - - /** - * Sets the Docker network the container should join. The network is created - * automatically when the container is started via run() or runIfNotExists(), - * if it does not already exist. - * - * @param string $name The name of the Docker network. - * @return static The current container instance for method chaining. - */ - public function withNetwork(string $name): static; - - /** - * Runs the container only if a container with the same name does not already exist. - * The returned instance treats the container as shared: calling stopOnShutdown() or - * remove() on it has no effect, allowing the container to persist across multiple - * PHP processes (e.g., mutation testing). - * - * @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 = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): ContainerStarted; - - /** - * Registers a file or directory to be copied into the container after it starts. - * - * @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): static; - - /** - * Adds a port mapping between the host and the container. - * - * @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): static; - - /** - * Adds a volume mapping between the host and the container. - * - * @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): static; - - /** - * Sets a wait strategy to be applied before the container runs. - * - * @param ContainerWaitBeforeStarted $wait The wait strategy to apply before starting. - * @return static The current container instance for method chaining. - */ - public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static; - - /** - * Disables automatic removal of the container when it stops. - * - * @return static The current container instance for method chaining. - */ - public function withoutAutoRemove(): static; - - /** - * Adds an environment variable to the container. - * - * @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): static; -} diff --git a/src/EnvironmentFlag.php b/src/EnvironmentFlag.php deleted file mode 100644 index 3f57c8e..0000000 --- a/src/EnvironmentFlag.php +++ /dev/null @@ -1,28 +0,0 @@ - getenv($name) === '1'; - } -} diff --git a/src/Exceptions/ContainerExitUnsuccessful.php b/src/Exceptions/ContainerExitUnsuccessful.php new file mode 100644 index 0000000..99025db --- /dev/null +++ b/src/Exceptions/ContainerExitUnsuccessful.php @@ -0,0 +1,20 @@ + did not exit successfully. Output: <%s>.'; + + parent::__construct(message: sprintf($template, $name, trim($output))); + } +} diff --git a/src/Exceptions/ContainerWaitTimeout.php b/src/Exceptions/ContainerWaitTimeout.php new file mode 100644 index 0000000..7642167 --- /dev/null +++ b/src/Exceptions/ContainerWaitTimeout.php @@ -0,0 +1,41 @@ + did not exit within <%d> seconds.'; + + return new ContainerWaitTimeout(message: sprintf($template, $name, $timeout->toSeconds())); + } + + /** + * Creates a ContainerWaitTimeout for a readiness condition that did not hold within the timeout. + * + * @param Duration $timeout The timeout that elapsed. + * @return ContainerWaitTimeout The created exception. + */ + public static function forReadiness(Duration $timeout): ContainerWaitTimeout + { + $template = 'Container readiness check timed out after <%d> seconds.'; + + return new ContainerWaitTimeout(message: sprintf($template, $timeout->toSeconds())); + } +} diff --git a/src/Exceptions/DockerCommandExecutionFailed.php b/src/Exceptions/DockerCommandExecutionFailed.php new file mode 100644 index 0000000..5a19aed --- /dev/null +++ b/src/Exceptions/DockerCommandExecutionFailed.php @@ -0,0 +1,38 @@ + in Docker container. Reason: %s'; + + parent::__construct(message: sprintf($template, $command, $reason)); + } + + /** + * Creates a DockerCommandExecutionFailed from a command and the unsuccessful outcome it produced. + * + * @param DockerCommand $command The command that failed. + * @param ExecutionCompleted $execution The unsuccessful outcome of the command. + * @return DockerCommandExecutionFailed The created exception, with the command rendered shell-escaped. + */ + public static function fromCommand( + DockerCommand $command, + ExecutionCompleted $execution + ): DockerCommandExecutionFailed { + $rendered = implode(' ', array_map('escapeshellarg', $command->toArguments())); + + return new DockerCommandExecutionFailed(reason: $execution->getOutput(), command: $rendered); + } +} diff --git a/src/Internal/Exceptions/ImageNameEmpty.php b/src/Exceptions/ImageNameEmpty.php similarity index 66% rename from src/Internal/Exceptions/ImageNameEmpty.php rename to src/Exceptions/ImageNameEmpty.php index 76f0cdb..12c13ed 100644 --- a/src/Internal/Exceptions/ImageNameEmpty.php +++ b/src/Exceptions/ImageNameEmpty.php @@ -2,10 +2,13 @@ declare(strict_types=1); -namespace TinyBlocks\DockerContainer\Internal\Exceptions; +namespace TinyBlocks\DockerContainer\Exceptions; use InvalidArgumentException; +/** + * Raised when a container is created from an empty image name. + */ final class ImageNameEmpty extends InvalidArgumentException { public function __construct() diff --git a/src/FlywayContainer.php b/src/FlywayContainer.php deleted file mode 100644 index 3ef1cca..0000000 --- a/src/FlywayContainer.php +++ /dev/null @@ -1,132 +0,0 @@ -Each command runs in a fresh container with a unique generated name, removed once Flyway exits, so parallel + * test runs never collide on it. Every with* method returns a new definition and leaves the receiver + * untouched.

+ */ +final readonly class FlywayDockerContainer { - protected function __construct(private GenericDockerContainer $container) - { - } + private const string MIGRATIONS_ON_CONTAINER = '/flyway/migrations'; + private const int DEFAULT_TIMEOUT_IN_SECONDS = 120; - public static function from(string $image, ?string $name = null): static + private function __construct(private Duration $timeout, private GenericDockerContainer $container) { - return new static(container: GenericDockerContainer::from(image: $image, name: $name)); } - public function repair(): ContainerStarted - { - return $this->container->run(commands: ['repair']); + /** + * Creates a FlywayDockerContainer from a Flyway Docker image. + * + * @param string $image The Flyway Docker image name (e.g., "flyway/flyway:12-alpine"). + * @param DockerClient $client The client that executes the Docker commands, the local docker executable by + * default. + * @return FlywayDockerContainer The created container definition. + * @throws ImageNameEmpty If the image name is empty. + */ + public static function from( + string $image, + DockerClient $client = new CommandLineDockerClient() + ): FlywayDockerContainer { + return new FlywayDockerContainer( + timeout: Duration::fromSeconds(seconds: self::DEFAULT_TIMEOUT_IN_SECONDS), + container: GenericDockerContainer::from(image: $image, client: $client) + ); } - public function migrate(): ContainerStarted + /** + * Runs the Flyway repair command and blocks until Flyway exits. + * + * @return ExecutionCompleted The successful outcome, carrying the Flyway output. + * @throws DockerCommandExecutionFailed If the Flyway container cannot be created. + * @throws ContainerWaitTimeout If the database source is not ready, or Flyway does not exit within the timeout. + * @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, carrying the Flyway error output. + */ + public function repair(): ExecutionCompleted { - return $this->container->run(commands: ['migrate']); + return $this->container->runToCompletion(timeout: $this->timeout, commands: ['repair']); } - public function validate(): ContainerStarted + /** + * Runs the Flyway migrate command and blocks until Flyway exits. + * + * @return ExecutionCompleted The successful outcome, carrying the Flyway output. + * @throws DockerCommandExecutionFailed If the Flyway container cannot be created. + * @throws ContainerWaitTimeout If the database source is not ready, or Flyway does not exit within the timeout. + * @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, carrying the Flyway error output. + */ + public function migrate(): ExecutionCompleted { - return $this->container->run(commands: ['validate']); + return $this->container->runToCompletion(timeout: $this->timeout, commands: ['migrate']); } - public function pullImage(): static + /** + * Runs the Flyway validate command and blocks until Flyway exits. + * + * @return ExecutionCompleted The successful outcome, carrying the Flyway output. + * @throws DockerCommandExecutionFailed If the Flyway container cannot be created. + * @throws ContainerWaitTimeout If the database source is not ready, or Flyway does not exit within the timeout. + * @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, including a failed validation. + */ + public function validate(): ExecutionCompleted { - $this->container->pullImage(); - - return $this; + return $this->container->runToCompletion(timeout: $this->timeout, commands: ['validate']); } - public function withTable(string $table): static + /** + * Returns a copy of the FlywayDockerContainer with the history table replaced. + * + * @param string $table The history table name, "schema_history" by default once withSource() is called. + * @return FlywayDockerContainer The copy with the new history table. + */ + public function withTable(string $table): FlywayDockerContainer { - $this->container->withEnvironmentVariable(key: 'FLYWAY_TABLE', value: $table); - - return $this; + return $this->withEnvironmentVariable(key: 'FLYWAY_TABLE', value: $table); } - public function withSchema(string $schema): static + /** + * Returns a copy of the FlywayDockerContainer with the target schema replaced. + * + * @param string $schema The schema name, the source database by default once withSource() is called. + * @return FlywayDockerContainer The copy with the new target schema. + */ + public function withSchema(string $schema): FlywayDockerContainer { - $this->container->withEnvironmentVariable(key: 'FLYWAY_SCHEMAS', value: $schema); - - return $this; + return $this->withEnvironmentVariable(key: 'FLYWAY_SCHEMAS', value: $schema); } - public function withSource(string $password, string $username, MySQLContainerStarted $container): static - { + /** + * Returns a copy of the FlywayDockerContainer that migrates the database of a started MySQL container. + * + *

Configures the JDBC URL, the credentials, the target schema (from MYSQL_DATABASE) and the + * history table (schema_history), and waits until MySQL accepts connections before each command. + * Call withSchema() or withTable() after this method to override the defaults.

+ * + * @param string $password The database password. + * @param string $username The database username. + * @param MySQLContainerStarted $container The started MySQL container. + * @return FlywayDockerContainer The copy bound to the MySQL container. + */ + public function withSource( + string $password, + string $username, + MySQLContainerStarted $container + ): FlywayDockerContainer { $schema = $container->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE'); - - $this->container->withEnvironmentVariable(key: 'FLYWAY_URL', value: $container->getJdbcUrl()); - $this->container->withEnvironmentVariable(key: 'FLYWAY_USER', value: $username); - $this->container->withEnvironmentVariable(key: 'FLYWAY_TABLE', value: 'schema_history'); - $this->container->withEnvironmentVariable(key: 'FLYWAY_SCHEMAS', value: $schema); - $this->container->withEnvironmentVariable(key: 'FLYWAY_PASSWORD', value: $password); - $this->container->withWaitBeforeRun( - wait: ContainerWaitForDependency::untilReady(condition: MySQLReady::from(container: $container)) + $mySQLReady = ContainerWaitForDependency::untilReady(condition: MySQLReady::from(container: $container)); + + return new FlywayDockerContainer( + timeout: $this->timeout, + container: $this->container + ->withWaitBeforeRun(wait: $mySQLReady) + ->withEnvironmentVariable(key: 'FLYWAY_URL', value: $container->getJdbcUrl()) + ->withEnvironmentVariable(key: 'FLYWAY_USER', value: $username) + ->withEnvironmentVariable(key: 'FLYWAY_TABLE', value: 'schema_history') + ->withEnvironmentVariable(key: 'FLYWAY_SCHEMAS', value: $schema) + ->withEnvironmentVariable(key: 'FLYWAY_PASSWORD', value: $password) ); - - return $this; } - public function withNetwork(string $name): static + /** + * Returns a copy of the FlywayDockerContainer with the Docker network replaced. + * + * @param string $name The name of the Docker network. + * @return FlywayDockerContainer The copy with the new network. + */ + public function withNetwork(string $name): FlywayDockerContainer { - $this->container->withNetwork(name: $name); - - return $this; + return new FlywayDockerContainer( + timeout: $this->timeout, + container: $this->container->withNetwork(name: $name) + ); } - public function withMigrations(string $pathOnHost): static + /** + * Returns a copy of the FlywayDockerContainer with the timeout of each command replaced. + * + *

The timeout bounds how long a command waits for Flyway to exit. It defaults to 120 seconds.

+ * + * @param Duration $timeout The longest time a command waits for Flyway to exit. + * @return FlywayDockerContainer The copy with the new timeout. + */ + public function withTimeout(Duration $timeout): FlywayDockerContainer { - $this->container->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: '/flyway/migrations'); - $this->container->withEnvironmentVariable(key: 'FLYWAY_LOCATIONS', value: 'filesystem:/flyway/migrations'); - - return $this; + return new FlywayDockerContainer(timeout: $timeout, container: $this->container); } - public function cleanAndMigrate(): ContainerStarted + /** + * Returns a copy of the FlywayDockerContainer that applies the migrations of a host directory. + * + *

The directory is copied into the container at /flyway/migrations before Flyway starts.

+ * + * @param string $pathOnHost The absolute path on the host holding the migration files. + * @return FlywayDockerContainer The copy with the migrations. + */ + public function withMigrations(string $pathOnHost): FlywayDockerContainer { - return $this->container->run( - commands: ['clean', 'migrate'], - waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 10) + $template = 'filesystem:%s'; + + return new FlywayDockerContainer( + timeout: $this->timeout, + container: $this->container + ->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: self::MIGRATIONS_ON_CONTAINER) + ->withEnvironmentVariable( + key: 'FLYWAY_LOCATIONS', + value: sprintf($template, self::MIGRATIONS_ON_CONTAINER) + ) ); } - public function withCleanDisabled(bool $disabled): static + /** + * Runs the Flyway clean command followed by migrate, and blocks until Flyway exits. + * + *

Enables the Flyway clean command for this run, which Flyway disables by default. The migrations are + * applied when this method returns.

+ * + * @return ExecutionCompleted The successful outcome, carrying the Flyway output. + * @throws DockerCommandExecutionFailed If the Flyway container cannot be created. + * @throws ContainerWaitTimeout If the database source is not ready, or Flyway does not exit within the timeout. + * @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, carrying the Flyway error output. + */ + public function cleanAndMigrate(): ExecutionCompleted { - $this->container->withEnvironmentVariable(key: 'FLYWAY_CLEAN_DISABLED', value: $disabled ? 'true' : 'false'); - - return $this; + return $this->container + ->withEnvironmentVariable(key: 'FLYWAY_CLEAN_DISABLED', value: 'false') + ->runToCompletion(timeout: $this->timeout, commands: ['clean', 'migrate']); } - public function withConnectRetries(int $retries): static + /** + * Returns a copy of the FlywayDockerContainer with the number of database connection retries replaced. + * + * @param int $retries The number of connection retries, one second apart. + * @return FlywayDockerContainer The copy with the new number of retries. + */ + public function withConnectRetries(int $retries): FlywayDockerContainer { - $this->container->withEnvironmentVariable(key: 'FLYWAY_CONNECT_RETRIES', value: (string)$retries); - - return $this; + return $this->withEnvironmentVariable(key: 'FLYWAY_CONNECT_RETRIES', value: (string)$retries); } - public function withValidateMigrationNaming(bool $enabled): static + /** + * Returns a copy of the FlywayDockerContainer with an environment variable added. + * + *

Any Flyway setting can be configured through its FLYWAY_* environment variable.

+ * + * @param string $key The environment variable name. + * @param string $value The environment variable value. + * @return FlywayDockerContainer The copy with the added environment variable. + */ + public function withEnvironmentVariable(string $key, string $value): FlywayDockerContainer { - $this->container->withEnvironmentVariable( - key: 'FLYWAY_VALIDATE_MIGRATION_NAMING', - value: $enabled ? 'true' : 'false' + return new FlywayDockerContainer( + timeout: $this->timeout, + container: $this->container->withEnvironmentVariable(key: $key, value: $value) ); + } - return $this; + /** + * Returns a copy of the FlywayDockerContainer that rejects migrations whose file names break the Flyway + * naming convention, which Flyway otherwise skips silently. + * + * @return FlywayDockerContainer The copy with migration naming validation enabled. + */ + public function withMigrationNamingValidation(): FlywayDockerContainer + { + return $this->withEnvironmentVariable(key: 'FLYWAY_VALIDATE_MIGRATION_NAMING', value: 'true'); } } diff --git a/src/GenericDockerContainer.php b/src/GenericDockerContainer.php index fc48676..cd570ac 100644 --- a/src/GenericDockerContainer.php +++ b/src/GenericDockerContainer.php @@ -4,144 +4,281 @@ namespace TinyBlocks\DockerContainer; -use Closure; -use TinyBlocks\DockerContainer\Internal\Client\DockerClient; -use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler; -use TinyBlocks\DockerContainer\Internal\CommandHandler\ContainerCommandHandler; -use TinyBlocks\DockerContainer\Internal\Commands\DockerPull; -use TinyBlocks\DockerContainer\Internal\Commands\DockerRun; -use TinyBlocks\DockerContainer\Internal\Containers\ContainerReaper; +use TinyBlocks\DockerContainer\Exceptions\ContainerExitUnsuccessful; +use TinyBlocks\DockerContainer\Exceptions\ContainerWaitTimeout; +use TinyBlocks\DockerContainer\Exceptions\DockerCommandExecutionFailed; +use TinyBlocks\DockerContainer\Exceptions\ImageNameEmpty; +use TinyBlocks\DockerContainer\Internal\Client\CommandLineDockerClient; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerLauncher; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerLookup; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerSharing; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerWaits; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; -use TinyBlocks\DockerContainer\Internal\Containers\RegisteredShutdownHook; -use TinyBlocks\DockerContainer\Internal\Containers\Reused; +use TinyBlocks\DockerContainer\Internal\Containers\OneShotContainer; use TinyBlocks\DockerContainer\Waits\ContainerWaitAfterStarted; use TinyBlocks\DockerContainer\Waits\ContainerWaitBeforeStarted; - -class GenericDockerContainer implements DockerContainer +use TinyBlocks\Time\Duration; + +/** + * Immutable definition of a Docker container built from any image, which runs it on demand. + * + *

Every with* method returns a new definition and leaves the receiver untouched.

+ */ +final readonly class GenericDockerContainer { - private ?ContainerWaitBeforeStarted $waitBeforeStarted = null; - - protected function __construct( - private readonly ContainerReaper $reaper, - protected ContainerDefinition $definition, - private readonly CommandHandler $commandHandler + private function __construct( + private ContainerWaits $waits, + private DockerClient $client, + private ContainerDefinition $definition ) { } - public static function from(string $image, ?string $name = null): static - { - $client = new DockerClient(); - $definition = ContainerDefinition::create(image: $image, name: $name); - $reaper = new ContainerReaper(client: $client); - $commandHandler = new ContainerCommandHandler(client: $client, shutdownHook: new RegisteredShutdownHook()); - - return new static(reaper: $reaper, definition: $definition, commandHandler: $commandHandler); + /** + * Creates a GenericDockerContainer from a Docker image. + * + *

The container gets a unique generated name until withName() replaces it.

+ * + * @param string $image The Docker image name (e.g., "mysql:8.4"). + * @param DockerClient $client The client that executes the Docker commands, the local docker executable by + * default. + * @return GenericDockerContainer The created container definition. + * @throws ImageNameEmpty If the image name is empty. + */ + public static function from( + string $image, + DockerClient $client = new CommandLineDockerClient() + ): GenericDockerContainer { + return new GenericDockerContainer( + waits: ContainerWaits::none(), + client: $client, + definition: ContainerDefinition::create(image: $image) + ); } - public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted + /** + * Creates and starts the container, then applies the wait strategies registered with withWaitAfterStarted(). + * + *

Files registered with copyToContainer() are copied after the container is created and before it + * starts.

+ * + * @param array $commands The command, and its arguments, the container runs on startup. + * @return DisposableContainer The started container, owned by this process. + * @throws DockerCommandExecutionFailed If a Docker command fails. + * @throws ContainerWaitTimeout If a wait strategy is not satisfied within its timeout. + */ + public function run(array $commands = []): DisposableContainer { - $this->waitBeforeStarted?->waitBefore(); + $this->waits->waitBeforeRun(); - $dockerRun = DockerRun::from(definition: $this->definition, commands: $commands); - $containerStarted = $this->commandHandler->run(dockerRun: $dockerRun); + $launcher = new ContainerLauncher(client: $this->client); + $id = $launcher->start(commands: $commands, definition: $this->definition); + $containerStarted = new ContainerLookup(client: $this->client)->byId(id: $id, name: $this->definition->name); - $waitAfterStarted?->waitAfter(containerStarted: $containerStarted); + $this->waits->waitAfterStarted(containerStarted: $containerStarted); return $containerStarted; } - public function runWhen( - Closure $gate, - Closure $then, - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): void { - if (!$gate()) { - return; - } - - $then($this->runIfNotExists(commands: $commands, waitAfterStarted: $waitAfterStarted)); + /** + * Returns a copy of the GenericDockerContainer with the container name replaced. + * + *

The name identifies the container that runIfNotExists() shares among processes.

+ * + * @param string $name The container name. + * @return GenericDockerContainer The copy with the new name. + */ + public function withName(string $name): GenericDockerContainer + { + return new GenericDockerContainer( + waits: $this->waits, + client: $this->client, + definition: $this->definition->withName(name: $name) + ); } - public function pullImage(): static + /** + * Returns a copy of the GenericDockerContainer with the Docker network replaced. + * + *

The network is created when the container runs, if it does not exist yet, and labeled + * tiny-blocks.docker-container=true.

+ * + * @param string $name The name of the Docker network. + * @return GenericDockerContainer The copy with the new network. + */ + public function withNetwork(string $name): GenericDockerContainer { - $this->commandHandler->execute(command: DockerPull::from(image: $this->definition->image->name)); - - return $this; + return new GenericDockerContainer( + waits: $this->waits, + client: $this->client, + definition: $this->definition->withNetwork(name: $name) + ); } - public function withNetwork(string $name): static + /** + * Starts the container unless one with the same name already exists, in which case that one is reused. + * + *

The returned container is shared by every PHP process that asks for the same name, as in mutation + * testing, so no single process removes it. When running inside Docker, a reaper container removes it once the + * current test runner container exits. The wait strategies registered with withWaitAfterStarted() apply only to + * a newly started container.

+ * + * @param array $commands The command, and its arguments, a new container runs on startup. + * @return SharedContainer The existing or newly started container. + * @throws DockerCommandExecutionFailed If a Docker command fails. + * @throws ContainerWaitTimeout If a wait strategy is not satisfied within its timeout. + */ + public function runIfNotExists(array $commands = []): SharedContainer { - $this->definition = $this->definition->withNetwork(name: $name); + return new ContainerSharing(client: $this->client)->share( + name: $this->definition->name, + start: fn(): DisposableContainer => $this->run(commands: $commands) + ); + } - return $this; + /** + * Returns a copy of the GenericDockerContainer with a file or directory added to copy into the container. + * + *

The copy happens after the container is created and before it starts, so its command already sees + * the files.

+ * + * @param string $pathOnHost The absolute path on the host. + * @param string $pathOnContainer The target path inside the container. + * @return GenericDockerContainer The copy with the added file or directory. + */ + public function copyToContainer(string $pathOnHost, string $pathOnContainer): GenericDockerContainer + { + return new GenericDockerContainer( + waits: $this->waits, + client: $this->client, + definition: $this->definition->withCopyInstruction( + pathOnHost: $pathOnHost, + pathOnContainer: $pathOnContainer + ) + ); } - public function runIfNotExists( - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): ContainerStarted { - $existing = $this->commandHandler->findBy(definition: $this->definition); + /** + * Runs the container until it exits and returns what it wrote. + * + *

The container is created, receives the files registered with copyToContainer(), then starts attached. + * It is removed once it exits, successfully or not, or once the timeout elapses.

+ * + * @param Duration $timeout The longest time the container may run. + * @param array $commands The command, and its arguments, the container runs. + * @return ExecutionCompleted The successful outcome, carrying the standard output of the container. + * @throws DockerCommandExecutionFailed If the container cannot be created or receive its files. + * @throws ContainerWaitTimeout If a wait strategy before the run is not satisfied, or if the container does + * not exit within the timeout. + * @throws ContainerExitUnsuccessful If the container exits with a non-zero status, carrying its error output. + */ + public function runToCompletion(Duration $timeout, array $commands = []): ExecutionCompleted + { + $this->waits->waitBeforeRun(); - if (!is_null($existing)) { - return new Reused(reaper: $this->reaper, wasReused: true, containerStarted: $existing); - } + $oneShot = new OneShotContainer(client: $this->client); - return new Reused( - reaper: $this->reaper, - wasReused: false, - containerStarted: $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted) - ); + return $oneShot->run(timeout: $timeout, commands: $commands, definition: $this->definition); } - public function copyToContainer(string $pathOnHost, string $pathOnContainer): static + /** + * Returns a copy of the GenericDockerContainer with a port mapping added between the host and the container. + * + * @param int $portOnHost The port on the host machine, or 0 for a port Docker picks. + * @param int $portOnContainer The port inside the container. + * @return GenericDockerContainer The copy with the added port mapping. + */ + public function withPortMapping(int $portOnHost, int $portOnContainer): GenericDockerContainer { - $this->definition = $this->definition->withCopyInstruction( - pathOnHost: $pathOnHost, - pathOnContainer: $pathOnContainer + return new GenericDockerContainer( + waits: $this->waits, + client: $this->client, + definition: $this->definition->withPortMapping(portOnHost: $portOnHost, portOnContainer: $portOnContainer) ); - - return $this; } - public function withPortMapping(int $portOnHost, int $portOnContainer): static + /** + * Returns a copy of the GenericDockerContainer with a volume mapping added between the host and the container. + * + * @param string $pathOnHost The absolute path on the host. + * @param string $pathOnContainer The target path inside the container. + * @return GenericDockerContainer The copy with the added volume mapping. + */ + public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): GenericDockerContainer { - $this->definition = $this->definition->withPortMapping( - portOnHost: $portOnHost, - portOnContainer: $portOnContainer + return new GenericDockerContainer( + waits: $this->waits, + client: $this->client, + definition: $this->definition->withVolumeMapping( + pathOnHost: $pathOnHost, + pathOnContainer: $pathOnContainer + ) ); - - return $this; } - public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): static + /** + * Returns a copy of the GenericDockerContainer with a wait strategy added, applied before the container runs. + * + *

The wait strategies apply in the order they were added.

+ * + * @param ContainerWaitBeforeStarted $wait The wait strategy applied before the container runs. + * @return GenericDockerContainer The copy with the added wait strategy. + */ + public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): GenericDockerContainer { - $this->definition = $this->definition->withVolumeMapping( - pathOnHost: $pathOnHost, - pathOnContainer: $pathOnContainer + return new GenericDockerContainer( + waits: $this->waits->withBeforeRun(wait: $wait), + client: $this->client, + definition: $this->definition ); - - return $this; } - public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static + /** + * Returns a copy of the GenericDockerContainer that Docker keeps after it stops. + * + *

By default, Docker removes the container once it stops.

+ * + * @return GenericDockerContainer The copy without automatic removal. + */ + public function withoutAutoRemove(): GenericDockerContainer { - $this->waitBeforeStarted = $wait; - - return $this; + return new GenericDockerContainer( + waits: $this->waits, + client: $this->client, + definition: $this->definition->withoutAutoRemove() + ); } - public function withoutAutoRemove(): static + /** + * Returns a copy of the GenericDockerContainer with a wait strategy added, applied after run() starts the + * container. + * + *

The wait strategies apply in the order they were added.

+ * + * @param ContainerWaitAfterStarted $wait The wait strategy applied after the container starts. + * @return GenericDockerContainer The copy with the added wait strategy. + */ + public function withWaitAfterStarted(ContainerWaitAfterStarted $wait): GenericDockerContainer { - $this->definition = $this->definition->withoutAutoRemove(); - - return $this; + return new GenericDockerContainer( + waits: $this->waits->withAfterStarted(wait: $wait), + client: $this->client, + definition: $this->definition + ); } - public function withEnvironmentVariable(string $key, string $value): static + /** + * Returns a copy of the GenericDockerContainer with an environment variable added. + * + * @param string $key The environment variable name. + * @param string $value The environment variable value. + * @return GenericDockerContainer The copy with the added environment variable. + */ + public function withEnvironmentVariable(string $key, string $value): GenericDockerContainer { - $this->definition = $this->definition->withEnvironmentVariable(key: $key, value: $value); - - return $this; + return new GenericDockerContainer( + waits: $this->waits, + client: $this->client, + definition: $this->definition->withEnvironmentVariable(key: $key, value: $value) + ); } } diff --git a/src/Internal/Client/Client.php b/src/Internal/Client/Client.php deleted file mode 100644 index b2979fe..0000000 --- a/src/Internal/Client/Client.php +++ /dev/null @@ -1,24 +0,0 @@ -process->run(); + } catch (Throwable $exception) { + throw new DockerCommandExecutionFailed( + reason: $exception->getMessage(), + command: $this->process->getCommandLine() + ); + } + + return Execution::from(process: $this->process); + } +} diff --git a/src/Internal/Client/CommandLineDockerClient.php b/src/Internal/Client/CommandLineDockerClient.php new file mode 100644 index 0000000..3a50691 --- /dev/null +++ b/src/Internal/Client/CommandLineDockerClient.php @@ -0,0 +1,26 @@ +toArguments()))->run(); + } + + public function executeWithTimeout(DockerCommandWithTimeout $command): ExecutionCompleted + { + $process = new Process(command: $command->toArguments(), timeout: $command->timeout()->toSeconds()); + + return new CommandLine(process: $process)->run(); + } +} diff --git a/src/Internal/Client/DockerClient.php b/src/Internal/Client/DockerClient.php deleted file mode 100644 index a9ea304..0000000 --- a/src/Internal/Client/DockerClient.php +++ /dev/null @@ -1,32 +0,0 @@ -toArguments()); - - try { - if ($command instanceof CommandWithTimeout) { - $process->setTimeout(timeout: $command->getTimeoutInWholeSeconds()); - } - - $process->run(); - - return Execution::from(process: $process); - } catch (Throwable $exception) { - throw DockerCommandExecutionFailed::fromProcess(process: $process, exception: $exception); - } - } -} diff --git a/src/Internal/Client/StrictExecutor.php b/src/Internal/Client/StrictExecutor.php new file mode 100644 index 0000000..aad2596 --- /dev/null +++ b/src/Internal/Client/StrictExecutor.php @@ -0,0 +1,28 @@ +client->execute(command: $command); + + if (!$executionCompleted->isSuccessful()) { + throw DockerCommandExecutionFailed::fromCommand(command: $command, execution: $executionCompleted); + } + + return $executionCompleted; + } +} diff --git a/src/Internal/CommandHandler/CommandHandler.php b/src/Internal/CommandHandler/CommandHandler.php deleted file mode 100644 index 54368ba..0000000 --- a/src/Internal/CommandHandler/CommandHandler.php +++ /dev/null @@ -1,45 +0,0 @@ -lookup = new ContainerLookup(client: $client, shutdownHook: $shutdownHook); - } - - public function run(DockerRun $dockerRun): ContainerStarted - { - if (!is_null($dockerRun->definition->network)) { - $this->client->execute(command: DockerNetworkCreate::from(network: $dockerRun->definition->network)); - - if (HostEnvironment::isInsideDocker()) { - $this->client->execute( - command: DockerNetworkConnect::from( - network: $dockerRun->definition->network, - container: HostEnvironment::containerHostname() - ) - ); - } - } - - $executionCompleted = $this->client->execute(command: $dockerRun); - - if (!$executionCompleted->isSuccessful()) { - throw DockerCommandExecutionFailed::fromCommand(command: $dockerRun, execution: $executionCompleted); - } - - $id = ContainerId::from(value: $executionCompleted->getOutput()); - - $started = $this->lookup->byId(id: $id, definition: $dockerRun->definition, commandHandler: $this); - - $dockerRun->definition->copyInstructions->each( - actions: function (CopyInstruction $instruction) use ($id): void { - $this->client->execute(command: DockerCopy::from(id: $id, instruction: $instruction)); - } - ); - - 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->lookup->byId(id: $id, definition: $definition, commandHandler: $this); - } - - public function execute(Command $command): ExecutionCompleted - { - return $this->client->execute(command: $command); - } -} diff --git a/src/Internal/Commands/Command.php b/src/Internal/Commands/Command.php deleted file mode 100644 index 2117217..0000000 --- a/src/Internal/Commands/Command.php +++ /dev/null @@ -1,22 +0,0 @@ - Ordered argument list. - */ - public function toArguments(): array; -} diff --git a/src/Internal/Commands/CommandWithTimeout.php b/src/Internal/Commands/CommandWithTimeout.php deleted file mode 100644 index 0fc623d..0000000 --- a/src/Internal/Commands/CommandWithTimeout.php +++ /dev/null @@ -1,18 +0,0 @@ -definition->name->value; + + return [ + 'docker', + 'create', + '--user', + 'root', + '--name', + $name, + '--hostname', + $name, + '--label', + self::MANAGED_LABEL, + ...$this->definition->toArguments(), + $this->definition->image->name, + ...$this->commands->toArray() + ]; + } +} diff --git a/src/Internal/Commands/DockerExecute.php b/src/Internal/Commands/DockerExecute.php index dd159b1..b2a6a73 100644 --- a/src/Internal/Commands/DockerExecute.php +++ b/src/Internal/Commands/DockerExecute.php @@ -5,9 +5,10 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; use TinyBlocks\Collection\Collection; +use TinyBlocks\DockerContainer\DockerCommand; use TinyBlocks\DockerContainer\Internal\Containers\Name; -final readonly class DockerExecute implements Command +final readonly class DockerExecute implements DockerCommand { private function __construct(private Name $name, private Collection $commands) { diff --git a/src/Internal/Commands/DockerInspect.php b/src/Internal/Commands/DockerInspect.php index 976c9c5..e635e6b 100644 --- a/src/Internal/Commands/DockerInspect.php +++ b/src/Internal/Commands/DockerInspect.php @@ -4,9 +4,10 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; +use TinyBlocks\DockerContainer\DockerCommand; use TinyBlocks\DockerContainer\Internal\Containers\ContainerId; -final readonly class DockerInspect implements Command +final readonly class DockerInspect implements DockerCommand { private function __construct(private ContainerId $id) { diff --git a/src/Internal/Commands/DockerList.php b/src/Internal/Commands/DockerList.php index ce258fe..32eca97 100644 --- a/src/Internal/Commands/DockerList.php +++ b/src/Internal/Commands/DockerList.php @@ -4,11 +4,12 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; +use TinyBlocks\DockerContainer\DockerCommand; use TinyBlocks\DockerContainer\Internal\Containers\Name; -final readonly class DockerList implements Command +final readonly class DockerList implements DockerCommand { - private function __construct(public Name $name) + private function __construct(private Name $name) { } diff --git a/src/Internal/Commands/DockerNetworkConnect.php b/src/Internal/Commands/DockerNetworkConnect.php index 5718c06..99704f1 100644 --- a/src/Internal/Commands/DockerNetworkConnect.php +++ b/src/Internal/Commands/DockerNetworkConnect.php @@ -4,19 +4,22 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; -final readonly class DockerNetworkConnect implements Command +use TinyBlocks\DockerContainer\DockerCommand; +use TinyBlocks\DockerContainer\Internal\Containers\Definitions\Network; + +final readonly class DockerNetworkConnect implements DockerCommand { - private function __construct(private string $network, private string $container) + private function __construct(private Network $network, private string $container) { } - public static function from(string $network, string $container): DockerNetworkConnect + public static function from(Network $network, string $container): DockerNetworkConnect { return new DockerNetworkConnect(network: $network, container: $container); } public function toArguments(): array { - return ['docker', 'network', 'connect', $this->network, $this->container]; + return ['docker', 'network', 'connect', $this->network->name, $this->container]; } } diff --git a/src/Internal/Commands/DockerNetworkCreate.php b/src/Internal/Commands/DockerNetworkCreate.php index 4a5ffb9..7cd0d38 100644 --- a/src/Internal/Commands/DockerNetworkCreate.php +++ b/src/Internal/Commands/DockerNetworkCreate.php @@ -4,19 +4,22 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; -final readonly class DockerNetworkCreate implements Command +use TinyBlocks\DockerContainer\DockerCommand; +use TinyBlocks\DockerContainer\Internal\Containers\Definitions\Network; + +final readonly class DockerNetworkCreate implements DockerCommand { - private function __construct(private string $network) + private function __construct(private Network $network) { } - public static function from(string $network): DockerNetworkCreate + public static function from(Network $network): DockerNetworkCreate { return new DockerNetworkCreate(network: $network); } public function toArguments(): array { - return ['docker', 'network', 'create', '--label', DockerRun::MANAGED_LABEL, $this->network]; + return ['docker', 'network', 'create', '--label', DockerCreate::MANAGED_LABEL, $this->network->name]; } } diff --git a/src/Internal/Commands/DockerNetworkPrune.php b/src/Internal/Commands/DockerNetworkPrune.php index b15ec9b..dec12c1 100644 --- a/src/Internal/Commands/DockerNetworkPrune.php +++ b/src/Internal/Commands/DockerNetworkPrune.php @@ -4,7 +4,9 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; -final readonly class DockerNetworkPrune implements Command +use TinyBlocks\DockerContainer\DockerCommand; + +final readonly class DockerNetworkPrune implements DockerCommand { private function __construct() { @@ -18,7 +20,7 @@ public static function create(): DockerNetworkPrune public function toArguments(): array { $template = 'label=%s'; - $filter = sprintf($template, DockerRun::MANAGED_LABEL); + $filter = sprintf($template, DockerCreate::MANAGED_LABEL); return ['docker', 'network', 'prune', '--force', '--filter', $filter]; } diff --git a/src/Internal/Commands/DockerPull.php b/src/Internal/Commands/DockerPull.php deleted file mode 100644 index 65b56d8..0000000 --- a/src/Internal/Commands/DockerPull.php +++ /dev/null @@ -1,22 +0,0 @@ -image]; - } -} diff --git a/src/Internal/Commands/DockerReaper.php b/src/Internal/Commands/DockerReaper.php index 48d250c..ab2701d 100644 --- a/src/Internal/Commands/DockerReaper.php +++ b/src/Internal/Commands/DockerReaper.php @@ -4,16 +4,19 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; -final readonly class DockerReaper implements Command +use TinyBlocks\DockerContainer\DockerCommand; +use TinyBlocks\DockerContainer\Internal\Containers\Name; + +final readonly class DockerReaper implements DockerCommand { private function __construct( - private string $reaperName, - private string $containerName, + private Name $reaperName, + private Name $containerName, private string $testRunnerHostname ) { } - public static function from(string $reaperName, string $containerName, string $testRunnerHostname): DockerReaper + public static function from(Name $reaperName, Name $containerName, string $testRunnerHostname): DockerReaper { return new DockerReaper( reaperName: $reaperName, @@ -22,34 +25,35 @@ public static function from(string $reaperName, string $containerName, string $t ); } - private function buildScript(): string + public function toArguments(): array { $template = implode(' ', [ 'while docker inspect %s >/dev/null 2>&1; do sleep 2; done;', 'docker rm -fv %s 2>/dev/null;', 'docker network prune -f --filter label=%s 2>/dev/null' ]); + $script = sprintf( + $template, + $this->testRunnerHostname, + $this->containerName->value, + DockerCreate::MANAGED_LABEL + ); - return sprintf($template, $this->testRunnerHostname, $this->containerName, DockerRun::MANAGED_LABEL); - } - - public function toArguments(): array - { return [ 'docker', 'run', '--rm', '-d', '--name', - $this->reaperName, + $this->reaperName->value, '--label', - DockerRun::MANAGED_LABEL, + DockerCreate::MANAGED_LABEL, '-v', '/var/run/docker.sock:/var/run/docker.sock', 'docker:cli', 'sh', '-c', - $this->buildScript() + $script ]; } } diff --git a/src/Internal/Commands/DockerRemove.php b/src/Internal/Commands/DockerRemove.php index 2ac2258..fc954cf 100644 --- a/src/Internal/Commands/DockerRemove.php +++ b/src/Internal/Commands/DockerRemove.php @@ -4,9 +4,10 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; +use TinyBlocks\DockerContainer\DockerCommand; use TinyBlocks\DockerContainer\Internal\Containers\ContainerId; -final readonly class DockerRemove implements Command +final readonly class DockerRemove implements DockerCommand { private function __construct(private ContainerId $id) { diff --git a/src/Internal/Commands/DockerRun.php b/src/Internal/Commands/DockerRun.php deleted file mode 100644 index 354267d..0000000 --- a/src/Internal/Commands/DockerRun.php +++ /dev/null @@ -1,82 +0,0 @@ -definition->name->value; - - $arguments = [ - 'docker', - 'run', - '--user', - 'root', - '--name', - $name, - '--hostname', - $name, - '--label', - self::MANAGED_LABEL - ]; - - $portArguments = $this->definition->portMappings->reduce( - accumulator: static fn(array $carry, PortMapping $port): array => [...$carry, ...$port->toArguments()], - initial: [] - ); - $arguments = [...$arguments, ...$portArguments]; - - if (!is_null($this->definition->network)) { - $template = '--network=%s'; - $arguments[] = sprintf($template, $this->definition->network); - } - - $volumeArguments = $this->definition->volumeMappings->reduce( - accumulator: static fn(array $carry, VolumeMapping $volume): array => [ - ...$carry, - ...$volume->toArguments() - ], - initial: [] - ); - $arguments = [...$arguments, ...$volumeArguments]; - - $arguments[] = '--detach'; - - if ($this->definition->autoRemove) { - $arguments[] = '--rm'; - } - - $environmentArguments = $this->definition->environmentVariables->reduce( - accumulator: static fn(array $carry, EnvironmentVariable $environment): array => [ - ...$carry, - ...$environment->toArguments() - ], - initial: [] - ); - $arguments = [...$arguments, ...$environmentArguments]; - - $arguments[] = $this->definition->image->name; - - return [...$arguments, ...$this->commands->toArray()]; - } -} diff --git a/src/Internal/Commands/DockerStart.php b/src/Internal/Commands/DockerStart.php new file mode 100644 index 0000000..19844b5 --- /dev/null +++ b/src/Internal/Commands/DockerStart.php @@ -0,0 +1,25 @@ +id->value]; + } +} diff --git a/src/Internal/Commands/DockerStartAttached.php b/src/Internal/Commands/DockerStartAttached.php new file mode 100644 index 0000000..c987521 --- /dev/null +++ b/src/Internal/Commands/DockerStartAttached.php @@ -0,0 +1,31 @@ +timeout; + } + + public function toArguments(): array + { + return ['docker', 'start', '--attach', $this->id->value]; + } +} diff --git a/src/Internal/Commands/DockerStop.php b/src/Internal/Commands/DockerStop.php index 2bbb804..beec889 100644 --- a/src/Internal/Commands/DockerStop.php +++ b/src/Internal/Commands/DockerStop.php @@ -4,33 +4,30 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; +use TinyBlocks\DockerContainer\DockerCommandWithTimeout; use TinyBlocks\DockerContainer\Internal\Containers\ContainerId; -use TinyBlocks\DockerContainer\Internal\Exceptions\StopTimeoutOutOfRange; +use TinyBlocks\Time\Duration; -final readonly class DockerStop implements CommandWithTimeout +final readonly class DockerStop implements DockerCommandWithTimeout { - private const int PROCESS_TIMEOUT_BUFFER_IN_WHOLE_SECONDS = 10; + private const int PROCESS_TIMEOUT_BUFFER_IN_SECONDS = 10; - private function __construct(private ContainerId $id, private int $gracefulTimeoutInWholeSeconds) + private function __construct(private ContainerId $id, private Duration $gracePeriod) { } - public static function from(ContainerId $id, int $timeoutInWholeSeconds): DockerStop + public static function from(ContainerId $id, Duration $gracePeriod): DockerStop { - if ($timeoutInWholeSeconds < 0) { - throw new StopTimeoutOutOfRange(timeoutInWholeSeconds: $timeoutInWholeSeconds); - } - - return new DockerStop(id: $id, gracefulTimeoutInWholeSeconds: $timeoutInWholeSeconds); + return new DockerStop(id: $id, gracePeriod: $gracePeriod); } - public function toArguments(): array + public function timeout(): Duration { - return ['docker', 'stop', '--time', (string)$this->gracefulTimeoutInWholeSeconds, $this->id->value]; + return $this->gracePeriod->plus(other: Duration::fromSeconds(seconds: self::PROCESS_TIMEOUT_BUFFER_IN_SECONDS)); } - public function getTimeoutInWholeSeconds(): int + public function toArguments(): array { - return ($this->gracefulTimeoutInWholeSeconds + self::PROCESS_TIMEOUT_BUFFER_IN_WHOLE_SECONDS); + return ['docker', 'stop', '--time', (string)$this->gracePeriod->toSeconds(), $this->id->value]; } } diff --git a/src/Internal/Containers/Address/Ports.php b/src/Internal/Containers/Address/Ports.php index 0253abe..cf98ac7 100644 --- a/src/Internal/Containers/Address/Ports.php +++ b/src/Internal/Containers/Address/Ports.php @@ -15,14 +15,20 @@ private function __construct(private array $exposedPorts, private array $hostMap { } - public static function from(Collection $exposedPorts, Collection $hostMappedPorts): Ports + public static function from(array $exposedPorts, array $portBindings): Ports { + $isAssigned = static fn(int $port): bool => $port !== 0; + return new Ports( - exposedPorts: $exposedPorts - ->filter(predicates: static fn(int $port): bool => $port !== 0) + exposedPorts: Collection::createFrom(elements: array_keys($exposedPorts)) + ->map(transformations: static fn(string $port): int => (int)explode('/', $port)[0]) + ->filter(predicates: $isAssigned) ->toArray(keyPreservation: KeyPreservation::DISCARD), - hostMappedPorts: $hostMappedPorts - ->filter(predicates: static fn(int $port): bool => $port !== 0) + hostMappedPorts: Collection::createFrom(elements: $portBindings) + ->filter() + ->flatten() + ->map(transformations: static fn(array $binding): int => (int)($binding['HostPort'] ?? 0)) + ->filter(predicates: $isAssigned) ->toArray(keyPreservation: KeyPreservation::DISCARD) ); } diff --git a/src/Internal/Containers/ContainerInspection.php b/src/Internal/Containers/ContainerInspection.php index 2a2f5e3..1c0444e 100644 --- a/src/Internal/Containers/ContainerInspection.php +++ b/src/Internal/Containers/ContainerInspection.php @@ -27,54 +27,17 @@ public static function from(array $inspectResult): ContainerInspection public function toAddress(): Address { - $networks = ($this->inspectResult['NetworkSettings']['Networks'] ?? []); $configuration = ($this->inspectResult['Config'] ?? []); - - $ip = IP::from(value: !empty($networks) ? ($networks[key($networks)]['IPAddress'] ?? '') : ''); - $hostname = Hostname::from(value: ($configuration['Hostname'] ?? '')); + $networkSettings = ($this->inspectResult['NetworkSettings'] ?? []); + $network = array_first(($networkSettings['Networks'] ?? [])); return Address::from( - ip: $ip, + ip: IP::from(value: ($network['IPAddress'] ?? '')), ports: Ports::from( - exposedPorts: $this->exposedPorts(configuration: $configuration), - hostMappedPorts: $this->hostMappedPorts() + exposedPorts: ($configuration['ExposedPorts'] ?? []), + portBindings: ($networkSettings['Ports'] ?? []) ), - hostname: $hostname - ); - } - - private function exposedPorts(array $configuration): Collection - { - $rawExposedPorts = ($configuration['ExposedPorts'] ?? []); - - return Collection::createFrom( - elements: array_map( - static fn(string $port): int => (int)explode('/', $port)[0], - array_keys($rawExposedPorts) - ) - ); - } - - private function hostMappedPorts(): Collection - { - $rawHostPorts = ($this->inspectResult['NetworkSettings']['Ports'] ?? []); - - return Collection::createFrom( - elements: array_reduce( - $rawHostPorts, - static function (array $ports, ?array $bindings): array { - if (is_null($bindings)) { - return $ports; - } - - foreach ($bindings as $binding) { - $ports[] = (int)($binding['HostPort'] ?? 0); - } - - return $ports; - }, - [] - ) + hostname: Hostname::from(value: ($configuration['Hostname'] ?? '')) ); } diff --git a/src/Internal/Containers/ContainerLauncher.php b/src/Internal/Containers/ContainerLauncher.php new file mode 100644 index 0000000..9e97071 --- /dev/null +++ b/src/Internal/Containers/ContainerLauncher.php @@ -0,0 +1,50 @@ +create(commands: $commands, definition: $definition); + + new StrictExecutor(client: $this->client)->execute(command: DockerStart::from(id: $id)); + + return $id; + } + + public function create(array $commands, ContainerDefinition $definition): ContainerId + { + $provisioner = new NetworkProvisioner(client: $this->client); + $definition->networks->each( + actions: static fn(Network $network) => $provisioner->provision(network: $network) + ); + + $dockerCreate = DockerCreate::from(commands: $commands, definition: $definition); + $creation = new StrictExecutor(client: $this->client)->execute(command: $dockerCreate); + $id = ContainerId::from(value: $creation->getOutput()); + + $definition->copyInstructions->each( + actions: fn(CopyInstruction $instruction) => $this->client->execute( + command: DockerCopy::from(id: $id, instruction: $instruction) + ) + ); + + return $id; + } +} diff --git a/src/Internal/Containers/ContainerLookup.php b/src/Internal/Containers/ContainerLookup.php index 2547580..313075a 100644 --- a/src/Internal/Containers/ContainerLookup.php +++ b/src/Internal/Containers/ContainerLookup.php @@ -4,41 +4,31 @@ namespace TinyBlocks\DockerContainer\Internal\Containers; -use TinyBlocks\DockerContainer\ContainerStarted; -use TinyBlocks\DockerContainer\Internal\Client\Client; -use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler; +use TinyBlocks\DockerContainer\DockerClient; use TinyBlocks\DockerContainer\Internal\Commands\DockerInspect; -use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerContainerNotFound; final readonly class ContainerLookup { - public function __construct(private Client $client, private ShutdownHook $shutdownHook) + public function __construct(private DockerClient $client) { } - public function byId( - ContainerId $id, - ContainerDefinition $definition, - CommandHandler $commandHandler - ): ContainerStarted { - $dockerInspect = DockerInspect::from(id: $id); - $executionCompleted = $this->client->execute(command: $dockerInspect); - - $inspectPayload = json_decode($executionCompleted->getOutput(), true); + public function byId(ContainerId $id, Name $name): Started + { + $inspectPayload = json_decode($this->client->execute(command: DockerInspect::from(id: $id))->getOutput(), true); if (!is_array($inspectPayload) || empty($inspectPayload[0]) || !is_array($inspectPayload[0])) { - throw new DockerContainerNotFound(name: $definition->name); + throw new DockerContainerNotFound(name: $name); } $inspection = ContainerInspection::from(inspectResult: $inspectPayload[0]); return new Started( id: $id, - name: $definition->name, + name: $name, + client: $this->client, address: $inspection->toAddress(), - shutdownHook: $this->shutdownHook, - commandHandler: $commandHandler, environmentVariables: $inspection->toEnvironmentVariables() ); } diff --git a/src/Internal/Containers/ContainerReaper.php b/src/Internal/Containers/ContainerReaper.php index 38f2870..7f346b4 100644 --- a/src/Internal/Containers/ContainerReaper.php +++ b/src/Internal/Containers/ContainerReaper.php @@ -4,28 +4,26 @@ namespace TinyBlocks\DockerContainer\Internal\Containers; -use TinyBlocks\DockerContainer\Internal\Client\Client; +use TinyBlocks\DockerContainer\DockerClient; use TinyBlocks\DockerContainer\Internal\Commands\DockerList; use TinyBlocks\DockerContainer\Internal\Commands\DockerReaper; final readonly class ContainerReaper { - public function __construct(private Client $client) + public function __construct(private DockerClient $client) { } - public function ensureRunningFor(string $containerName): void + public function watch(Name $containerName): void { - if (!file_exists('/.dockerenv')) { + if (!HostEnvironment::isInsideDocker()) { return; } $template = 'tiny-blocks-reaper-%s'; - $reaperName = sprintf($template, $containerName); - $reaperList = DockerList::from(name: Name::from(value: $reaperName)); - $reaperExists = trim($this->client->execute(command: $reaperList)->getOutput()) !== ''; + $reaperName = Name::from(value: sprintf($template, $containerName->value)); - if ($reaperExists) { + if (trim($this->client->execute(command: DockerList::from(name: $reaperName))->getOutput()) !== '') { return; } @@ -33,7 +31,7 @@ public function ensureRunningFor(string $containerName): void command: DockerReaper::from( reaperName: $reaperName, containerName: $containerName, - testRunnerHostname: gethostname() + testRunnerHostname: HostEnvironment::containerHostname() ) ); } diff --git a/src/Internal/Containers/ContainerSharing.php b/src/Internal/Containers/ContainerSharing.php new file mode 100644 index 0000000..fc207b7 --- /dev/null +++ b/src/Internal/Containers/ContainerSharing.php @@ -0,0 +1,30 @@ +client->execute(command: DockerList::from(name: $name))->getOutput()); + $lookup = new ContainerLookup(client: $this->client); + + $shared = $listing === '' + ? Shared::created(containerStarted: $start()) + : Shared::reused(containerStarted: $lookup->byId(id: ContainerId::from(value: $listing), name: $name)); + + new ContainerReaper(client: $this->client)->watch(containerName: $name); + + return $shared; + } +} diff --git a/src/Internal/Containers/ContainerWaits.php b/src/Internal/Containers/ContainerWaits.php new file mode 100644 index 0000000..a965a23 --- /dev/null +++ b/src/Internal/Containers/ContainerWaits.php @@ -0,0 +1,47 @@ +beforeRun->each(actions: static fn(ContainerWaitBeforeStarted $wait) => $wait->waitBefore()); + } + + public function withBeforeRun(ContainerWaitBeforeStarted $wait): ContainerWaits + { + return new ContainerWaits(beforeRun: $this->beforeRun->add($wait), afterStarted: $this->afterStarted); + } + + public function waitAfterStarted(ContainerStarted $containerStarted): void + { + $this->afterStarted->each( + actions: static fn(ContainerWaitAfterStarted $wait) => $wait->waitAfter(containerStarted: $containerStarted) + ); + } + + public function withAfterStarted(ContainerWaitAfterStarted $wait): ContainerWaits + { + return new ContainerWaits(beforeRun: $this->beforeRun, afterStarted: $this->afterStarted->add($wait)); + } +} diff --git a/src/Internal/Containers/Definitions/ContainerDefinition.php b/src/Internal/Containers/Definitions/ContainerDefinition.php index afc996b..b9431cf 100644 --- a/src/Internal/Containers/Definitions/ContainerDefinition.php +++ b/src/Internal/Containers/Definitions/ContainerDefinition.php @@ -13,8 +13,8 @@ private function __construct( public Name $name, public Image $image, - public ?string $network, - public bool $autoRemove, + public Removal $removal, + public Collection $networks, public Collection $portMappings, public Collection $volumeMappings, public Collection $copyInstructions, @@ -22,13 +22,13 @@ private function __construct( ) { } - public static function create(string $image, ?string $name = null): ContainerDefinition + public static function create(string $image): ContainerDefinition { return new ContainerDefinition( - name: Name::from(value: $name), + name: Name::generated(), image: Image::from(image: $image), - network: null, - autoRemove: true, + removal: Removal::AUTOMATIC, + networks: Collection::createFromEmpty(), portMappings: Collection::createFromEmpty(), volumeMappings: Collection::createFromEmpty(), copyInstructions: Collection::createFromEmpty(), @@ -36,13 +36,43 @@ public static function create(string $image, ?string $name = null): ContainerDef ); } + public function withName(string $name): ContainerDefinition + { + return new ContainerDefinition( + name: Name::from(value: $name), + image: $this->image, + removal: $this->removal, + networks: $this->networks, + portMappings: $this->portMappings, + volumeMappings: $this->volumeMappings, + copyInstructions: $this->copyInstructions, + environmentVariables: $this->environmentVariables + ); + } + + public function toArguments(): array + { + return $this->portMappings + ->merge(other: $this->networks) + ->merge(other: $this->volumeMappings) + ->add($this->removal) + ->merge(other: $this->environmentVariables) + ->reduce( + accumulator: static fn(array $arguments, ContainerOption $option): array => [ + ...$arguments, + ...$option->toArguments() + ], + initial: [] + ); + } + public function withNetwork(string $name): ContainerDefinition { return new ContainerDefinition( name: $this->name, image: $this->image, - network: $name, - autoRemove: $this->autoRemove, + removal: $this->removal, + networks: Collection::createFrom(elements: [Network::from(name: $name)]), portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, copyInstructions: $this->copyInstructions, @@ -55,8 +85,8 @@ public function withPortMapping(int $portOnHost, int $portOnContainer): Containe return new ContainerDefinition( name: $this->name, image: $this->image, - network: $this->network, - autoRemove: $this->autoRemove, + removal: $this->removal, + networks: $this->networks, portMappings: $this->portMappings->add( PortMapping::from(portOnHost: $portOnHost, portOnContainer: $portOnContainer) ), @@ -71,8 +101,8 @@ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): return new ContainerDefinition( name: $this->name, image: $this->image, - network: $this->network, - autoRemove: $this->autoRemove, + removal: $this->removal, + networks: $this->networks, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings->add( VolumeMapping::from(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer) @@ -87,8 +117,8 @@ public function withoutAutoRemove(): ContainerDefinition return new ContainerDefinition( name: $this->name, image: $this->image, - network: $this->network, - autoRemove: false, + removal: Removal::MANUAL, + networks: $this->networks, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, copyInstructions: $this->copyInstructions, @@ -101,8 +131,8 @@ public function withCopyInstruction(string $pathOnHost, string $pathOnContainer) return new ContainerDefinition( name: $this->name, image: $this->image, - network: $this->network, - autoRemove: $this->autoRemove, + removal: $this->removal, + networks: $this->networks, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, copyInstructions: $this->copyInstructions->add( @@ -117,8 +147,8 @@ public function withEnvironmentVariable(string $key, string $value): ContainerDe return new ContainerDefinition( name: $this->name, image: $this->image, - network: $this->network, - autoRemove: $this->autoRemove, + removal: $this->removal, + networks: $this->networks, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, copyInstructions: $this->copyInstructions, diff --git a/src/Internal/Containers/Definitions/ContainerOption.php b/src/Internal/Containers/Definitions/ContainerOption.php new file mode 100644 index 0000000..0b2156a --- /dev/null +++ b/src/Internal/Containers/Definitions/ContainerOption.php @@ -0,0 +1,18 @@ +docker create command. + */ +interface ContainerOption +{ + /** + * Returns the ContainerOption as the ordered arguments it adds to docker create. + * + * @return array The ordered arguments. + */ + public function toArguments(): array; +} diff --git a/src/Internal/Containers/Definitions/EnvironmentVariable.php b/src/Internal/Containers/Definitions/EnvironmentVariable.php index 975c192..be0cb63 100644 --- a/src/Internal/Containers/Definitions/EnvironmentVariable.php +++ b/src/Internal/Containers/Definitions/EnvironmentVariable.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Definitions; -final readonly class EnvironmentVariable +final readonly class EnvironmentVariable implements ContainerOption { private function __construct(public string $key, public string $value) { diff --git a/src/Internal/Containers/Definitions/Network.php b/src/Internal/Containers/Definitions/Network.php new file mode 100644 index 0000000..c01b93c --- /dev/null +++ b/src/Internal/Containers/Definitions/Network.php @@ -0,0 +1,24 @@ +name)]; + } +} diff --git a/src/Internal/Containers/Definitions/PortMapping.php b/src/Internal/Containers/Definitions/PortMapping.php index 35ac1c0..578009a 100644 --- a/src/Internal/Containers/Definitions/PortMapping.php +++ b/src/Internal/Containers/Definitions/PortMapping.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Definitions; -final readonly class PortMapping +final readonly class PortMapping implements ContainerOption { private function __construct(public int $portOnHost, public int $portOnContainer) { diff --git a/src/Internal/Containers/Definitions/Removal.php b/src/Internal/Containers/Definitions/Removal.php new file mode 100644 index 0000000..c7bc4f2 --- /dev/null +++ b/src/Internal/Containers/Definitions/Removal.php @@ -0,0 +1,19 @@ + [], + self::AUTOMATIC => ['--rm'] + }; + } +} diff --git a/src/Internal/Containers/Definitions/VolumeMapping.php b/src/Internal/Containers/Definitions/VolumeMapping.php index 8317bc4..9537371 100644 --- a/src/Internal/Containers/Definitions/VolumeMapping.php +++ b/src/Internal/Containers/Definitions/VolumeMapping.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Definitions; -final readonly class VolumeMapping +final readonly class VolumeMapping implements ContainerOption { private function __construct(public string $pathOnHost, public string $pathOnContainer) { diff --git a/src/Internal/Containers/Drivers/MySQL/JdbcUrl.php b/src/Internal/Containers/Drivers/MySQL/JdbcUrl.php new file mode 100644 index 0000000..55a33fb --- /dev/null +++ b/src/Internal/Containers/Drivers/MySQL/JdbcUrl.php @@ -0,0 +1,41 @@ +getAddress(); + + return new JdbcUrl( + port: ($address->getPorts()->firstExposedPort() ?? self::DEFAULT_MYSQL_PORT), + database: $containerStarted->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE'), + hostname: $address->getHostname() + ); + } + + public function toString(array $options): string + { + $template = 'jdbc:mysql://%s:%d/%s'; + $baseUrl = sprintf($template, $this->hostname, $this->port, $this->database); + + if (empty($options)) { + return $baseUrl; + } + + $template = '%s?%s'; + + return sprintf($template, $baseUrl, http_build_query($options)); + } +} diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php b/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php index c496f69..4ec9976 100644 --- a/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php +++ b/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php @@ -9,7 +9,7 @@ final class MySQLCommands private const string GRANT_ALL = "GRANT ALL PRIVILEGES ON *.* TO '%s'@'%s' WITH GRANT OPTION;"; private const string USER_ROOT = 'root'; private const string CREATE_USER = "CREATE USER IF NOT EXISTS '%s'@'%s' IDENTIFIED BY '%s';"; - private const string EXECUTE_SQL = 'mysql -u%s -p%s -e "%s"'; + private const string ROOT_PASSWORD = 'MYSQL_PWD=%s'; private const string CREATE_DATABASE = 'CREATE DATABASE IF NOT EXISTS %s;'; private const string FLUSH_PRIVILEGES = 'FLUSH PRIVILEGES;'; @@ -17,7 +17,7 @@ private function __construct() { } - public static function setupDatabase(string $database, array $grantedHosts, string $rootPassword): string + public static function setupDatabase(string $database, array $grantedHosts, string $rootPassword): array { $statements = []; @@ -26,16 +26,20 @@ public static function setupDatabase(string $database, array $grantedHosts, stri } foreach ($grantedHosts as $host) { - $createUser = sprintf(self::CREATE_USER, self::USER_ROOT, $host, $rootPassword); - $grantAll = sprintf(self::GRANT_ALL, self::USER_ROOT, $host); - $template = '%s %s'; - $statements[] = sprintf($template, $createUser, $grantAll); + $statements[] = sprintf(self::CREATE_USER, self::USER_ROOT, $host, $rootPassword); + $statements[] = sprintf(self::GRANT_ALL, self::USER_ROOT, $host); } - if (!empty($statements)) { - $statements[] = self::FLUSH_PRIVILEGES; - } - - return sprintf(self::EXECUTE_SQL, self::USER_ROOT, $rootPassword, implode(' ', $statements)); + $statements[] = self::FLUSH_PRIVILEGES; + $template = '-u%s'; + + return [ + 'env', + sprintf(self::ROOT_PASSWORD, $rootPassword), + 'mysql', + sprintf($template, self::USER_ROOT), + '-e', + implode(' ', $statements) + ]; } } diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLShared.php b/src/Internal/Containers/Drivers/MySQL/MySQLShared.php new file mode 100644 index 0000000..68c3731 --- /dev/null +++ b/src/Internal/Containers/Drivers/MySQL/MySQLShared.php @@ -0,0 +1,64 @@ +containerStarted->stop(gracePeriod: $gracePeriod); + } + + public function getId(): string + { + return $this->containerStarted->getId(); + } + + public function getName(): string + { + return $this->containerStarted->getName(); + } + + public function wasReused(): bool + { + return $this->containerStarted->wasReused(); + } + + public function getAddress(): Address + { + return $this->containerStarted->getAddress(); + } + + public function getJdbcUrl(array $options = self::DEFAULT_JDBC_OPTIONS): string + { + return JdbcUrl::from(containerStarted: $this->containerStarted)->toString(options: $options); + } + + public function executeAfterStarted(array $commands): ExecutionCompleted + { + return $this->containerStarted->executeAfterStarted(commands: $commands); + } + + public function getEnvironmentVariables(): EnvironmentVariables + { + return $this->containerStarted->getEnvironmentVariables(); + } +} diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php b/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php index 7a3ff25..0317d08 100644 --- a/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php +++ b/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php @@ -5,27 +5,26 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Drivers\MySQL; use TinyBlocks\DockerContainer\Address; -use TinyBlocks\DockerContainer\ContainerStarted; +use TinyBlocks\DockerContainer\DisposableContainer; use TinyBlocks\DockerContainer\EnvironmentVariables; use TinyBlocks\DockerContainer\ExecutionCompleted; use TinyBlocks\DockerContainer\MySQL\MySQLContainerStarted; +use TinyBlocks\Time\Duration; -final readonly class MySQLStarted implements MySQLContainerStarted +final readonly class MySQLStarted implements MySQLContainerStarted, DisposableContainer { - private const int DEFAULT_MYSQL_PORT = 3306; - - private function __construct(private ContainerStarted $containerStarted) + private function __construct(private DisposableContainer $containerStarted) { } - public static function from(ContainerStarted $containerStarted): MySQLStarted + public static function from(DisposableContainer $containerStarted): MySQLStarted { return new MySQLStarted(containerStarted: $containerStarted); } - public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted + public function stop(Duration $gracePeriod): ExecutionCompleted { - return $this->containerStarted->stop(timeoutInWholeSeconds: $timeoutInWholeSeconds); + return $this->containerStarted->stop(gracePeriod: $gracePeriod); } public function getId(): string @@ -43,11 +42,6 @@ public function getName(): string return $this->containerStarted->getName(); } - public function wasReused(): bool - { - return $this->containerStarted->wasReused(); - } - public function getAddress(): Address { return $this->containerStarted->getAddress(); @@ -55,22 +49,7 @@ public function getAddress(): Address public function getJdbcUrl(array $options = self::DEFAULT_JDBC_OPTIONS): string { - $address = $this->getAddress(); - $port = ($address->getPorts()->firstExposedPort() ?? self::DEFAULT_MYSQL_PORT); - $hostname = $address->getHostname(); - $database = $this->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE'); - - $template = 'jdbc:mysql://%s:%d/%s'; - - $baseUrl = sprintf($template, $hostname, $port, $database); - - if (!empty($options)) { - $template = '%s?%s'; - - return sprintf($template, $baseUrl, http_build_query($options)); - } - - return $baseUrl; + return JdbcUrl::from(containerStarted: $this->containerStarted)->toString(options: $options); } public function stopOnShutdown(): void diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLStartup.php b/src/Internal/Containers/Drivers/MySQL/MySQLStartup.php new file mode 100644 index 0000000..1b81d55 --- /dev/null +++ b/src/Internal/Containers/Drivers/MySQL/MySQLStartup.php @@ -0,0 +1,56 @@ +withTimeout(timeout: $this->readinessTimeout) + ->waitBefore(); + + $environmentVariables = $containerStarted->getEnvironmentVariables(); + $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); + + if ($database !== '' || !empty($this->grantedHosts)) { + $setup = MySQLCommands::setupDatabase( + database: $database, + grantedHosts: $this->grantedHosts, + rootPassword: $environmentVariables->getValueBy(key: 'MYSQL_ROOT_PASSWORD') + ); + $containerStarted->executeAfterStarted(commands: $setup); + } + } + + public function withGrantedHosts(array $hosts): MySQLStartup + { + return new MySQLStartup(grantedHosts: $hosts, readinessTimeout: $this->readinessTimeout); + } + + public function withReadinessTimeout(Duration $timeout): MySQLStartup + { + return new MySQLStartup(grantedHosts: $this->grantedHosts, readinessTimeout: $timeout); + } +} diff --git a/src/Internal/Containers/Image.php b/src/Internal/Containers/Image.php index 0f2b494..362d135 100644 --- a/src/Internal/Containers/Image.php +++ b/src/Internal/Containers/Image.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Containers; -use TinyBlocks\DockerContainer\Internal\Exceptions\ImageNameEmpty; +use TinyBlocks\DockerContainer\Exceptions\ImageNameEmpty; final readonly class Image { diff --git a/src/Internal/Containers/Name.php b/src/Internal/Containers/Name.php index f824c6c..fc99804 100644 --- a/src/Internal/Containers/Name.php +++ b/src/Internal/Containers/Name.php @@ -12,10 +12,13 @@ private function __construct(public string $value) { } - public static function from(?string $value): Name + public static function from(string $value): Name { - $value = is_null($value) || $value === '' ? Ksuid::random()->value() : $value; - return new Name(value: $value); } + + public static function generated(): Name + { + return new Name(value: Ksuid::random()->value()); + } } diff --git a/src/Internal/Containers/NetworkProvisioner.php b/src/Internal/Containers/NetworkProvisioner.php new file mode 100644 index 0000000..9c0ff13 --- /dev/null +++ b/src/Internal/Containers/NetworkProvisioner.php @@ -0,0 +1,27 @@ +client->execute(command: DockerNetworkCreate::from(network: $network)); + + if (HostEnvironment::isInsideDocker()) { + $command = DockerNetworkConnect::from(network: $network, container: HostEnvironment::containerHostname()); + $this->client->execute(command: $command); + } + } +} diff --git a/src/Internal/Containers/OneShotContainer.php b/src/Internal/Containers/OneShotContainer.php new file mode 100644 index 0000000..2d536aa --- /dev/null +++ b/src/Internal/Containers/OneShotContainer.php @@ -0,0 +1,43 @@ +client); + $id = $launcher->create(commands: $commands, definition: $definition->withoutAutoRemove()); + $startAttached = DockerStartAttached::from(id: $id, timeout: $timeout); + + try { + $execution = $this->client->executeWithTimeout(command: $startAttached); + + if (!$execution->isSuccessful()) { + throw new ContainerExitUnsuccessful(name: $definition->name->value, output: $execution->getOutput()); + } + + return $execution; + } catch (DockerCommandExecutionFailed) { + throw ContainerWaitTimeout::forExit(name: $definition->name->value, timeout: $timeout); + } finally { + $this->client->execute(command: DockerRemove::from(id: $id)); + } + } +} diff --git a/src/Internal/Containers/RegisteredShutdownHook.php b/src/Internal/Containers/RegisteredShutdownHook.php deleted file mode 100644 index efe4f02..0000000 --- a/src/Internal/Containers/RegisteredShutdownHook.php +++ /dev/null @@ -1,13 +0,0 @@ -ensureRunningFor(containerName: $containerStarted->getName()); + private function __construct(private bool $wasReused, private ContainerStarted $containerStarted) + { } - public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted + public static function reused(ContainerStarted $containerStarted): Shared { - return $this->containerStarted->stop(timeoutInWholeSeconds: $timeoutInWholeSeconds); + return new Shared(wasReused: true, containerStarted: $containerStarted); } - public function getId(): string + public static function created(ContainerStarted $containerStarted): Shared { - return $this->containerStarted->getId(); + return new Shared(wasReused: false, containerStarted: $containerStarted); } - public function remove(): void + public function stop(Duration $gracePeriod): ExecutionCompleted { + return $this->containerStarted->stop(gracePeriod: $gracePeriod); + } + + public function getId(): string + { + return $this->containerStarted->getId(); } public function getName(): string @@ -48,10 +52,6 @@ public function getAddress(): Address return $this->containerStarted->getAddress(); } - public function stopOnShutdown(): void - { - } - public function executeAfterStarted(array $commands): ExecutionCompleted { return $this->containerStarted->executeAfterStarted(commands: $commands); diff --git a/src/Internal/Containers/ShutdownHook.php b/src/Internal/Containers/ShutdownHook.php deleted file mode 100644 index d6b0e1e..0000000 --- a/src/Internal/Containers/ShutdownHook.php +++ /dev/null @@ -1,18 +0,0 @@ -id, timeoutInWholeSeconds: $timeoutInWholeSeconds); - - return $this->commandHandler->execute(command: $command); + return $this->client->executeWithTimeout(command: DockerStop::from(id: $this->id, gracePeriod: $gracePeriod)); } public function getId(): string @@ -42,8 +38,8 @@ public function getId(): string public function remove(): void { - $this->commandHandler->execute(command: DockerRemove::from(id: $this->id)); - $this->commandHandler->execute(command: DockerNetworkPrune::create()); + $this->client->execute(command: DockerRemove::from(id: $this->id)); + $this->client->execute(command: DockerNetworkPrune::create()); } public function getName(): string @@ -51,11 +47,6 @@ public function getName(): string return $this->name->value; } - public function wasReused(): bool - { - return false; - } - public function getAddress(): Address { return $this->address; @@ -63,14 +54,12 @@ public function getAddress(): Address public function stopOnShutdown(): void { - $this->shutdownHook->register(callback: [$this, 'remove']); + register_shutdown_function($this->remove(...)); } public function executeAfterStarted(array $commands): ExecutionCompleted { - $command = DockerExecute::from(name: $this->name, commands: $commands); - - return $this->commandHandler->execute(command: $command); + return $this->client->execute(command: DockerExecute::from(name: $this->name, commands: $commands)); } public function getEnvironmentVariables(): EnvironmentVariables diff --git a/src/Internal/Exceptions/ContainerWaitTimeout.php b/src/Internal/Exceptions/ContainerWaitTimeout.php deleted file mode 100644 index aaad5ad..0000000 --- a/src/Internal/Exceptions/ContainerWaitTimeout.php +++ /dev/null @@ -1,17 +0,0 @@ - seconds.'; - - parent::__construct(message: sprintf($template, $timeoutInSeconds)); - } -} diff --git a/src/Internal/Exceptions/DockerCommandExecutionFailed.php b/src/Internal/Exceptions/DockerCommandExecutionFailed.php deleted file mode 100644 index d0b72ae..0000000 --- a/src/Internal/Exceptions/DockerCommandExecutionFailed.php +++ /dev/null @@ -1,35 +0,0 @@ - in Docker container. Reason: %s'; - - parent::__construct(message: sprintf($template, $command, $reason)); - } - - public static function fromCommand(Command $command, ExecutionCompleted $execution): DockerCommandExecutionFailed - { - $rendered = implode(' ', array_map('escapeshellarg', $command->toArguments())); - - return new DockerCommandExecutionFailed(reason: $execution->getOutput(), command: $rendered); - } - - public static function fromProcess(Process $process, Throwable $exception): DockerCommandExecutionFailed - { - $reason = $process->isStarted() ? $process->getErrorOutput() : $exception->getMessage(); - - return new DockerCommandExecutionFailed(reason: $reason, command: $process->getCommandLine()); - } -} diff --git a/src/Internal/Exceptions/StopTimeoutOutOfRange.php b/src/Internal/Exceptions/StopTimeoutOutOfRange.php deleted file mode 100644 index 1d83208..0000000 --- a/src/Internal/Exceptions/StopTimeoutOutOfRange.php +++ /dev/null @@ -1,17 +0,0 @@ - seconds.'; - - parent::__construct(message: sprintf($template, $timeoutInWholeSeconds)); - } -} diff --git a/src/MySQLContainer.php b/src/MySQLContainer.php deleted file mode 100644 index d8dc36a..0000000 --- a/src/MySQLContainer.php +++ /dev/null @@ -1,67 +0,0 @@ - $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 root password for the MySQL instance. - * - * @param string $rootPassword The root password. - * @return static The current container instance for method chaining. - */ - public function withRootPassword(string $rootPassword): 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 11afdea..0ec61b7 100644 --- a/src/MySQLDockerContainer.php +++ b/src/MySQLDockerContainer.php @@ -4,191 +4,281 @@ namespace TinyBlocks\DockerContainer; -use Closure; -use TinyBlocks\DockerContainer\Internal\Containers\Drivers\MySQL\MySQLCommands; +use TinyBlocks\DockerContainer\Exceptions\ContainerWaitTimeout; +use TinyBlocks\DockerContainer\Exceptions\DockerCommandExecutionFailed; +use TinyBlocks\DockerContainer\Exceptions\ImageNameEmpty; +use TinyBlocks\DockerContainer\Internal\Client\CommandLineDockerClient; +use TinyBlocks\DockerContainer\Internal\Containers\Drivers\MySQL\MySQLShared; use TinyBlocks\DockerContainer\Internal\Containers\Drivers\MySQL\MySQLStarted; +use TinyBlocks\DockerContainer\Internal\Containers\Drivers\MySQL\MySQLStartup; use TinyBlocks\DockerContainer\MySQL\MySQLContainerStarted; -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 implements MySQLContainer +use TinyBlocks\Time\Duration; + +/** + * Immutable definition of a MySQL Docker container, which runs it and hands it over ready for connections. + * + *

Every with* method returns a new definition and leaves the receiver untouched.

+ */ +final readonly class MySQLDockerContainer { - /** @var array */ - private array $grantedHosts = []; - - private int $readinessTimeoutInSeconds; - - protected function __construct(private GenericDockerContainer $container) + private function __construct(private MySQLStartup $startup, private GenericDockerContainer $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)); + /** + * Creates a MySQLDockerContainer from a MySQL Docker image. + * + *

The container gets a unique generated name until withName() replaces it.

+ * + * @param string $image The MySQL Docker image name (e.g., "mysql:8.4"). + * @param DockerClient $client The client that executes the Docker commands, the local docker executable by + * default. + * @return MySQLDockerContainer The created container definition. + * @throws ImageNameEmpty If the image name is empty. + */ + public static function from( + string $image, + DockerClient $client = new CommandLineDockerClient() + ): MySQLDockerContainer { + return new MySQLDockerContainer( + startup: MySQLStartup::create(), + container: GenericDockerContainer::from(image: $image, client: $client) + ); } - public function run( - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): MySQLContainerStarted { + /** + * Starts the container and waits until MySQL accepts connections. + * + *

Once MySQL answers, the database is created and root is granted access from the granted hosts, when + * either was configured.

+ * + * @param array $commands The arguments passed to the MySQL server on startup. + * @return MySQLContainerStarted&DisposableContainer The started container, owned by this process and ready for + * connections. + * @throws DockerCommandExecutionFailed If a Docker command fails. + * @throws ContainerWaitTimeout If MySQL does not accept connections within the readiness timeout. + */ + public function run(array $commands = []): MySQLContainerStarted&DisposableContainer + { $containerStarted = $this->container->run(commands: $commands); - - $condition = MySQLReady::from(container: $containerStarted); - 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 ($database !== '' || !empty($this->grantedHosts)) { - $containerStarted->executeAfterStarted( - commands: [ - MySQLCommands::setupDatabase( - database: $database, - grantedHosts: $this->grantedHosts, - rootPassword: $rootPassword - ) - ] - ); - } + $this->startup->complete(containerStarted: $containerStarted); return MySQLStarted::from(containerStarted: $containerStarted); } - public function runWhen( - Closure $gate, - Closure $then, - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): void { - $this->container->runWhen( - gate: $gate, - then: static function (ContainerStarted $started) use ($then): void { - $then(MySQLStarted::from(containerStarted: $started)); - }, - commands: $commands, - waitAfterStarted: $waitAfterStarted - ); - } - - public function pullImage(): static + /** + * Returns a copy of the MySQLDockerContainer with the container name replaced. + * + *

The name identifies the container that runIfNotExists() shares among processes.

+ * + * @param string $name The container name. + * @return MySQLDockerContainer The copy with the new name. + */ + public function withName(string $name): MySQLDockerContainer { - $this->container->pullImage(); - - return $this; + return new MySQLDockerContainer(startup: $this->startup, container: $this->container->withName(name: $name)); } - public function withNetwork(string $name): static + /** + * Returns a copy of the MySQLDockerContainer with the Docker network replaced. + * + * @param string $name The name of the Docker network. + * @return MySQLDockerContainer The copy with the new network. + */ + public function withNetwork(string $name): MySQLDockerContainer { - $this->container->withNetwork(name: $name); - - return $this; + return new MySQLDockerContainer(startup: $this->startup, container: $this->container->withNetwork(name: $name)); } - public function withDatabase(string $database): static + /** + * Returns a copy of the MySQLDockerContainer with the database created on startup replaced. + * + * @param string $database The database name. + * @return MySQLDockerContainer The copy with the new database. + */ + public function withDatabase(string $database): MySQLDockerContainer { - $this->container->withEnvironmentVariable(key: 'MYSQL_DATABASE', value: $database); - - return $this; + return $this->withEnvironmentVariable(key: 'MYSQL_DATABASE', value: $database); } - public function withPassword(string $password): static + /** + * Returns a copy of the MySQLDockerContainer with the password of the user created on startup replaced. + * + * @param string $password The user password. + * @return MySQLDockerContainer The copy with the new password. + */ + public function withPassword(string $password): MySQLDockerContainer { - $this->container->withEnvironmentVariable(key: 'MYSQL_PASSWORD', value: $password); - - return $this; + return $this->withEnvironmentVariable(key: 'MYSQL_PASSWORD', value: $password); } - public function withTimezone(string $timezone): static + /** + * Returns a copy of the MySQLDockerContainer with the timezone replaced. + * + * @param string $timezone The timezone identifier (e.g., "America/Sao_Paulo"). + * @return MySQLDockerContainer The copy with the new timezone. + */ + public function withTimezone(string $timezone): MySQLDockerContainer { - $this->container->withEnvironmentVariable(key: 'TZ', value: $timezone); - - return $this; + return $this->withEnvironmentVariable(key: 'TZ', value: $timezone); } - public function withUsername(string $user): static + /** + * Returns a copy of the MySQLDockerContainer with the user created on startup replaced. + * + * @param string $user The username. + * @return MySQLDockerContainer The copy with the new user. + */ + public function withUsername(string $user): MySQLDockerContainer { - $this->container->withEnvironmentVariable(key: 'MYSQL_USER', value: $user); - - return $this; + return $this->withEnvironmentVariable(key: 'MYSQL_USER', value: $user); } - public function runIfNotExists( - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): MySQLContainerStarted { + /** + * Starts the container unless one with the same name already exists, then waits until MySQL accepts + * connections. + * + *

The container is shared as described in {@see GenericDockerContainer::runIfNotExists()}. The readiness + * wait and the database setup run for a reused container too, so a container another process has just + * started is handed over only once it is ready. The setup statements are idempotent.

+ * + * @param array $commands The arguments passed to a new MySQL server on startup. + * @return MySQLContainerStarted&SharedContainer The existing or newly started container, ready for connections. + * @throws DockerCommandExecutionFailed If a Docker command fails. + * @throws ContainerWaitTimeout If MySQL does not accept connections within the readiness timeout. + */ + public function runIfNotExists(array $commands = []): MySQLContainerStarted&SharedContainer + { $containerStarted = $this->container->runIfNotExists(commands: $commands); + $this->startup->complete(containerStarted: $containerStarted); - return MySQLStarted::from(containerStarted: $containerStarted); + return MySQLShared::from(containerStarted: $containerStarted); } - public function copyToContainer(string $pathOnHost, string $pathOnContainer): static + /** + * Returns a copy of the MySQLDockerContainer with a file or directory added to copy into the container. + * + * @param string $pathOnHost The absolute path on the host. + * @param string $pathOnContainer The target path inside the container. + * @return MySQLDockerContainer The copy with the added file or directory. + */ + public function copyToContainer(string $pathOnHost, string $pathOnContainer): MySQLDockerContainer { - $this->container->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer); - - return $this; + return new MySQLDockerContainer( + startup: $this->startup, + container: $this->container->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer) + ); } - public function withPortMapping(int $portOnHost, int $portOnContainer): static + /** + * Returns a copy of the MySQLDockerContainer with a port mapping added between the host and the container. + * + * @param int $portOnHost The port on the host machine, or 0 for a port Docker picks. + * @param int $portOnContainer The port inside the container. + * @return MySQLDockerContainer The copy with the added port mapping. + */ + public function withPortMapping(int $portOnHost, int $portOnContainer): MySQLDockerContainer { - $this->container->withPortMapping(portOnHost: $portOnHost, portOnContainer: $portOnContainer); - - return $this; + return new MySQLDockerContainer( + startup: $this->startup, + container: $this->container->withPortMapping(portOnHost: $portOnHost, portOnContainer: $portOnContainer) + ); } - public function withGrantedHosts(array $hosts = ['%', '172.%']): static + /** + * Returns a copy of the MySQLDockerContainer with the hosts root is granted access from replaced. + * + * @param array $hosts The host patterns (e.g., ["%", "172.%"]). + * @return MySQLDockerContainer The copy with the new granted hosts. + */ + public function withGrantedHosts(array $hosts = ['%', '172.%']): MySQLDockerContainer { - $this->grantedHosts = $hosts; - - return $this; + return new MySQLDockerContainer( + startup: $this->startup->withGrantedHosts(hosts: $hosts), + container: $this->container + ); } - public function withRootPassword(string $rootPassword): static + /** + * Returns a copy of the MySQLDockerContainer with the root password replaced. + * + * @param string $rootPassword The root password. + * @return MySQLDockerContainer The copy with the new root password. + */ + public function withRootPassword(string $rootPassword): MySQLDockerContainer { - $this->container->withEnvironmentVariable(key: 'MYSQL_ROOT_PASSWORD', value: $rootPassword); - - return $this; + return $this->withEnvironmentVariable(key: 'MYSQL_ROOT_PASSWORD', value: $rootPassword); } - public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): static + /** + * Returns a copy of the MySQLDockerContainer with a volume mapping added between the host and the container. + * + * @param string $pathOnHost The absolute path on the host. + * @param string $pathOnContainer The target path inside the container. + * @return MySQLDockerContainer The copy with the added volume mapping. + */ + public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): MySQLDockerContainer { - $this->container->withVolumeMapping(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer); - - return $this; + return new MySQLDockerContainer( + startup: $this->startup, + container: $this->container->withVolumeMapping(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer) + ); } - public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static + /** + * Returns a copy of the MySQLDockerContainer with a wait strategy added, applied before the container runs. + * + * @param ContainerWaitBeforeStarted $wait The wait strategy applied before the container runs. + * @return MySQLDockerContainer The copy with the added wait strategy. + */ + public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): MySQLDockerContainer { - $this->container->withWaitBeforeRun(wait: $wait); - - return $this; + return new MySQLDockerContainer( + startup: $this->startup, + container: $this->container->withWaitBeforeRun(wait: $wait) + ); } - public function withoutAutoRemove(): static + /** + * Returns a copy of the MySQLDockerContainer that Docker keeps after it stops. + * + * @return MySQLDockerContainer The copy without automatic removal. + */ + public function withoutAutoRemove(): MySQLDockerContainer { - $this->container->withoutAutoRemove(); - - return $this; + return new MySQLDockerContainer(startup: $this->startup, container: $this->container->withoutAutoRemove()); } - public function withReadinessTimeout(int $timeoutInSeconds): static + /** + * Returns a copy of the MySQLDockerContainer with the readiness timeout replaced. + * + *

The readiness timeout bounds how long run() and runIfNotExists() wait for MySQL to accept connections. + * It defaults to 30 seconds.

+ * + * @param Duration $timeout The longest time to wait for MySQL to accept connections. + * @return MySQLDockerContainer The copy with the new readiness timeout. + */ + public function withReadinessTimeout(Duration $timeout): MySQLDockerContainer { - $this->readinessTimeoutInSeconds = $timeoutInSeconds; - - return $this; + return new MySQLDockerContainer( + startup: $this->startup->withReadinessTimeout(timeout: $timeout), + container: $this->container + ); } - public function withEnvironmentVariable(string $key, string $value): static + /** + * Returns a copy of the MySQLDockerContainer with an environment variable added. + * + * @param string $key The environment variable name. + * @param string $value The environment variable value. + * @return MySQLDockerContainer The copy with the added environment variable. + */ + public function withEnvironmentVariable(string $key, string $value): MySQLDockerContainer { - $this->container->withEnvironmentVariable(key: $key, value: $value); - - return $this; + return new MySQLDockerContainer( + startup: $this->startup, + container: $this->container->withEnvironmentVariable(key: $key, value: $value) + ); } } diff --git a/src/SharedContainer.php b/src/SharedContainer.php new file mode 100644 index 0000000..a48a233 --- /dev/null +++ b/src/SharedContainer.php @@ -0,0 +1,20 @@ +When the code runs inside Docker, a reaper container removes it once the test runner container exits.

+ */ +interface SharedContainer extends ContainerStarted +{ + /** + * Tells whether the container was reused from an already-running instance. + * + * @return bool True when an existing container was reused, false when a new one was started. + */ + public function wasReused(): bool; +} diff --git a/src/Waits/Conditions/MySQL/MySQLReady.php b/src/Waits/Conditions/MySQL/MySQLReady.php index e69ff53..9b60009 100644 --- a/src/Waits/Conditions/MySQL/MySQLReady.php +++ b/src/Waits/Conditions/MySQL/MySQLReady.php @@ -8,12 +8,21 @@ use TinyBlocks\DockerContainer\ContainerStarted; use TinyBlocks\DockerContainer\Waits\Conditions\ContainerReady; +/** + * Readiness condition that holds once the MySQL server inside a started container answers a ping. + */ final readonly class MySQLReady implements ContainerReady { private function __construct(private ContainerStarted $container) { } + /** + * Creates a MySQLReady from a started MySQL container. + * + * @param ContainerStarted $container The started container running the MySQL server. + * @return MySQLReady The created readiness condition. + */ public static function from(ContainerStarted $container): MySQLReady { return new MySQLReady(container: $container); diff --git a/src/Waits/ContainerWait.php b/src/Waits/ContainerWait.php deleted file mode 100644 index 11946b5..0000000 --- a/src/Waits/ContainerWait.php +++ /dev/null @@ -1,18 +0,0 @@ -The condition is checked every 250 milliseconds and at least once, even when the timeout is zero. The timeout + * defaults to 30 seconds.

+ */ final readonly class ContainerWaitForDependency implements ContainerWaitBeforeStarted { private const int MICROSECONDS_PER_SECOND = 1_000_000; + private const int DEFAULT_TIMEOUT_IN_SECONDS = 30; + private const int POLL_INTERVAL_IN_MICROSECONDS = 250_000; - private function __construct( - private ContainerReady $condition, - private int $timeoutInSeconds, - private int $pollIntervalInMicroseconds - ) { + private function __construct(private Duration $timeout, private ContainerReady $condition) + { } - public static function untilReady( - ContainerReady $condition, - int $timeoutInSeconds = self::DEFAULT_TIMEOUT_IN_SECONDS, - int $pollIntervalInMicroseconds = self::DEFAULT_POLL_INTERVAL_IN_MICROSECONDS - ): ContainerWaitForDependency { + /** + * Creates a ContainerWaitForDependency that waits until the condition holds, within the default timeout. + * + * @param ContainerReady $condition The readiness condition to poll. + * @return ContainerWaitForDependency The created wait strategy. + */ + public static function untilReady(ContainerReady $condition): ContainerWaitForDependency + { return new ContainerWaitForDependency( - condition: $condition, - timeoutInSeconds: $timeoutInSeconds, - pollIntervalInMicroseconds: $pollIntervalInMicroseconds + timeout: Duration::fromSeconds(seconds: self::DEFAULT_TIMEOUT_IN_SECONDS), + condition: $condition ); } public function waitBefore(): void { - $totalBudgetInMicroseconds = ($this->timeoutInSeconds * self::MICROSECONDS_PER_SECOND); - $maxAttempts = max(1, intdiv($totalBudgetInMicroseconds, $this->pollIntervalInMicroseconds)); + $budgetInMicroseconds = ($this->timeout->toSeconds() * self::MICROSECONDS_PER_SECOND); + $attempts = max(1, intdiv($budgetInMicroseconds, self::POLL_INTERVAL_IN_MICROSECONDS)); - for ($attempts = 0; $attempts < $maxAttempts; $attempts++) { + for ($attempt = 0; $attempt < $attempts; $attempt++) { if ($this->condition->isReady()) { return; } - usleep($this->pollIntervalInMicroseconds); + usleep(self::POLL_INTERVAL_IN_MICROSECONDS); } - throw new ContainerWaitTimeout(timeoutInSeconds: $this->timeoutInSeconds); + throw ContainerWaitTimeout::forReadiness(timeout: $this->timeout); + } + + /** + * Returns a copy of the ContainerWaitForDependency with the timeout replaced. + * + * @param Duration $timeout The longest time to wait for the condition to hold. + * @return ContainerWaitForDependency The copy with the new timeout. + */ + public function withTimeout(Duration $timeout): ContainerWaitForDependency + { + return new ContainerWaitForDependency(timeout: $timeout, condition: $this->condition); } } diff --git a/src/Waits/ContainerWaitForTime.php b/src/Waits/ContainerWaitForTime.php index 3c18b19..b420c7f 100644 --- a/src/Waits/ContainerWaitForTime.php +++ b/src/Waits/ContainerWaitForTime.php @@ -5,27 +5,37 @@ namespace TinyBlocks\DockerContainer\Waits; use TinyBlocks\DockerContainer\ContainerStarted; +use TinyBlocks\Time\Duration; +/** + * Wait strategy that pauses for a fixed duration, before a container runs or after it starts. + */ final readonly class ContainerWaitForTime implements ContainerWaitBeforeStarted, ContainerWaitAfterStarted { private const int MICROSECONDS_PER_SECOND = 1_000_000; - private function __construct(private int $seconds) + private function __construct(private Duration $duration) { } - public static function forSeconds(int $seconds): ContainerWaitForTime + /** + * Creates a ContainerWaitForTime that pauses for the given duration. + * + * @param Duration $duration The time to pause. + * @return ContainerWaitForTime The created wait strategy. + */ + public static function forDuration(Duration $duration): ContainerWaitForTime { - return new ContainerWaitForTime(seconds: $seconds); + return new ContainerWaitForTime(duration: $duration); } - public function waitBefore(): void + public function waitAfter(ContainerStarted $containerStarted): void { - usleep($this->seconds * self::MICROSECONDS_PER_SECOND); + $this->waitBefore(); } - public function waitAfter(ContainerStarted $containerStarted): void + public function waitBefore(): void { - usleep($this->seconds * self::MICROSECONDS_PER_SECOND); + usleep($this->duration->toSeconds() * self::MICROSECONDS_PER_SECOND); } } diff --git a/tests/Integration/Database/SlowMigrations/V0000__Create_slow_table.sql b/tests/Integration/Database/SlowMigrations/V0000__Create_slow_table.sql new file mode 100644 index 0000000..6bac4ad --- /dev/null +++ b/tests/Integration/Database/SlowMigrations/V0000__Create_slow_table.sql @@ -0,0 +1,9 @@ +DO SLEEP(15); + +CREATE TABLE slow +( + id INT PRIMARY KEY NOT NULL COMMENT 'Unique identifier.' +); + +INSERT INTO slow (id) +VALUES (1); diff --git a/tests/Integration/DockerContainerTest.php b/tests/Integration/DockerContainerTest.php index b99028e..d43bff0 100644 --- a/tests/Integration/DockerContainerTest.php +++ b/tests/Integration/DockerContainerTest.php @@ -5,101 +5,174 @@ namespace Test\Integration; use PHPUnit\Framework\TestCase; +use TinyBlocks\DockerContainer\Exceptions\ContainerExitUnsuccessful; use TinyBlocks\DockerContainer\FlywayDockerContainer; use TinyBlocks\DockerContainer\GenericDockerContainer; +use TinyBlocks\DockerContainer\MySQL\MySQLContainerStarted; use TinyBlocks\DockerContainer\MySQLDockerContainer; use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime; +use TinyBlocks\Time\Duration; final class DockerContainerTest extends TestCase { private const string ROOT = 'xpto'; private const string DATABASE = 'test_adm'; - public function testMultipleContainersAreRunSuccessfully(): void + private static MySQLContainerStarted $mySQLStarted; + + public static function setUpBeforeClass(): void { - /** @Given a MySQL container is configured */ - $mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'test-database') - ->pullImage() + $template = 'test-database-%s'; + + self::$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4') + ->withName(name: sprintf($template, gethostname())) ->withNetwork(name: 'tiny-blocks') + ->withDatabase(database: self::DATABASE) + ->withPassword(password: self::ROOT) ->withTimezone(timezone: 'America/Sao_Paulo') ->withUsername(user: self::ROOT) - ->withPassword(password: self::ROOT) - ->withDatabase(database: self::DATABASE) - ->withPortMapping(portOnHost: 3306, portOnContainer: 3306) - ->withRootPassword(rootPassword: self::ROOT) + ->withPortMapping(portOnHost: 0, portOnContainer: 3306) ->withGrantedHosts() - ->withReadinessTimeout(timeoutInSeconds: 60); - - /** @And a Flyway container is configured with migrations (pull starts in parallel) */ - $flywayContainer = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine') - ->pullImage() - ->withNetwork(name: 'tiny-blocks') - ->withMigrations(pathOnHost: '/test-adm-migrations') - ->withCleanDisabled(disabled: false) - ->withConnectRetries(retries: 15) - ->withValidateMigrationNaming(enabled: true); + ->withRootPassword(rootPassword: self::ROOT) + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 60)) + ->runIfNotExists(); + } - /** @When the MySQL container is started */ - $mySQLStarted = $mySQLContainer->runIfNotExists(); - $mySQLStarted->stopOnShutdown(); + public function testMySQLWhenStartedThenAcceptsConnectionsToItsDatabase(): void + { + /** @Given the MySQL container shared by this test run */ + $environmentVariables = self::$mySQLStarted->getEnvironmentVariables(); - /** @Then the MySQL container should be running */ - $environmentVariables = $mySQLStarted->getEnvironmentVariables(); - $address = $mySQLStarted->getAddress(); + /** @When the address of the container is read */ + $address = self::$mySQLStarted->getAddress(); - self::assertSame('test-database', $mySQLStarted->getName()); + /** @Then the container should expose MySQL and carry the configured database */ self::assertSame(3306, $address->getPorts()->firstExposedPort()); self::assertSame(self::DATABASE, $environmentVariables->getValueBy(key: 'MYSQL_DATABASE')); + } - /** @And when Flyway runs migrations against the started MySQL container */ - $flywayStarted = $flywayContainer - ->withSource( - password: $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD'), - username: $environmentVariables->getValueBy(key: 'MYSQL_USER'), - container: $mySQLStarted - ) - ->cleanAndMigrate(); - - /** @Then the migrations should have populated the database */ - self::assertNotEmpty($flywayStarted->getName()); + public function testRunToCompletionWhenCommandSucceedsThenReturnsItsOutput(): void + { + /** @Given a container whose command prints a line and exits successfully */ + $container = GenericDockerContainer::from(image: 'php:fpm-alpine'); - $records = MySQLRepository::connectFrom(container: $mySQLStarted)->allRecordsFrom(table: 'xpto'); + /** @When the container runs to completion */ + $execution = $container->runToCompletion( + timeout: Duration::fromSeconds(seconds: 60), + commands: ['sh', '-c', 'echo completed'] + ); - self::assertCount(10, $records); + /** @Then the outcome should carry what the container wrote */ + self::assertSame('completed', trim($execution->getOutput())); } - public function testRunCalledTwiceForSameContainerDoesNotStartTwice(): void + public function testRunIfNotExistsWhenCalledTwiceThenTheSameContainerIsReused(): void { - /** @Given a container is configured */ - $container = GenericDockerContainer::from(image: 'php:fpm-alpine', name: 'test-container') + /** @Given a container name unique to this test run */ + $name = sprintf('test-container-%s', gethostname()); + + /** @And a container that sleeps once started */ + $container = GenericDockerContainer::from(image: 'php:fpm-alpine') + ->withName(name: $name) ->withNetwork(name: 'tiny-blocks') - ->withWaitBeforeRun(wait: ContainerWaitForTime::forSeconds(seconds: 1)) + ->withWaitBeforeRun(wait: ContainerWaitForTime::forDuration(duration: Duration::fromSeconds(seconds: 1))) ->withEnvironmentVariable(key: 'TEST', value: '123'); - /** @When the container is started for the first time */ + /** @And the container started for the first time */ $firstRun = $container->runIfNotExists(); - $firstRun->stopOnShutdown(); - /** @Then the container should be successfully started */ - self::assertSame('123', $firstRun->getEnvironmentVariables()->getValueBy(key: 'TEST')); - - /** @And when the same container is started again */ - $secondRun = GenericDockerContainer::from(image: 'php:fpm-alpine', name: 'test-container') + /** @When the same container is asked for again */ + $secondRun = GenericDockerContainer::from(image: 'php:fpm-alpine') + ->withName(name: $name) ->runIfNotExists(); - /** @Then the container should not be restarted */ + /** @Then the first run should be reused rather than restarted */ + self::assertTrue($secondRun->wasReused()); self::assertSame($firstRun->getId(), $secondRun->getId()); - self::assertSame($firstRun->getName(), $secondRun->getName()); - self::assertEquals($firstRun->getAddress(), $secondRun->getAddress()); - self::assertEquals( - $firstRun->getEnvironmentVariables(), - $secondRun->getEnvironmentVariables() + self::assertSame('123', $secondRun->getEnvironmentVariables()->getValueBy(key: 'TEST')); + } + + public function testRunWhenFilesAreCopiedThenTheyExistBeforeTheContainerStarts(): void + { + /** @Given a directory holding a marker file */ + $template = '%s/Files'; + + /** @And a container whose command exits at once unless the marker file is already present */ + $container = GenericDockerContainer::from(image: 'php:fpm-alpine') + ->withNetwork(name: 'tiny-blocks') + ->copyToContainer(pathOnHost: sprintf($template, __DIR__), pathOnContainer: '/copied'); + + /** @And the container is started with that command */ + $started = $container->run(commands: ['sh', '-c', 'test -f /copied/marker.txt && sleep 60']); + + /** @And the container is removed when the test process exits */ + $started->stopOnShutdown(); + + /** @When the marker file is looked up inside the running container */ + $lookup = $started->executeAfterStarted(commands: ['test', '-f', '/copied/marker.txt']); + + /** @Then the marker file should be in place */ + self::assertTrue($lookup->isSuccessful()); + } + + public function testCleanAndMigrateWhenMigrationIsSlowThenWaitsForFlywayToFinish(): void + { + /** @Given a migration directory whose migration sleeps longer than ten seconds */ + $template = '%s/Database/SlowMigrations'; + + /** @And a Flyway container bound to the MySQL container with that slow migration */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine') + ->withSource(password: self::ROOT, username: self::ROOT, container: self::$mySQLStarted) + ->withNetwork(name: 'tiny-blocks') + ->withMigrations(pathOnHost: sprintf($template, __DIR__)) + ->withConnectRetries(retries: 15); + + /** @When Flyway cleans the schema and runs the slow migration */ + $flyway->cleanAndMigrate(); + + /** @Then the table created by the slow migration should already hold its rows */ + self::assertCount( + 1, + MySQLRepository::connectFrom(container: self::$mySQLStarted)->allRecordsFrom(table: 'slow') ); + } - /** @And when the container is stopped */ - $stopped = $firstRun->stop(); + public function testCleanAndMigrateWhenMigrationsAreGivenThenDatabaseIsPopulated(): void + { + /** @Given a migration directory */ + $template = '%s/Database/Migrations'; - /** @Then the stop operation should be successful */ - self::assertTrue($stopped->isSuccessful()); + /** @And a Flyway container bound to the MySQL container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine') + ->withSource(password: self::ROOT, username: self::ROOT, container: self::$mySQLStarted) + ->withNetwork(name: 'tiny-blocks') + ->withMigrations(pathOnHost: sprintf($template, __DIR__)) + ->withConnectRetries(retries: 15) + ->withMigrationNamingValidation(); + + /** @When Flyway cleans the schema and migrates it */ + $flyway->cleanAndMigrate(); + + /** @Then the migrations should have populated the database */ + self::assertCount( + 10, + MySQLRepository::connectFrom(container: self::$mySQLStarted)->allRecordsFrom(table: 'xpto') + ); + } + + public function testRunToCompletionWhenCommandFailsThenContainerExitUnsuccessfulCarriesItsError(): void + { + /** @Given a container whose command reports an error and exits with a non-zero status */ + $container = GenericDockerContainer::from(image: 'php:fpm-alpine'); + + /** @Then an exception carrying the error the container wrote should be thrown */ + $this->expectException(ContainerExitUnsuccessful::class); + $this->expectExceptionMessageMatches('/Output: \.$/'); + + /** @When the container runs to completion */ + $container->runToCompletion( + timeout: Duration::fromSeconds(seconds: 60), + commands: ['sh', '-c', 'echo "migration failed" >&2; exit 3'] + ); } } diff --git a/tests/Integration/Files/marker.txt b/tests/Integration/Files/marker.txt new file mode 100644 index 0000000..44688ef --- /dev/null +++ b/tests/Integration/Files/marker.txt @@ -0,0 +1 @@ +copied before the container starts diff --git a/tests/Unit/ClientMock.php b/tests/Unit/ClientMock.php index 2a02076..13c451c 100644 --- a/tests/Unit/ClientMock.php +++ b/tests/Unit/ClientMock.php @@ -5,147 +5,124 @@ namespace Test\Unit; use Throwable; +use TinyBlocks\DockerContainer\DockerClient; +use TinyBlocks\DockerContainer\DockerCommand; +use TinyBlocks\DockerContainer\DockerCommandWithTimeout; use TinyBlocks\DockerContainer\ExecutionCompleted; -use TinyBlocks\DockerContainer\Internal\Client\Client; -use TinyBlocks\DockerContainer\Internal\Commands\Command; -use TinyBlocks\DockerContainer\Internal\Commands\CommandWithTimeout; -use TinyBlocks\DockerContainer\Internal\Commands\DockerCopy; -use TinyBlocks\DockerContainer\Internal\Commands\DockerExecute; -use TinyBlocks\DockerContainer\Internal\Commands\DockerInspect; -use TinyBlocks\DockerContainer\Internal\Commands\DockerList; -use TinyBlocks\DockerContainer\Internal\Commands\DockerNetworkConnect; -use TinyBlocks\DockerContainer\Internal\Commands\DockerNetworkCreate; -use TinyBlocks\DockerContainer\Internal\Commands\DockerPull; -use TinyBlocks\DockerContainer\Internal\Commands\DockerRun; -use TinyBlocks\DockerContainer\Internal\Commands\DockerStop; - -final class ClientMock implements Client -{ - private array $runResponses = []; - - private array $listResponses = []; - - private array $inspectResponses = []; - - private array $executeResponses = []; - - private array $stopResponses = []; - private array $executedCommandLines = []; +final class ClientMock implements DockerClient +{ + private const array UNANSWERED = [ + 'docker ps' => ['', false], + 'docker exec' => ['', true], + 'docker stop' => ['', true], + 'docker start' => ['', true], + 'docker create' => ['', true], + 'docker inspect' => ['[null]', false], + 'docker start --attach' => ['', true], + '' => ['', true] + ]; + + private array $responses = [ + 'docker start --attach' => [], + 'docker inspect' => [], + 'docker create' => [], + 'docker start' => [], + 'docker stop' => [], + 'docker exec' => [], + 'docker ps' => [], + '' => [] + ]; private array $executedArguments = []; private array $requestedTimeouts = []; - private bool $runIsSuccessful = true; - - public function execute(Command $command): ExecutionCompleted + public function execute(DockerCommand $command): ExecutionCompleted { - $this->executedArguments[] = $command->toArguments(); - $this->executedCommandLines[] = implode(' ', $command->toArguments()); - - if ($command instanceof CommandWithTimeout) { - $this->requestedTimeouts[] = $command->getTimeoutInWholeSeconds(); + $arguments = $command->toArguments(); + $commandLine = implode(' ', $arguments); + $this->executedArguments[] = $arguments; + + $prefix = (string)array_find( + array_keys($this->responses), + static fn(string $prefix): bool => str_starts_with($commandLine, $prefix) + ); + $response = (array_shift($this->responses[$prefix]) ?? self::UNANSWERED[$prefix]); + + if ($response instanceof Throwable) { + throw $response; } - if ($command instanceof DockerExecute) { - $response = array_shift($this->executeResponses); - - if ($response instanceof Throwable) { - throw $response; - } - - [$output, $isSuccessful] = ($response ?? ['', true]); - - return new ExecutionCompletedMock(output: (string)$output, successful: $isSuccessful); - } - - [$output, $isSuccessful] = $this->responseFor(command: $command); + [$output, $isSuccessful] = $response; return new ExecutionCompletedMock(output: (string)$output, successful: $isSuccessful); } - private function responseFor(Command $command): array + public function executeWithTimeout(DockerCommandWithTimeout $command): ExecutionCompleted { - return match (true) { - $command instanceof DockerRun => [ - (array_shift($this->runResponses) ?? ''), - $this->runIsSuccessful - ], - $command instanceof DockerList => $this->listResponse(), - $command instanceof DockerStop => (array_shift($this->stopResponses) ?? ['', true]), - $command instanceof DockerInspect => $this->inspectResponse(), - default => $this->defaultResponse(command: $command) - }; + $this->requestedTimeouts[] = $command->timeout()->toSeconds(); + + return $this->execute(command: $command); } - private function listResponse(): array + public function getExecutedArguments(): array { - $output = (array_shift($this->listResponses) ?? ''); - - return [$output, !empty($output)]; + return $this->executedArguments; } - private function defaultResponse(Command $command): array + public function getRequestedTimeouts(): array { - $passthrough = $command instanceof DockerCopy - || $command instanceof DockerPull - || $command instanceof DockerNetworkCreate - || $command instanceof DockerNetworkConnect; - - return ['', $passthrough]; + return $this->requestedTimeouts; } - private function inspectResponse(): array + public function withDockerListResponse(string $output): void { - $inspectData = array_shift($this->inspectResponses); - - return [json_encode([$inspectData]), !empty($inspectData)]; + $this->responses['docker ps'][] = [$output, !empty($output)]; } - public function getExecutedArguments(): array + public function withDockerStopResponse(string $output, bool $isSuccessful = true): void { - return $this->executedArguments; + $this->responses['docker stop'][] = [$output, $isSuccessful]; } - public function getRequestedTimeouts(): array + public function getExecutedCommandLines(): array { - return $this->requestedTimeouts; + return array_map(static fn(array $arguments): string => implode(' ', $arguments), $this->executedArguments); } - public function withDockerRunResponse(string $output, bool $isSuccessful = true): void + public function withDockerStartResponse(string $output, bool $isSuccessful = true): void { - $this->runResponses[] = $output; - $this->runIsSuccessful = $isSuccessful; + $this->responses['docker start'][] = [$output, $isSuccessful]; } - public function withDockerListResponse(string $output): void + public function withDockerCreateResponse(string $output, bool $isSuccessful = true): void { - $this->listResponses[] = $output; + $this->responses['docker create'][] = [$output, $isSuccessful]; } - public function withDockerStopResponse(string $output, bool $isSuccessful = true): void + public function withDockerExecuteResponse(string $output, bool $isSuccessful = true): void { - $this->stopResponses[] = [$output, $isSuccessful]; + $this->responses['docker exec'][] = [$output, $isSuccessful]; } - public function getExecutedCommandLines(): array + public function withDockerInspectResponse(array $inspectResult): void { - return $this->executedCommandLines; + $this->responses['docker inspect'][] = [json_encode([$inspectResult]), !empty($inspectResult)]; } - public function withDockerExecuteResponse(string $output, bool $isSuccessful = true): void + public function withDockerExecuteException(Throwable $exception): void { - $this->executeResponses[] = [$output, $isSuccessful]; + $this->responses['docker exec'][] = $exception; } - public function withDockerInspectResponse(array $inspectResult): void + public function withDockerAttachedStartResponse(string $output, bool $isSuccessful = true): void { - $this->inspectResponses[] = $inspectResult; + $this->responses['docker start --attach'][] = [$output, $isSuccessful]; } - public function withDockerExecuteException(Throwable $exception): void + public function withDockerAttachedStartException(Throwable $exception): void { - $this->executeResponses[] = $exception; + $this->responses['docker start --attach'][] = $exception; } } diff --git a/tests/Unit/CommandMock.php b/tests/Unit/CommandMock.php index bdeff4e..5442844 100644 --- a/tests/Unit/CommandMock.php +++ b/tests/Unit/CommandMock.php @@ -4,9 +4,9 @@ namespace Test\Unit; -use TinyBlocks\DockerContainer\Internal\Commands\Command; +use TinyBlocks\DockerContainer\DockerCommand; -final readonly class CommandMock implements Command +final readonly class CommandMock implements DockerCommand { public function __construct(private string $command) { diff --git a/tests/Unit/CommandWithTimeoutMock.php b/tests/Unit/CommandWithTimeoutMock.php index 2116712..d2f3e6a 100644 --- a/tests/Unit/CommandWithTimeoutMock.php +++ b/tests/Unit/CommandWithTimeoutMock.php @@ -4,21 +4,22 @@ namespace Test\Unit; -use TinyBlocks\DockerContainer\Internal\Commands\CommandWithTimeout; +use TinyBlocks\DockerContainer\DockerCommandWithTimeout; +use TinyBlocks\Time\Duration; -final readonly class CommandWithTimeoutMock implements CommandWithTimeout +final readonly class CommandWithTimeoutMock implements DockerCommandWithTimeout { - public function __construct(private string $command, private int $timeoutInWholeSeconds) + public function __construct(private string $command, private Duration $timeout) { } - public function toArguments(): array + public function timeout(): Duration { - return explode(' ', $this->command); + return $this->timeout; } - public function getTimeoutInWholeSeconds(): int + public function toArguments(): array { - return $this->timeoutInWholeSeconds; + return explode(' ', $this->command); } } diff --git a/tests/Unit/EnvironmentFlagTest.php b/tests/Unit/EnvironmentFlagTest.php deleted file mode 100644 index 5530576..0000000 --- a/tests/Unit/EnvironmentFlagTest.php +++ /dev/null @@ -1,68 +0,0 @@ -newInstanceWithoutConstructor(); - - /** @When the private constructor is invoked through reflection */ - $reflection->getMethod('__construct')->invoke($instance); - - /** @Then the environment flag instance is created */ - self::assertInstanceOf(EnvironmentFlag::class, $instance); - } -} diff --git a/tests/Unit/FlywayDockerContainerTest.php b/tests/Unit/FlywayDockerContainerTest.php index 344a1b7..b993687 100644 --- a/tests/Unit/FlywayDockerContainerTest.php +++ b/tests/Unit/FlywayDockerContainerTest.php @@ -6,6 +6,11 @@ use PHPUnit\Framework\TestCase; use Test\Models\InspectResponseFixture; +use TinyBlocks\DockerContainer\Exceptions\ContainerExitUnsuccessful; +use TinyBlocks\DockerContainer\Exceptions\ContainerWaitTimeout; +use TinyBlocks\DockerContainer\Exceptions\DockerCommandExecutionFailed; +use TinyBlocks\DockerContainer\FlywayDockerContainer; +use TinyBlocks\Time\Duration; final class FlywayDockerContainerTest extends TestCase { @@ -16,58 +21,43 @@ protected function setUp(): void $this->client = new ClientMock(); } - public function testPullImageStartsBackgroundPull(): void + public function testMigrateThenFlywayCleanIsNotEnabled(): void { - /** @Given a Flyway container with image pulling enabled */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-pull', - image: 'flyway/flyway:12-alpine', - client: $this->client - )->pullImage(); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-pull') - ); + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); - /** @When migrate is called */ - $started = $container->migrate(); + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); - /** @Then the container should start successfully after the pull completes */ - self::assertSame('flyway-pull', $started->getName()); + /** @When migrate is called */ + $flyway->migrate(); - /** @And the docker pull command should have been executed */ - self::assertStringContainsString( - 'docker pull flyway/flyway:12-alpine', - implode(PHP_EOL, $this->client->getExecutedCommandLines()) - ); + /** @Then Flyway should keep its own default, which disables clean */ + self::assertStringNotContainsString('FLYWAY_CLEAN_DISABLED', $this->client->getExecutedCommandLines()[0]); } - public function testRepairRunsFlywayRepairCommand(): void + public function testWithNetworkThenFlywayJoinsTheNetwork(): void { - /** @Given a Flyway container */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-beta', - image: 'flyway/flyway:12-alpine', - client: $this->client - ); + /** @Given a Flyway container with a network */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withNetwork(name: 'test-network'); - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-beta') - ); + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); - /** @When repair is called */ - $started = $container->repair(); + /** @When migrate is called */ + $flyway->migrate(); - /** @Then the container should have executed the repair command */ - self::assertSame('flyway-beta', $started->getName()); - self::assertStringContainsString('repair', implode(PHP_EOL, $this->client->getExecutedCommandLines())); + /** @Then the network should be created and joined */ + $commandLines = implode(PHP_EOL, $this->client->getExecutedCommandLines()); + self::assertStringContainsString('--network=test-network', $commandLines); + self::assertStringContainsString( + 'docker network create --label tiny-blocks.docker-container=true test-network', + $commandLines + ); } - public function testWithTableOverridesDefaultTable(): void + public function testWithTableThenOverridesTheHistoryTable(): void { /** @Given a running MySQL container */ $mySQLStarted = RunningMySQLContainer::startWith( @@ -76,336 +66,314 @@ public function testWithTableOverridesDefaultTable(): void hostname: 'custom-table-db' ); - /** @And a Flyway container with source and a table override */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-override-table', - image: 'flyway/flyway:12-alpine', - client: $this->client - ) + /** @And a Flyway container with the MySQL source and a table override */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) ->withSource(password: 'root', username: 'root', container: $mySQLStarted) ->withTable(table: 'flyway_history'); - /** @And the MySQL readiness check succeeds */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - - /** @And the Docker daemon returns valid Flyway responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-override-table') - ); + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @When migrate is called */ - $container->migrate(); + $flyway->migrate(); - /** @Then the overridden table should be present in the command */ - self::assertStringContainsString( - 'FLYWAY_TABLE=flyway_history', + /** @Then the overridden table should come after the default one, so it prevails */ + self::assertMatchesRegularExpression( + '/FLYWAY_TABLE=schema_history .*FLYWAY_TABLE=flyway_history/', implode(PHP_EOL, $this->client->getExecutedCommandLines()) ); } - public function testCleanAndMigrateRunsBothCommands(): void + public function testRepairThenRunsFlywayRepairToCompletion(): void { /** @Given a Flyway container */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-clean-migrate', - image: 'flyway/flyway:12-alpine', - client: $this->client - ); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-clean-migrate') - ); + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); - /** @When cleanAndMigrate is called */ - $start = microtime(true); - $started = $container->cleanAndMigrate(); - $elapsed = (microtime(true) - $start); + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); - /** @Then the container should have executed clean followed by migrate */ - self::assertSame('flyway-clean-migrate', $started->getName()); - self::assertStringContainsString('clean migrate', implode(PHP_EOL, $this->client->getExecutedCommandLines())); + /** @When repair is called */ + $flyway->repair(); - /** @And the wait time should be exactly 10 seconds */ - self::assertGreaterThanOrEqual(9.5, $elapsed); - self::assertLessThanOrEqual(10.5, $elapsed); + /** @Then Flyway should have been created with the repair command, run attached, and removed */ + self::assertSame( + ['repair', 'docker start --attach 6acae5967be0', 'docker rm --force --volumes 6acae5967be0'], + [ + array_last($this->client->getExecutedArguments()[0]), + $this->client->getExecutedCommandLines()[1], + $this->client->getExecutedCommandLines()[2] + ] + ); } - public function testMigrateRunsFlywayMigrateCommand(): void + public function testMigrateThenRunsFlywayMigrateToCompletion(): void { /** @Given a Flyway container */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-alpha', - image: 'flyway/flyway:12-alpine', - client: $this->client - ); + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-alpha') - ); + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @When migrate is called */ - $started = $container->migrate(); + $flyway->migrate(); - /** @Then the container should have executed the migrate command */ - self::assertSame('flyway-alpha', $started->getName()); - self::assertStringContainsString('migrate', implode(PHP_EOL, $this->client->getExecutedCommandLines())); + /** @Then Flyway should have been created with the migrate command, run attached, and removed */ + self::assertSame( + ['migrate', 'docker start --attach 6acae5967be0', 'docker rm --force --volumes 6acae5967be0'], + [ + array_last($this->client->getExecutedArguments()[0]), + $this->client->getExecutedCommandLines()[1], + $this->client->getExecutedCommandLines()[2] + ] + ); } - public function testValidateRunsFlywayValidateCommand(): void + public function testWithSchemaThenOverridesTheDetectedSchema(): void { - /** @Given a Flyway container */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-gamma', - image: 'flyway/flyway:12-alpine', - client: $this->client - ); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-gamma') + /** @Given a running MySQL container with database "original" */ + $mySQLStarted = RunningMySQLContainer::startWith( + client: $this->client, + database: 'original', + hostname: 'override-db' ); - /** @When validate is called */ - $started = $container->validate(); - - /** @Then the container should have executed the validate command */ - self::assertSame('flyway-gamma', $started->getName()); - self::assertStringContainsString('validate', implode(PHP_EOL, $this->client->getExecutedCommandLines())); - } + /** @And a Flyway container with the MySQL source and a schema override */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withSource(password: 'root', username: 'root', container: $mySQLStarted) + ->withSchema(schema: 'custom_schema'); - public function testWithNetworkConfiguresDockerNetwork(): void - { - /** @Given a Flyway container with a network */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-network', - image: 'flyway/flyway:12-alpine', - client: $this->client - )->withNetwork(name: 'test-network'); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'flyway-network', - networkName: 'test-network' - ) - ); + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @When migrate is called */ - $container->migrate(); + $flyway->migrate(); - /** @Then the docker run command should include the network and auto-creation */ - $commandLines = $this->client->getExecutedCommandLines(); - self::assertStringContainsString('--network=test-network', implode(PHP_EOL, $commandLines)); - self::assertStringContainsString( - 'docker network create --label tiny-blocks.docker-container=true test-network', - implode(PHP_EOL, $commandLines) + /** @Then the overridden schema should come after the detected one, so it prevails */ + self::assertMatchesRegularExpression( + '/FLYWAY_SCHEMAS=original .*FLYWAY_SCHEMAS=custom_schema/', + implode(PHP_EOL, $this->client->getExecutedCommandLines()) ); } - public function testWithSchemaOverridesAutoDetectedSchema(): void + public function testWithSourceThenSetsTheDefaultHistoryTable(): void { - /** @Given a running MySQL container with database "original" */ + /** @Given a running MySQL container */ $mySQLStarted = RunningMySQLContainer::startWith( client: $this->client, - database: 'original', - hostname: 'override-db' + database: 'test_db', + hostname: 'table-db' ); - /** @And a Flyway container with source and a schema override */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-override-schema', - image: 'flyway/flyway:12-alpine', - client: $this->client - ) - ->withSource(password: 'root', username: 'root', container: $mySQLStarted) - ->withSchema(schema: 'custom_schema'); - - /** @And the MySQL readiness check succeeds */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + /** @And a Flyway container with the MySQL source */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withSource(password: 'root', username: 'root', container: $mySQLStarted); - /** @And the Docker daemon returns valid Flyway responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-override-schema') - ); + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @When migrate is called */ - $container->migrate(); + $flyway->migrate(); - /** @Then the overridden schema should be present in the command */ + /** @Then the history table should default to "schema_history" */ self::assertStringContainsString( - 'FLYWAY_SCHEMAS=custom_schema', + 'FLYWAY_TABLE=schema_history', implode(PHP_EOL, $this->client->getExecutedCommandLines()) ); } - public function testWithMigrationsConfiguresCopyAndLocation(): void + public function testValidateThenRunsFlywayValidateToCompletion(): void { - /** @Given a Flyway container with migrations configured */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-migrations', - image: 'flyway/flyway:12-alpine', - client: $this->client - )->withMigrations(pathOnHost: '/host/migrations'); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-migrations') + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @When validate is called */ + $flyway->validate(); + + /** @Then Flyway should have been created with the validate command, run attached, and removed */ + self::assertSame( + ['validate', 'docker start --attach 6acae5967be0', 'docker rm --force --volumes 6acae5967be0'], + [ + array_last($this->client->getExecutedArguments()[0]), + $this->client->getExecutedCommandLines()[1], + $this->client->getExecutedCommandLines()[2] + ] ); + } + + public function testWithConnectRetriesThenSetsTheNumberOfRetries(): void + { + /** @Given a Flyway container with connection retries */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withConnectRetries(retries: 10); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @When migrate is called */ - $container->migrate(); + $flyway->migrate(); - /** @Then the FLYWAY_LOCATIONS should point to the container migrations path */ - $commandLines = $this->client->getExecutedCommandLines(); - self::assertStringContainsString( - 'FLYWAY_LOCATIONS=filesystem:/flyway/migrations', - implode(PHP_EOL, $commandLines) - ); - self::assertStringContainsString('docker cp /host/migrations', implode(PHP_EOL, $commandLines)); + /** @Then the number of retries should be passed to Flyway */ + self::assertStringContainsString('FLYWAY_CONNECT_RETRIES=10', $this->client->getExecutedCommandLines()[0]); } - public function testWithSourceSetsDefaultSchemaHistoryTable(): void + public function testWithSourceThenConfiguresJdbcUrlAndCredentials(): void { /** @Given a running MySQL container */ $mySQLStarted = RunningMySQLContainer::startWith( client: $this->client, - database: 'test_db', - hostname: 'table-db' + database: 'app_database', + hostname: 'source-db' ); - /** @And a Flyway container configured with the MySQL source */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-table', - image: 'flyway/flyway:12-alpine', - client: $this->client - )->withSource(password: 'root', username: 'root', container: $mySQLStarted); - - /** @And the MySQL readiness check succeeds */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + /** @And a Flyway container with the MySQL source */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withSource(password: 'secret', username: 'admin', container: $mySQLStarted); - /** @And the Docker daemon returns valid Flyway responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-table') - ); + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @When migrate is called */ - $container->migrate(); + $flyway->migrate(); - /** @Then FLYWAY_TABLE should default to "schema_history" */ - self::assertStringContainsString( - 'FLYWAY_TABLE=schema_history', - implode(PHP_EOL, $this->client->getExecutedCommandLines()) - ); + /** @Then the Flyway container should carry the JDBC URL and the credentials */ + $commandLines = implode(PHP_EOL, $this->client->getExecutedCommandLines()); + self::assertStringContainsString('FLYWAY_URL=jdbc:mysql://source-db:3306/app_database', $commandLines); + self::assertStringContainsString('FLYWAY_USER=admin', $commandLines); + self::assertStringContainsString('FLYWAY_PASSWORD=secret', $commandLines); } - public function testWithCleanDisabledSetsEnvironmentVariable(): void + public function testWithMigrationsThenCopiesThemBeforeFlywayStarts(): void { - /** @Given a Flyway container with clean disabled */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-clean-disabled', - image: 'flyway/flyway:12-alpine', - client: $this->client - )->withCleanDisabled(disabled: true); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-clean-disabled') - ); + /** @Given a Flyway container with migrations */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withMigrations(pathOnHost: '/host/migrations'); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @When migrate is called */ - $container->migrate(); + $flyway->migrate(); - /** @Then FLYWAY_CLEAN_DISABLED should be set to true */ - self::assertStringContainsString( - 'FLYWAY_CLEAN_DISABLED=true', - implode(PHP_EOL, $this->client->getExecutedCommandLines()) + /** @Then Flyway should point at the migrations, copied after creation and before the attached start */ + self::assertContains( + 'FLYWAY_LOCATIONS=filesystem:/flyway/migrations', + $this->client->getExecutedArguments()[0] + ); + self::assertSame( + 'docker cp /host/migrations 6acae5967be0:/flyway/migrations', + $this->client->getExecutedCommandLines()[1] ); } - public function testWithConnectRetriesSetsEnvironmentVariable(): void + public function testMigrateWhenFlywaySucceedsThenReturnsFlywayOutput(): void { - /** @Given a Flyway container with connect retries configured */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-retries', - image: 'flyway/flyway:12-alpine', - client: $this->client - )->withConnectRetries(retries: 10); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-retries') - ); + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And Flyway exits successfully after applying the migrations */ + $this->client->withDockerAttachedStartResponse(output: 'Successfully applied 2 migrations'); /** @When migrate is called */ - $container->migrate(); + $execution = $flyway->migrate(); - /** @Then FLYWAY_CONNECT_RETRIES should be set to 10 */ - self::assertStringContainsString( - 'FLYWAY_CONNECT_RETRIES=10', - implode(PHP_EOL, $this->client->getExecutedCommandLines()) - ); + /** @Then the outcome should carry the Flyway output */ + self::assertSame('Successfully applied 2 migrations', $execution->getOutput()); } - public function testWithSourceConfiguresJdbcUrlAndCredentials(): void + public function testWithSourceThenWaitsForMySQLBeforeFlywayIsCreated(): void { - /** @Given a running MySQL container */ + /** @Given a running MySQL container, pinged once while it started */ $mySQLStarted = RunningMySQLContainer::startWith( client: $this->client, database: 'app_database', - hostname: 'source-db' + hostname: 'ready-db' ); - /** @And a Flyway container configured with the MySQL source */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-source', - image: 'flyway/flyway:12-alpine', - client: $this->client - )->withSource(password: 'secret', username: 'admin', container: $mySQLStarted); + /** @And a Flyway container with the MySQL source */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withSource(password: 'root', username: 'root', container: $mySQLStarted); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); - /** @And the MySQL readiness check succeeds */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + /** @When migrate is called */ + $flyway->migrate(); - /** @And the Docker daemon returns valid Flyway responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-source') + /** @Then MySQL should have been pinged again right before the Flyway container was created */ + self::assertSame( + ['docker exec ready-db env MYSQL_PWD=root mysqladmin ping -h 127.0.0.1', 'docker create'], + [ + $this->client->getExecutedCommandLines()[5], + implode(' ', array_slice($this->client->getExecutedArguments()[6], 0, 2)) + ] ); + } + + public function testMigrateWhenTimeoutIsGivenThenWaitsUpToThatTimeout(): void + { + /** @Given a Flyway container with a timeout of 45 seconds */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withTimeout(timeout: Duration::fromSeconds(seconds: 45)); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @When migrate is called */ - $container->migrate(); + $flyway->migrate(); + + /** @Then the wait for Flyway to exit should be bounded by the given timeout */ + self::assertSame([45], $this->client->getRequestedTimeouts()); + } - /** @Then the docker run command should include the JDBC URL and credentials */ - $commandLines = $this->client->getExecutedCommandLines(); + public function testWithEnvironmentVariableThenPassesTheFlywaySetting(): void + { + /** @Given a Flyway container with a placeholder setting */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withEnvironmentVariable(key: 'FLYWAY_PLACEHOLDERS_TENANT', value: 'acme'); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @When migrate is called */ + $flyway->migrate(); + + /** @Then the setting should be passed to Flyway */ self::assertStringContainsString( - 'FLYWAY_URL=jdbc:mysql://source-db:3306/app_database', - implode(PHP_EOL, $commandLines) + 'FLYWAY_PLACEHOLDERS_TENANT=acme', + $this->client->getExecutedCommandLines()[0] ); - self::assertStringContainsString('FLYWAY_USER=admin', implode(PHP_EOL, $commandLines)); - self::assertStringContainsString('FLYWAY_PASSWORD=secret', implode(PHP_EOL, $commandLines)); - - /** @And a MySQL readiness check should have been executed before Flyway started */ - $mysqladminPingCount = count( - array_filter( - $commandLines, - static fn(string $cmd): bool => str_contains($cmd, 'mysqladmin ping') - ) - ); - self::assertSame(2, $mysqladminPingCount); } - public function testWithSourceAutoDetectsSchemaFromMySQLContainer(): void + public function testMigrateWhenFlywayFailsThenFlywayContainerIsRemoved(): void + { + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And Flyway exits with a non-zero status */ + $this->client->withDockerAttachedStartResponse(output: 'ERROR: Unable to connect', isSuccessful: false); + + try { + /** @When migrate is called */ + $flyway->migrate(); + } catch (ContainerExitUnsuccessful) { + /** @Then the Flyway container should have been removed */ + self::assertSame( + 'docker rm --force --volumes 6acae5967be0', + array_last($this->client->getExecutedCommandLines()) + ); + } + } + + public function testWithSourceThenDetectsTheSchemaFromTheMySQLDatabase(): void { /** @Given a running MySQL container with database "products" */ $mySQLStarted = RunningMySQLContainer::startWith( @@ -414,54 +382,220 @@ public function testWithSourceAutoDetectsSchemaFromMySQLContainer(): void hostname: 'schema-db' ); - /** @And a Flyway container configured with the MySQL source */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-schema', - image: 'flyway/flyway:12-alpine', - client: $this->client - )->withSource(password: 'root', username: 'root', container: $mySQLStarted); - - /** @And the MySQL readiness check succeeds during Flyway startup */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + /** @And a Flyway container with the MySQL source */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withSource(password: 'root', username: 'root', container: $mySQLStarted); - /** @And the Docker daemon returns valid Flyway responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-schema') - ); + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @When migrate is called */ - $container->migrate(); + $flyway->migrate(); - /** @Then FLYWAY_SCHEMAS should be auto-detected from the MySQL database name */ + /** @Then the target schema should be the MySQL database */ self::assertStringContainsString( 'FLYWAY_SCHEMAS=products', implode(PHP_EOL, $this->client->getExecutedCommandLines()) ); } - public function testWithValidateMigrationNamingSetsEnvironmentVariable(): void + public function testMigrateWhenTimeoutIsNotGivenThenWaitsUpToTwoMinutes(): void { - /** @Given a Flyway container with migration naming validation enabled */ - $container = TestableFlywayDockerContainer::createWith( - name: 'flyway-naming', - image: 'flyway/flyway:12-alpine', - client: $this->client - )->withValidateMigrationNaming(enabled: true); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-naming') - ); + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @When migrate is called */ - $container->migrate(); + $flyway->migrate(); + + /** @Then the wait for Flyway to exit should be bounded by 120 seconds */ + self::assertSame([120], $this->client->getRequestedTimeouts()); + } - /** @Then FLYWAY_VALIDATE_MIGRATION_NAMING should be set to true */ + public function testWithTimeoutThenLeavesTheOriginalDefinitionUntouched(): void + { + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And a copy of it with a timeout of 45 seconds */ + $flyway->withTimeout(timeout: Duration::fromSeconds(seconds: 45)); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @When the original definition migrates */ + $flyway->migrate(); + + /** @Then it should still wait up to the default timeout */ + self::assertSame([120], $this->client->getRequestedTimeouts()); + } + + public function testWithMigrationNamingValidationThenEnablesTheValidation(): void + { + /** @Given a Flyway container with migration naming validation */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withMigrationNamingValidation(); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @When migrate is called */ + $flyway->migrate(); + + /** @Then the validation should be enabled in Flyway */ self::assertStringContainsString( 'FLYWAY_VALIDATE_MIGRATION_NAMING=true', - implode(PHP_EOL, $this->client->getExecutedCommandLines()) + $this->client->getExecutedCommandLines()[0] ); } + + public function testMigrateThenFlywayContainerIsNotRemovedByDockerOnItsOwn(): void + { + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @When migrate is called */ + $flyway->migrate(); + + /** @Then the Flyway container should be created without automatic removal, so its exit is observed */ + self::assertNotContains('--rm', $this->client->getExecutedArguments()[0]); + } + + public function testMigrateWhenTwoFlywayContainersRunThenEachHasItsOwnName(): void + { + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And another Flyway container from the same image, as a parallel test run would create */ + $parallelFlyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And the Docker daemon creates a Flyway container for each run */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And the first Flyway container has migrated */ + $flyway->migrate(); + + /** @When the other Flyway container migrates */ + $parallelFlyway->migrate(); + + /** @Then each run should have created its container under a distinct name */ + self::assertNotSame( + $this->client->getExecutedArguments()[0][5], + $this->client->getExecutedArguments()[3][5] + ); + } + + public function testMigrateWhenFlywayOutlastsTimeoutThenContainerWaitTimeout(): void + { + /** @Given a Flyway container with a timeout of 45 seconds */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withTimeout(timeout: Duration::fromSeconds(seconds: 45)); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And the attached run is interrupted when the timeout is reached */ + $this->client->withDockerAttachedStartException( + exception: new DockerCommandExecutionFailed( + reason: 'The process exceeded the timeout of 45 seconds.', + command: 'docker start --attach 6acae5967be0' + ) + ); + + /** @Then a timeout exception carrying the given timeout should be thrown */ + $this->expectException(ContainerWaitTimeout::class); + $this->expectExceptionMessageMatches('/^Container <\w+> did not exit within <45> seconds\.$/'); + + /** @When migrate is called */ + $flyway->migrate(); + } + + public function testCleanAndMigrateThenEnablesCleanAndRunsCleanFollowedByMigrate(): void + { + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @When cleanAndMigrate is called */ + $flyway->cleanAndMigrate(); + + /** @Then Flyway should be created with clean enabled and the clean and migrate commands */ + self::assertStringEndsWith( + '--env FLYWAY_CLEAN_DISABLED=false flyway/flyway:12-alpine clean migrate', + $this->client->getExecutedCommandLines()[0] + ); + } + + public function testMigrateWhenFlywayOutlastsTimeoutThenFlywayContainerIsRemoved(): void + { + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And the attached run is interrupted when the timeout is reached */ + $this->client->withDockerAttachedStartException( + exception: new DockerCommandExecutionFailed(reason: 'timeout', command: 'docker start') + ); + + try { + /** @When migrate is called */ + $flyway->migrate(); + } catch (ContainerWaitTimeout) { + /** @Then the Flyway container still running should have been removed */ + self::assertSame( + 'docker rm --force --volumes 6acae5967be0', + array_last($this->client->getExecutedCommandLines()) + ); + } + } + + public function testMigrateWhenFlywayCannotBeCreatedThenDockerCommandExecutionFailed(): void + { + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And the Docker daemon fails to create the Flyway container */ + $this->client->withDockerCreateResponse(output: 'pull access denied', isSuccessful: false); + + /** @Then the failure reported by Docker should be thrown */ + $this->expectException(DockerCommandExecutionFailed::class); + $this->expectExceptionMessageMatches('/Reason: pull access denied/'); + + /** @When migrate is called */ + $flyway->migrate(); + } + + public function testMigrateWhenFlywayFailsThenContainerExitUnsuccessfulCarriesFlywayError(): void + { + /** @Given a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And Flyway exits with a non-zero status after reporting the error */ + $this->client->withDockerAttachedStartResponse( + output: "ERROR: Validate failed: Migrations invalid\n", + isSuccessful: false + ); + + /** @Then an exception carrying the Flyway error should be thrown */ + $this->expectException(ContainerExitUnsuccessful::class); + $this->expectExceptionMessageMatches( + '/^Container <\w+> did not exit successfully\. Output: \.$/' + ); + + /** @When migrate is called */ + $flyway->migrate(); + } } diff --git a/tests/Unit/GenericDockerContainerTest.php b/tests/Unit/GenericDockerContainerTest.php index a2dce6e..60e7a8d 100644 --- a/tests/Unit/GenericDockerContainerTest.php +++ b/tests/Unit/GenericDockerContainerTest.php @@ -8,15 +8,15 @@ use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use Test\Models\InspectResponseFixture; -use TinyBlocks\DockerContainer\ContainerStarted; +use TinyBlocks\DockerContainer\Exceptions\ContainerWaitTimeout; +use TinyBlocks\DockerContainer\Exceptions\DockerCommandExecutionFailed; use TinyBlocks\DockerContainer\GenericDockerContainer; -use TinyBlocks\DockerContainer\Internal\Exceptions\ContainerWaitTimeout; -use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerContainerNotFound; -use TinyBlocks\DockerContainer\Internal\Exceptions\StopTimeoutOutOfRange; use TinyBlocks\DockerContainer\Waits\Conditions\ContainerReady; +use TinyBlocks\DockerContainer\Waits\ContainerWaitAfterStarted; use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency; use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime; +use TinyBlocks\Time\Duration; final class GenericDockerContainerTest extends TestCase { @@ -30,14 +30,11 @@ protected function setUp(): void public function testStopContainer(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'stop-test', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'stop-test'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-test')); $this->client->withDockerStopResponse(output: ''); @@ -45,7 +42,7 @@ public function testStopContainer(): void $started = $container->run(); /** @When the container is stopped */ - $stopped = $started->stop(); + $stopped = $started->stop(gracePeriod: Duration::fromSeconds(seconds: 10)); /** @Then the stop should be successful */ self::assertTrue($stopped->isSuccessful()); @@ -54,14 +51,11 @@ public function testStopContainer(): void public function testExecuteAfterStarted(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'exec-test', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'exec-test'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-test')); $this->client->withDockerExecuteResponse(output: 'command output'); @@ -79,14 +73,11 @@ public function testExecuteAfterStarted(): void public function testExceptionWhenRunFails(): void { /** @Given a container that will fail to start */ - $container = TestableGenericDockerContainer::createWith( - name: 'fail-test', - image: 'invalid:image', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'invalid:image', client: $this->client) + ->withName(name: 'fail-test'); /** @And the Docker daemon returns a failure */ - $this->client->withDockerRunResponse(output: 'Cannot connect to the Docker daemon.', isSuccessful: false); + $this->client->withDockerCreateResponse(output: 'Cannot connect to the Docker daemon.', isSuccessful: false); /** @Then a DockerCommandExecutionFailed exception should be thrown */ $this->expectException(DockerCommandExecutionFailed::class); @@ -99,14 +90,11 @@ public function testExceptionWhenRunFails(): void public function testRunContainerSuccessfully(): void { /** @Given a container configured with an image and a name */ - $container = TestableGenericDockerContainer::createWith( - name: 'test-alpine', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'test-alpine'); /** @And the Docker daemon returns a valid container ID and inspect response */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'test-alpine', @@ -127,14 +115,11 @@ public function testRunContainerSuccessfully(): void public function testRunContainerWithCommands(): void { /** @Given a container */ - $container = TestableGenericDockerContainer::createWith( - name: 'cmd-test', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'cmd-test'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'cmd-test')); /** @When the container is started with commands */ @@ -144,41 +129,15 @@ public function testRunContainerWithCommands(): void self::assertSame('cmd-test', $started->getName()); } - public function testRunContainerWithPullImage(): void - { - /** @Given a container with image pulling enabled */ - $container = TestableGenericDockerContainer::createWith( - name: 'pull-test', - image: 'alpine:latest', - client: $this->client - )->pullImage(); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'pull-test')); - - /** @When the container is started (waiting for the image pull to complete first) */ - $started = $container->run(); - - /** @Then the container should be running */ - self::assertSame('pull-test', $started->getName()); - - /** @And the docker pull command should have been executed */ - $commandLines = $this->client->getExecutedCommandLines(); - self::assertStringContainsString('docker pull alpine:latest', $commandLines[0]); - } - public function testContainerWithHostPortMapping(): void { /** @Given a container with a host port mapping */ - $container = TestableGenericDockerContainer::createWith( - name: 'host-port', - image: 'mysql:8.4', - client: $this->client - )->withPortMapping(portOnHost: 33060, portOnContainer: 3306); + $container = GenericDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'host-port') + ->withPortMapping(portOnHost: 33060, portOnContainer: 3306); /** @And the Docker daemon returns a response with host port bindings */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'host-port', @@ -210,44 +169,15 @@ public function testExceptionWhenImageNameIsEmpty(): void GenericDockerContainer::from(image: ''); } - public function testRemoveOnReusedContainerIsNoOp(): void - { - /** @Given a container returned by runIfNotExists (a Reused instance) */ - $container = TestableGenericDockerContainer::createWith( - name: 'reused-remove', - image: 'alpine:latest', - client: $this->client - ); - - /** @And the Docker list returns an existing container */ - $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); - - /** @And the Docker inspect returns the container details */ - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'reused-remove') - ); - - /** @When runIfNotExists returns a reused container */ - $started = $container->runIfNotExists(); - - /** @And remove is called on the reused container */ - $started->remove(); - - /** @Then the container should still be accessible (remove is a no-op for reused containers) */ - self::assertSame('reused-remove', $started->getName()); - } - public function testRunCommandLineIncludesNetwork(): void { /** @Given a container with a network */ - $container = TestableGenericDockerContainer::createWith( - name: 'net-cmd', - image: 'alpine:latest', - client: $this->client - )->withNetwork(name: 'my-network'); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'net-cmd') + ->withNetwork(name: 'my-network'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'net-cmd')); /** @When the container is started */ @@ -272,14 +202,12 @@ public function testRunContainerWithWaitBeforeRun(): void $condition->expects(self::once())->method('isReady')->willReturn(true); /** @And a container with a wait-before-run condition */ - $container = TestableGenericDockerContainer::createWith( - name: 'wait-test', - image: 'alpine:latest', - client: $this->client - )->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'wait-test') + ->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-test')); /** @When the container is started */ @@ -292,14 +220,12 @@ public function testRunContainerWithWaitBeforeRun(): void public function testRunContainerWithoutAutoRemove(): void { /** @Given a container with auto-remove disabled */ - $container = TestableGenericDockerContainer::createWith( - name: 'persistent', - image: 'alpine:latest', - client: $this->client - )->withoutAutoRemove(); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'persistent') + ->withoutAutoRemove(); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'persistent')); /** @When the container is started */ @@ -312,14 +238,11 @@ public function testRunContainerWithoutAutoRemove(): void public function testStopExecutesDockerStopCommand(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'stop-cmd', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'stop-cmd'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-cmd')); $this->client->withDockerStopResponse(output: ''); @@ -327,54 +250,22 @@ public function testStopExecutesDockerStopCommand(): void $started = $container->run(); /** @When the container is stopped */ - $started->stop(); + $started->stop(gracePeriod: Duration::fromSeconds(seconds: 10)); /** @Then a docker stop command should have been executed with the container ID */ - $stopCommand = $this->client->getExecutedCommandLines()[2]; + $stopCommand = $this->client->getExecutedCommandLines()[3]; self::assertStringStartsWith('docker stop', $stopCommand); self::assertStringContainsString(InspectResponseFixture::shortContainerId(), $stopCommand); } - public function testStopOnShutdownRegistersRemove(): void - { - /** @Given a ShutdownHook that tracks registration */ - $shutdownHook = new ShutdownHookMock(); - - /** @And a running container using the tracked hook */ - $container = TestableGenericDockerContainer::createWith( - name: 'shutdown-test', - image: 'alpine:latest', - client: $this->client, - shutdownHook: $shutdownHook - ); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'shutdown-test') - ); - - /** @And the container is started */ - $started = $container->run(); - - /** @When stopOnShutdown is called */ - $started->stopOnShutdown(); - - /** @Then the shutdown hook should have registered the remove callback */ - self::assertSame(1, $shutdownHook->getRegistrationCount()); - } - public function testRemoveCanBeCalledMultipleTimes(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'already-removed', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'already-removed'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build(hostname: 'already-removed') ); @@ -393,14 +284,11 @@ public function testRemoveCanBeCalledMultipleTimes(): void public function testRunCommandLineIncludesCommands(): void { /** @Given a container */ - $container = TestableGenericDockerContainer::createWith( - name: 'args-cmd', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'args-cmd'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'args-cmd')); /** @When the container is started with commands */ @@ -414,14 +302,11 @@ public function testRunCommandLineIncludesCommands(): void public function testStopContainerWithCustomTimeout(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'stop-timeout', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'stop-timeout'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build(hostname: 'stop-timeout') ); @@ -431,7 +316,7 @@ public function testStopContainerWithCustomTimeout(): void $started = $container->run(); /** @When the container is stopped with a custom timeout */ - $stopped = $started->stop(timeoutInWholeSeconds: 10); + $stopped = $started->stop(gracePeriod: Duration::fromSeconds(seconds: 10)); /** @Then the stop should be successful */ self::assertTrue($stopped->isSuccessful()); @@ -440,14 +325,12 @@ public function testStopContainerWithCustomTimeout(): void public function testRunContainerWithCopyToContainer(): void { /** @Given a container with files to copy */ - $container = TestableGenericDockerContainer::createWith( - name: 'copy-test', - image: 'alpine:latest', - client: $this->client - )->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config'); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'copy-test') + ->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'copy-test')); /** @When the container is started (docker cp is automatically called) */ @@ -464,17 +347,15 @@ public function testRunIfNotExistsWithWaitBeforeRun(): void $condition->expects(self::once())->method('isReady')->willReturn(true); /** @And a container with a wait-before-run that does not exist */ - $container = TestableGenericDockerContainer::createWith( - name: 'wait-new', - image: 'alpine:latest', - client: $this->client - )->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'wait-new') + ->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); /** @And the Docker list returns empty */ $this->client->withDockerListResponse(output: ''); /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-new')); /** @When runIfNotExists is called */ @@ -487,19 +368,19 @@ public function testRunIfNotExistsWithWaitBeforeRun(): void public function testRunContainerWithWaitAfterStarted(): void { /** @Given a container */ - $container = TestableGenericDockerContainer::createWith( - name: 'wait-after', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'wait-after') + ->withWaitAfterStarted( + wait: ContainerWaitForTime::forDuration(duration: Duration::fromSeconds(seconds: 1)) + ); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-after')); /** @When the container is started with a wait-after condition */ $start = microtime(true); - $started = $container->run(waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 1)); + $started = $container->run(); $elapsed = (microtime(true) - $start); /** @Then the container should have waited after starting */ @@ -510,14 +391,11 @@ public function testRunContainerWithWaitAfterStarted(): void public function testExecuteAfterStartedReturnsFailure(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'exec-fail', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'exec-fail'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-fail')); $this->client->withDockerExecuteResponse(output: 'command not found', isSuccessful: false); @@ -535,14 +413,12 @@ public function testExecuteAfterStartedReturnsFailure(): void public function testRunCommandLineIncludesPortMapping(): void { /** @Given a container with a port mapping */ - $container = TestableGenericDockerContainer::createWith( - name: 'port-cmd', - image: 'nginx:latest', - client: $this->client - )->withPortMapping(portOnHost: 8080, portOnContainer: 80); + $container = GenericDockerContainer::from(image: 'nginx:latest', client: $this->client) + ->withName(name: 'port-cmd') + ->withPortMapping(portOnHost: 8080, portOnContainer: 80); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'port-cmd')); /** @When the container is started */ @@ -556,14 +432,10 @@ public function testRunCommandLineIncludesPortMapping(): void public function testRunContainerWithAutoGeneratedName(): void { /** @Given a container without a name */ - $container = TestableGenericDockerContainer::createWith( - name: null, - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client); /** @And the Docker daemon returns valid responses (with any hostname from KSUID) */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'auto-generated' @@ -580,11 +452,8 @@ public function testRunContainerWithAutoGeneratedName(): void public function testRunContainerWithFullConfiguration(): void { /** @Given a fully configured container */ - $container = TestableGenericDockerContainer::createWith( - name: 'web-server', - image: 'nginx:latest', - client: $this->client - ) + $container = GenericDockerContainer::from(image: 'nginx:latest', client: $this->client) + ->withName(name: 'web-server') ->withNetwork(name: 'my-network') ->withPortMapping(portOnHost: 8080, portOnContainer: 80) ->withVolumeMapping(pathOnHost: '/var/www', pathOnContainer: '/usr/share/nginx/html') @@ -592,7 +461,7 @@ public function testRunContainerWithFullConfiguration(): void ->withEnvironmentVariable(key: 'NGINX_PORT', value: '80'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'web-server', @@ -622,17 +491,15 @@ public function testRunContainerWithFullConfiguration(): void public function testRunIfNotExistsCreatesNewContainer(): void { /** @Given a container that does not exist */ - $container = TestableGenericDockerContainer::createWith( - name: 'new-container', - image: 'alpine:latest', - client: $this->client - )->withEnvironmentVariable(key: 'APP_ENV', value: 'test'); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'new-container') + ->withEnvironmentVariable(key: 'APP_ENV', value: 'test'); /** @And the Docker list returns empty (container does not exist) */ $this->client->withDockerListResponse(output: ''); /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'new-container', @@ -655,17 +522,12 @@ public function testExceptionWhenWaitBeforeRunTimesOut(): void $condition->method('isReady')->willReturn(false); /** @And a container with a wait-before-run that has a short timeout */ - $container = TestableGenericDockerContainer::createWith( - name: 'timeout-wait', - image: 'alpine:latest', - client: $this->client - )->withWaitBeforeRun( - wait: ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: 1, - pollIntervalInMicroseconds: 50_000 - ) - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'timeout-wait') + ->withWaitBeforeRun( + wait: ContainerWaitForDependency::untilReady(condition: $condition) + ->withTimeout(timeout: Duration::fromSeconds(seconds: 1)) + ); /** @Then a ContainerWaitTimeout exception should be thrown */ $this->expectException(ContainerWaitTimeout::class); @@ -677,16 +539,13 @@ public function testExceptionWhenWaitBeforeRunTimesOut(): void public function testRunCommandLineIncludesVolumeMapping(): void { /** @Given a container with two volume mappings */ - $container = TestableGenericDockerContainer::createWith( - name: 'vol-cmd', - image: 'alpine:latest', - client: $this->client - ) + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'vol-cmd') ->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/app/data') ->withVolumeMapping(pathOnHost: '/host/logs', pathOnContainer: '/app/logs'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'vol-cmd')); /** @When the container is started */ @@ -703,16 +562,13 @@ public function testRunCommandLineIncludesVolumeMapping(): void public function testRunContainerWithMultiplePortMappings(): void { /** @Given a container with multiple port mappings */ - $container = TestableGenericDockerContainer::createWith( - name: 'multi-port', - image: 'nginx:latest', - client: $this->client - ) + $container = GenericDockerContainer::from(image: 'nginx:latest', client: $this->client) + ->withName(name: 'multi-port') ->withPortMapping(portOnHost: 8080, portOnContainer: 80) ->withPortMapping(portOnHost: 8443, portOnContainer: 443); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'multi-port', @@ -731,14 +587,11 @@ public function testRunContainerWithMultiplePortMappings(): void public function testAddressDefaultsWhenNetworkInfoIsEmpty(): void { /** @Given a container with empty network info */ - $container = TestableGenericDockerContainer::createWith( - name: 'no-net', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'no-net'); /** @And the Docker daemon returns a response with empty address data */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: '', @@ -757,16 +610,13 @@ public function testAddressDefaultsWhenNetworkInfoIsEmpty(): void public function testContainerWithMultipleHostPortMappings(): void { /** @Given a container with multiple host port mappings */ - $container = TestableGenericDockerContainer::createWith( - name: 'multi-host-port', - image: 'nginx:latest', - client: $this->client - ) + $container = GenericDockerContainer::from(image: 'nginx:latest', client: $this->client) + ->withName(name: 'multi-host-port') ->withPortMapping(portOnHost: 8080, portOnContainer: 80) ->withPortMapping(portOnHost: 8443, portOnContainer: 443); /** @And the Docker daemon returns a response with multiple host port bindings */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'multi-host-port', @@ -790,14 +640,11 @@ public function testContainerWithMultipleHostPortMappings(): void public function testRemoveExecutesDockerRmAndNetworkPrune(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'force-remove', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'force-remove'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build(hostname: 'force-remove') ); @@ -810,7 +657,7 @@ public function testRemoveExecutesDockerRmAndNetworkPrune(): void /** @Then a docker rm command should have been executed with the container ID */ $commandLines = $this->client->getExecutedCommandLines(); - $removeCommand = $commandLines[2]; + $removeCommand = $commandLines[3]; self::assertStringContainsString('docker rm --force --volumes', $removeCommand); self::assertStringContainsString( @@ -819,7 +666,7 @@ public function testRemoveExecutesDockerRmAndNetworkPrune(): void ); /** @And a docker network prune command should have been executed with the managed label */ - $pruneCommand = $commandLines[3]; + $pruneCommand = $commandLines[4]; self::assertStringContainsString( 'docker network prune --force --filter label=tiny-blocks.docker-container=true', @@ -827,44 +674,14 @@ public function testRemoveExecutesDockerRmAndNetworkPrune(): void ); } - public function testRunWhenGateDoesNotHoldThenNothingRuns(): void - { - /** @Given a container */ - $container = TestableGenericDockerContainer::createWith( - name: 'gate-closed', - image: 'alpine:latest', - client: $this->client - ); - - /** @And a callback that records whether it was invoked */ - $wasInvoked = false; - - /** @When runWhen is invoked with a gate that does not hold */ - $container->runWhen( - gate: static fn(): bool => false, - then: static function (ContainerStarted $started) use (&$wasInvoked): void { - $wasInvoked = true; - } - ); - - /** @Then the callback should not have been invoked */ - self::assertFalse($wasInvoked); - - /** @And no docker command should have been executed */ - self::assertEmpty($this->client->getExecutedCommandLines()); - } - public function testContainerWithNoExposedPortsReturnsNull(): void { /** @Given a container with no exposed ports */ - $container = TestableGenericDockerContainer::createWith( - name: 'no-ports', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'no-ports'); /** @And the Docker daemon returns valid responses without exposed ports */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'no-ports')); /** @When the container is started */ @@ -882,21 +699,19 @@ public function testContainerWithNoExposedPortsReturnsNull(): void public function testCopyToContainerExecutesDockerCpCommand(): void { /** @Given a container with a copy instruction */ - $container = TestableGenericDockerContainer::createWith( - name: 'cp-cmd', - image: 'alpine:latest', - client: $this->client - )->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql'); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'cp-cmd') + ->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: 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 */ - $copyCommand = $this->client->getExecutedCommandLines()[2]; + $copyCommand = $this->client->getExecutedCommandLines()[1]; self::assertStringStartsWith('docker cp', $copyCommand); self::assertStringContainsString('/host/sql', $copyCommand); self::assertStringContainsString('/app/sql', $copyCommand); @@ -905,16 +720,13 @@ public function testCopyToContainerExecutesDockerCpCommand(): void public function testRunContainerWithMultipleVolumeMappings(): void { /** @Given a container with multiple volume mappings */ - $container = TestableGenericDockerContainer::createWith( - name: 'multi-vol', - image: 'alpine:latest', - client: $this->client - ) + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'multi-vol') ->withVolumeMapping(pathOnHost: '/data', pathOnContainer: '/app/data') ->withVolumeMapping(pathOnHost: '/config', pathOnContainer: '/app/config'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'multi-vol')); /** @When the container is started */ @@ -927,11 +739,8 @@ public function testRunContainerWithMultipleVolumeMappings(): void public function testRunIfNotExistsReturnsExistingContainer(): void { /** @Given a container that already exists */ - $container = TestableGenericDockerContainer::createWith( - name: 'existing', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'existing'); /** @And the Docker list returns the existing container ID */ $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); @@ -956,14 +765,11 @@ public function testRunIfNotExistsReturnsExistingContainer(): void public function testContainerWithExposedPortButNoHostBinding(): void { /** @Given a container with an exposed port but no host binding */ - $container = TestableGenericDockerContainer::createWith( - name: 'no-host-bind', - image: 'redis:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'redis:latest', client: $this->client) + ->withName(name: 'no-host-bind'); /** @And the Docker daemon returns a response with exposed port but null host bindings */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'no-host-bind', @@ -986,14 +792,11 @@ public function testContainerWithExposedPortButNoHostBinding(): void public function testExecuteAfterStartedRunsDockerExecCommand(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'exec-cmd', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'exec-cmd'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-cmd')); $this->client->withDockerExecuteResponse(output: ''); @@ -1004,23 +807,20 @@ public function testExecuteAfterStartedRunsDockerExecCommand(): void $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]; + $execCommand = $this->client->getExecutedCommandLines()[3]; self::assertSame('docker exec exec-cmd ls -la /tmp', $execCommand); } public function testRunContainerWithMultipleCopyInstructions(): void { /** @Given a container with multiple copy instructions */ - $container = TestableGenericDockerContainer::createWith( - name: 'multi-copy', - image: 'alpine:latest', - client: $this->client - ) + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'multi-copy') ->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql') ->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'multi-copy')); /** @When the container is started */ @@ -1033,14 +833,11 @@ public function testRunContainerWithMultipleCopyInstructions(): void public function testExceptionWhenContainerInspectReturnsEmpty(): void { /** @Given a container whose inspect returns empty data */ - $container = TestableGenericDockerContainer::createWith( - name: 'ghost', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'ghost'); /** @And the Docker daemon returns a valid ID but empty inspect */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: []); /** @Then a DockerContainerNotFound exception should be thrown */ @@ -1054,14 +851,11 @@ public function testExceptionWhenContainerInspectReturnsEmpty(): void public function testRunCommandLineIncludesAutoRemoveByDefault(): void { /** @Given a container with default configuration */ - $container = TestableGenericDockerContainer::createWith( - name: 'rm-cmd', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'rm-cmd'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'rm-cmd')); /** @When the container is started */ @@ -1075,14 +869,12 @@ public function testRunCommandLineIncludesAutoRemoveByDefault(): void public function testRunCommandLineIncludesEnvironmentVariable(): void { /** @Given a container with an environment variable */ - $container = TestableGenericDockerContainer::createWith( - name: 'env-cmd', - image: 'alpine:latest', - client: $this->client - )->withEnvironmentVariable(key: 'APP_ENV', value: 'production'); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'env-cmd') + ->withEnvironmentVariable(key: 'APP_ENV', value: 'production'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'env-cmd')); /** @When the container is started */ @@ -1096,14 +888,11 @@ public function testRunCommandLineIncludesEnvironmentVariable(): void public function testContainerWithZeroHostPortDropsItFromResult(): void { /** @Given a container whose inspect payload reports a binding with HostPort equal to zero */ - $container = TestableGenericDockerContainer::createWith( - name: 'zero-host-port', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'zero-host-port'); /** @And the Docker daemon returns an inspect response with HostPort zero alongside a valid binding */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'zero-host-port', @@ -1125,14 +914,11 @@ public function testContainerWithZeroHostPortDropsItFromResult(): void public function testExceptionWhenDockerReturnsEmptyContainerId(): void { /** @Given a container */ - $container = TestableGenericDockerContainer::createWith( - name: 'empty-id', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'empty-id'); /** @And the Docker daemon returns an empty container ID */ - $this->client->withDockerRunResponse(output: ' '); + $this->client->withDockerCreateResponse(output: ' '); /** @Then an InvalidArgumentException should be thrown */ $this->expectException(InvalidArgumentException::class); @@ -1145,16 +931,13 @@ public function testExceptionWhenDockerReturnsEmptyContainerId(): void public function testRunCommandLineIncludesMultiplePortMappings(): void { /** @Given a container with multiple port mappings */ - $container = TestableGenericDockerContainer::createWith( - name: 'multi-port-cmd', - image: 'nginx:latest', - client: $this->client - ) + $container = GenericDockerContainer::from(image: 'nginx:latest', client: $this->client) + ->withName(name: 'multi-port-cmd') ->withPortMapping(portOnHost: 8080, portOnContainer: 80) ->withPortMapping(portOnHost: 8443, portOnContainer: 443); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'multi-port-cmd' @@ -1173,14 +956,11 @@ public function testRunCommandLineIncludesMultiplePortMappings(): void public function testContainerWithBindingMissingHostPortIsSkipped(): void { /** @Given a container whose inspect payload includes a binding with no HostPort key */ - $container = TestableGenericDockerContainer::createWith( - name: 'no-host-port-key', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'no-host-port-key'); /** @And the Docker daemon returns an inspect response with a partial binding */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'no-host-port-key', @@ -1201,14 +981,12 @@ public function testContainerWithBindingMissingHostPortIsSkipped(): void public function testRunCommandLineExcludesAutoRemoveWhenDisabled(): void { /** @Given a container with auto-remove disabled */ - $container = TestableGenericDockerContainer::createWith( - name: 'no-rm-cmd', - image: 'alpine:latest', - client: $this->client - )->withoutAutoRemove(); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'no-rm-cmd') + ->withoutAutoRemove(); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'no-rm-cmd')); /** @When the container is started */ @@ -1222,17 +1000,14 @@ public function testRunCommandLineExcludesAutoRemoveWhenDisabled(): void public function testRunContainerWithMultipleEnvironmentVariables(): void { /** @Given a container with multiple environment variables */ - $container = TestableGenericDockerContainer::createWith( - name: 'multi-env', - image: 'alpine:latest', - client: $this->client - ) + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'multi-env') ->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(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'multi-env', @@ -1255,14 +1030,11 @@ public function testRunContainerWithMultipleEnvironmentVariables(): void public function testExceptionWhenDockerReturnsTooShortContainerId(): void { /** @Given a container */ - $container = TestableGenericDockerContainer::createWith( - name: 'short-id', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'short-id'); /** @And the Docker daemon returns a too-short container ID */ - $this->client->withDockerRunResponse(output: 'abc123'); + $this->client->withDockerCreateResponse(output: 'abc123'); /** @Then an InvalidArgumentException should be thrown */ $this->expectException(InvalidArgumentException::class); @@ -1275,55 +1047,49 @@ public function testExceptionWhenDockerReturnsTooShortContainerId(): void public function testRunContainerThenInspectCommandTargetsContainerId(): void { /** @Given a container configured with an image and a name */ - $container = TestableGenericDockerContainer::createWith( - name: 'inspect-cmd', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'inspect-cmd'); /** @And the Docker daemon returns a valid container ID and inspect response */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'inspect-cmd')); /** @When the container is started */ $container->run(); /** @Then a docker inspect command should have been executed for the container ID */ - $inspectCommand = $this->client->getExecutedCommandLines()[1]; + $inspectCommand = $this->client->getExecutedCommandLines()[2]; self::assertSame(sprintf('docker inspect %s', InspectResponseFixture::shortContainerId()), $inspectCommand); } - public function testRunContainerWhenStartedFreshThenWasReusedIsFalse(): void + public function testRunWhenStartFailsThenDockerCommandExecutionFailed(): void { /** @Given a container */ - $container = TestableGenericDockerContainer::createWith( - name: 'fresh-run', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'start-fails'); - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'fresh-run')); + /** @And the Docker daemon creates the container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); - /** @When the container is started */ - $started = $container->run(); + /** @And the Docker daemon fails to start it */ + $this->client->withDockerStartResponse(output: 'port is already allocated', isSuccessful: false); - /** @Then the started container should report that it was not reused */ - self::assertFalse($started->wasReused()); + /** @Then the failure should name the start command and carry the reason reported by Docker */ + $this->expectException(DockerCommandExecutionFailed::class); + $this->expectExceptionMessageMatches("/<'docker' 'start' '6acae5967be0'>.*Reason: port is already allocated/"); + + /** @When the container is started */ + $container->run(); } public function testEnvironmentVariableReturnsEmptyStringForMissingKey(): void { /** @Given a running container with known environment variables */ - $container = TestableGenericDockerContainer::createWith( - name: 'env-test', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'env-test'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'env-test', @@ -1341,17 +1107,43 @@ public function testEnvironmentVariableReturnsEmptyStringForMissingKey(): void self::assertSame('', $missingValue); } + public function testRunWhenTwoWaitsBeforeRunAreAddedThenBothAreApplied(): void + { + /** @Given a first condition that is immediately ready */ + $first = $this->createMock(ContainerReady::class); + $first->expects(self::once())->method('isReady')->willReturn(true); + + /** @And a second condition that is immediately ready */ + $second = $this->createMock(ContainerReady::class); + $second->expects(self::once())->method('isReady')->willReturn(true); + + /** @And a container that waits for both conditions before it runs */ + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'two-waits-before') + ->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $first)) + ->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $second)); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'two-waits-before') + ); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then the container should be running after both conditions held */ + self::assertSame('two-waits-before', $started->getName()); + } + public function testStopContainerWhenTimeoutIsZeroThenStopIsSuccessful(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'stop-zero', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'stop-zero'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-zero')); $this->client->withDockerStopResponse(output: ''); @@ -1359,20 +1151,49 @@ public function testStopContainerWhenTimeoutIsZeroThenStopIsSuccessful(): void $started = $container->run(); /** @When the container is stopped with a zero graceful timeout */ - $stopped = $started->stop(timeoutInWholeSeconds: 0); + $stopped = $started->stop(gracePeriod: Duration::fromSeconds(seconds: 0)); /** @Then the stop should be successful */ self::assertTrue($stopped->isSuccessful()); } + #[RunInSeparateProcess] + public function testStopOnShutdownWhenProcessExitsThenContainerIsRemoved(): void + { + $template = '%s/Internal/Containers/Overrides/register_shutdown_function_spy.php'; + require_once sprintf($template, __DIR__); + + /** @Given a Docker client */ + $client = new ClientMock(); + + /** @And a container */ + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $client) + ->withName(name: 'shutdown-run'); + + /** @And the Docker daemon returns valid responses */ + $client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And the inspect response carries the container details */ + $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'shutdown-run')); + + /** @And the container is started and registered for removal on shutdown */ + $container->run()->stopOnShutdown(); + + /** @When the registered shutdown callback runs */ + $GLOBALS['registeredShutdownCallbacks'][0](); + + /** @Then the container should have been removed */ + self::assertContains( + sprintf('docker rm --force --volumes %s', InspectResponseFixture::shortContainerId()), + $client->getExecutedCommandLines() + ); + } + public function testRunIfNotExistsWhenContainerExistsThenStartedWasReused(): void { /** @Given a container that already exists */ - $container = TestableGenericDockerContainer::createWith( - name: 'reused-flag', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'reused-flag'); /** @And the Docker list returns the existing container ID */ $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); @@ -1397,14 +1218,12 @@ public function testGetPortForConnectionWhenInsideDockerReturnsExposedPort(): vo $client = new ClientMock(); /** @And a container with a host port mapping */ - $container = TestableGenericDockerContainer::createWith( - name: 'conn-port', - image: 'mysql:8.4', - client: $client - )->withPortMapping(portOnHost: 33060, portOnContainer: 3306); + $container = GenericDockerContainer::from(image: 'mysql:8.4', client: $client) + ->withName(name: 'conn-port') + ->withPortMapping(portOnHost: 33060, portOnContainer: 3306); /** @And the Docker daemon returns valid responses */ - $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @And the inspect response carries exposed and host-mapped ports */ $client->withDockerInspectResponse( @@ -1435,11 +1254,8 @@ public function testRunIfNotExistsWhenOutsideDockerThenSkipsReaperCreation(): vo $client = new ClientMock(); /** @And a container that already exists, running outside a Docker environment */ - $container = TestableGenericDockerContainer::createWith( - name: 'reaper-outside', - image: 'alpine:latest', - client: $client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $client) + ->withName(name: 'reaper-outside'); /** @And the Docker list returns an existing container */ $client->withDockerListResponse(output: InspectResponseFixture::containerId()); @@ -1457,40 +1273,6 @@ public function testRunIfNotExistsWhenOutsideDockerThenSkipsReaperCreation(): vo ); } - public function testRunWhenGateHoldsThenStartedContainerIsPassedToCallback(): void - { - /** @Given a container */ - $container = TestableGenericDockerContainer::createWith( - name: 'gate-open', - image: 'alpine:latest', - client: $this->client - ); - - /** @And the Docker list is empty so a fresh container is started */ - $this->client->withDockerListResponse(output: ''); - - /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'gate-open')); - - /** @And a callback that captures the container it receives */ - $received = null; - - /** @When runWhen is invoked with a gate that holds */ - $container->runWhen( - gate: static fn(): bool => true, - then: static function (ContainerStarted $started) use (&$received): void { - $received = $started; - } - ); - - /** @Then the callback should have received the started container */ - self::assertSame('gate-open', $received?->getName()); - - /** @And a docker run command should have been executed */ - self::assertStringContainsString('docker run', implode(PHP_EOL, $this->client->getExecutedCommandLines())); - } - #[RunInSeparateProcess] public function testRunIfNotExistsSkipsReaperCreationWhenReaperAlreadyExists(): void { @@ -1499,11 +1281,8 @@ public function testRunIfNotExistsSkipsReaperCreationWhenReaperAlreadyExists(): /** @Given a container that already exists */ $client = new ClientMock(); - $container = TestableGenericDockerContainer::createWith( - name: 'reaper-skip', - image: 'alpine:latest', - client: $client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $client) + ->withName(name: 'reaper-skip'); /** @And the Docker list returns an existing container */ $client->withDockerListResponse(output: InspectResponseFixture::containerId()); @@ -1539,11 +1318,8 @@ public function testRunIfNotExistsSkipsReaperCreationWhenReaperAlreadyExists(): public function testRunIfNotExistsThenListCommandFiltersByExactContainerName(): void { /** @Given a container that already exists */ - $container = TestableGenericDockerContainer::createWith( - name: 'list-cmd', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'list-cmd'); /** @And the Docker list returns the existing container ID */ $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); @@ -1559,6 +1335,60 @@ public function testRunIfNotExistsThenListCommandFiltersByExactContainerName(): self::assertSame('docker ps --all --quiet --filter name=^list-cmd$', $listCommand); } + #[RunInSeparateProcess] + public function testStopOnShutdownWhenCalledThenRegistersOneShutdownCallback(): void + { + $template = '%s/Internal/Containers/Overrides/register_shutdown_function_spy.php'; + require_once sprintf($template, __DIR__); + + /** @Given a Docker client */ + $client = new ClientMock(); + + /** @And a container */ + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $client) + ->withName(name: 'shutdown-test'); + + /** @And the Docker daemon returns valid responses */ + $client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And the inspect response carries the container details */ + $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'shutdown-test')); + + /** @And the container is started */ + $started = $container->run(); + + /** @When stopOnShutdown is called */ + $started->stopOnShutdown(); + + /** @Then exactly one shutdown callback should have been registered */ + self::assertCount(1, $GLOBALS['registeredShutdownCallbacks']); + } + + public function testStopWhenContainerIsSharedThenStopsTheUnderlyingContainer(): void + { + /** @Given a container that already exists */ + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'shared-stop'); + + /** @And the Docker list returns the existing container ID */ + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + + /** @And the Docker inspect returns the container details */ + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'shared-stop')); + + /** @And the shared container is obtained */ + $started = $container->runIfNotExists(); + + /** @When the shared container is stopped with a grace period of 30 seconds */ + $started->stop(gracePeriod: Duration::fromSeconds(seconds: 30)); + + /** @Then the underlying container should be stopped with that grace period */ + self::assertContains( + sprintf('docker stop --time 30 %s', InspectResponseFixture::shortContainerId()), + $this->client->getExecutedCommandLines() + ); + } + #[RunInSeparateProcess] public function testGetPortForConnectionWhenOutsideDockerReturnsHostMappedPort(): void { @@ -1569,14 +1399,12 @@ public function testGetPortForConnectionWhenOutsideDockerReturnsHostMappedPort() $client = new ClientMock(); /** @And a container with a host port mapping */ - $container = TestableGenericDockerContainer::createWith( - name: 'conn-port', - image: 'mysql:8.4', - client: $client - )->withPortMapping(portOnHost: 33060, portOnContainer: 3306); + $container = GenericDockerContainer::from(image: 'mysql:8.4', client: $client) + ->withName(name: 'conn-port') + ->withPortMapping(portOnHost: 33060, portOnContainer: 3306); /** @And the Docker daemon returns valid responses */ - $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @And the inspect response carries exposed and host-mapped ports */ $client->withDockerInspectResponse( @@ -1600,14 +1428,11 @@ public function testGetPortForConnectionWhenOutsideDockerReturnsHostMappedPort() public function testRunContainerWhenExposedPortIsZeroThenItIsDroppedFromResult(): void { /** @Given a container */ - $container = TestableGenericDockerContainer::createWith( - name: 'zero-exposed', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'zero-exposed'); /** @And the Docker daemon returns an inspect response with a zero exposed port alongside a valid one */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'zero-exposed', @@ -1632,11 +1457,8 @@ public function testRunIfNotExistsWhenReaperIsMissingThenStartsReaperContainer() $client = new ClientMock(); /** @And a container that already exists */ - $container = TestableGenericDockerContainer::createWith( - name: 'reaper-start', - image: 'alpine:latest', - client: $client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $client) + ->withName(name: 'reaper-start'); /** @And the Docker list returns an existing container */ $client->withDockerListResponse(output: InspectResponseFixture::containerId()); @@ -1661,16 +1483,13 @@ public function testRunIfNotExistsWhenReaperIsMissingThenStartsReaperContainer() public function testContainerWithMixedValidAndNullHostBindingsRetainsValidPorts(): void { /** @Given a container whose inspect payload mixes valid and null host bindings */ - $container = TestableGenericDockerContainer::createWith( - name: 'mixed-bindings', - image: 'nginx:latest', - client: $this->client - ) + $container = GenericDockerContainer::from(image: 'nginx:latest', client: $this->client) + ->withName(name: 'mixed-bindings') ->withPortMapping(portOnHost: 8080, portOnContainer: 80) ->withPortMapping(portOnHost: 8443, portOnContainer: 443); /** @And the Docker daemon returns an inspect response with a trailing null binding */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'mixed-bindings', @@ -1700,14 +1519,11 @@ public function testGetHostForConnectionWhenOutsideDockerReturnsLoopbackAddress( $client = new ClientMock(); /** @And a container with a known hostname */ - $container = TestableGenericDockerContainer::createWith( - name: 'conn-host', - image: 'alpine:latest', - client: $client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $client) + ->withName(name: 'conn-host'); /** @And the Docker daemon returns valid responses */ - $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @And the inspect response carries the hostname */ $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'conn-host')); @@ -1730,14 +1546,12 @@ public function testRunContainerWithNetworkWhenOutsideDockerSkipsHostConnection( /** @Given a container configured with a network, running outside a Docker environment */ $client = new ClientMock(); - $container = TestableGenericDockerContainer::createWith( - name: 'outside-docker', - image: 'alpine:latest', - client: $client - )->withNetwork(name: 'my-network'); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $client) + ->withName(name: 'outside-docker') + ->withNetwork(name: 'my-network'); /** @And the Docker daemon returns valid responses */ - $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build(hostname: 'outside-docker') ); @@ -1762,17 +1576,14 @@ public function testRunContainerWithNetworkWhenOutsideDockerSkipsHostConnection( public function testRunIfNotExistsWhenContainerIsMissingThenStartedWasNotReused(): void { /** @Given a container that does not exist */ - $container = TestableGenericDockerContainer::createWith( - name: 'fresh-flag', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'fresh-flag'); /** @And the Docker list returns empty so the container is created */ $this->client->withDockerListResponse(output: ''); /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'fresh-flag')); /** @When runIfNotExists is called */ @@ -1782,47 +1593,18 @@ public function testRunIfNotExistsWhenContainerIsMissingThenStartedWasNotReused( self::assertFalse($started->wasReused()); } - public function testStopContainerWhenTimeoutIsNegativeThenStopTimeoutOutOfRange(): void - { - /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'stop-negative', - image: 'alpine:latest', - client: $this->client - ); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'stop-negative') - ); - - /** @And the container is started */ - $started = $container->run(); - - /** @Then an exception indicating the graceful timeout is out of range should be thrown */ - $this->expectException(StopTimeoutOutOfRange::class); - $this->expectExceptionMessage('Graceful stop timeout must be zero or greater, got <-1> seconds.'); - - /** @When the container is stopped with a negative graceful timeout */ - $started->stop(timeoutInWholeSeconds: -1); - } - public function testExceptionWhenRunFailsRendersCommandWithShellEscapedArguments(): void { /** @Given a container that will fail to start */ - $container = TestableGenericDockerContainer::createWith( - name: 'fail-render-test', - image: 'invalid:image', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'invalid:image', client: $this->client) + ->withName(name: 'fail-render-test'); /** @And the Docker daemon returns a failure */ - $this->client->withDockerRunResponse(output: 'boom', isSuccessful: false); + $this->client->withDockerCreateResponse(output: 'boom', isSuccessful: false); /** @Then the failure message should render each argument shell-escaped */ $this->expectException(DockerCommandExecutionFailed::class); - $this->expectExceptionMessageMatches("/'docker' 'run'/"); + $this->expectExceptionMessageMatches("/'docker' 'create'/"); /** @When the container is started */ $container->run(); @@ -1838,14 +1620,11 @@ public function testGetHostForConnectionWhenInsideDockerReturnsContainerHostname $client = new ClientMock(); /** @And a container with a known hostname */ - $container = TestableGenericDockerContainer::createWith( - name: 'conn-host', - image: 'alpine:latest', - client: $client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $client) + ->withName(name: 'conn-host'); /** @And the Docker daemon returns valid responses */ - $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @And the inspect response carries the hostname */ $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'conn-host')); @@ -1870,14 +1649,12 @@ public function testRunContainerWithNetworkWhenInsideDockerConnectsHostToNetwork $client = new ClientMock(); /** @And a container configured with a network, running inside a Docker environment */ - $container = TestableGenericDockerContainer::createWith( - name: 'inside-docker', - image: 'alpine:latest', - client: $client - )->withNetwork(name: 'my-network'); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $client) + ->withName(name: 'inside-docker') + ->withNetwork(name: 'my-network'); /** @And the Docker daemon returns valid responses */ - $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); /** @And the inspect response carries the hostname */ $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'inside-docker')); @@ -1893,17 +1670,14 @@ public function testRunContainerWithNetworkWhenInsideDockerConnectsHostToNetwork public function testRunIfNotExistsTreatsWhitespaceOnlyListOutputAsMissingContainer(): void { /** @Given a container that does not exist according to a whitespace-only docker list response */ - $container = TestableGenericDockerContainer::createWith( - name: 'whitespace-list', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'whitespace-list'); /** @And the Docker list returns only whitespace */ $this->client->withDockerListResponse(output: " \n\t "); /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build(hostname: 'whitespace-list') ); @@ -1915,17 +1689,69 @@ public function testRunIfNotExistsTreatsWhitespaceOnlyListOutputAsMissingContain self::assertSame('whitespace-list', $started->getName()); } + public function testRunWhenFilesAreCopiedThenTheyAreCopiedBeforeTheContainerStarts(): void + { + /** @Given a container with a copy instruction */ + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'cp-before-start') + ->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql'); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'cp-before-start') + ); + + /** @When the container is started */ + $container->run(); + + /** @Then the container should be created, receive the files, be started, and only then be inspected */ + self::assertSame( + ['docker create', 'docker cp', 'docker start', 'docker inspect'], + array_map( + static fn(array $arguments): string => implode(' ', array_slice($arguments, 0, 2)), + $this->client->getExecutedArguments() + ) + ); + } + + public function testRunWhenTwoWaitsAfterStartedAreAddedThenBothReceiveTheContainer(): void + { + /** @Given a first wait strategy applied after the container starts */ + $first = $this->createMock(ContainerWaitAfterStarted::class); + $first->expects(self::once())->method('waitAfter'); + + /** @And a second wait strategy applied after the container starts */ + $second = $this->createMock(ContainerWaitAfterStarted::class); + $second->expects(self::once())->method('waitAfter'); + + /** @And a container that applies both wait strategies after it starts */ + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'two-waits-after') + ->withWaitAfterStarted(wait: $first) + ->withWaitAfterStarted(wait: $second); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'two-waits-after') + ); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then the container should be running after both wait strategies were applied */ + self::assertSame('two-waits-after', $started->getName()); + } + public function testRunContainerWhenContainerIdIsExactlyMinimumLengthThenIdIsAccepted(): void { /** @Given a container */ - $container = TestableGenericDockerContainer::createWith( - name: 'min-id', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'min-id'); /** @And the Docker daemon returns a container ID exactly at the minimum length */ - $this->client->withDockerRunResponse(output: '123456789012'); + $this->client->withDockerCreateResponse(output: '123456789012'); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'min-id')); /** @When the container is started */ @@ -1945,11 +1771,8 @@ public function testRunIfNotExistsWhenReaperListIsWhitespaceThenStartsReaperCont $client = new ClientMock(); /** @And a container that already exists */ - $container = TestableGenericDockerContainer::createWith( - name: 'reaper-blank', - image: 'alpine:latest', - client: $client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $client) + ->withName(name: 'reaper-blank'); /** @And the Docker list returns an existing container */ $client->withDockerListResponse(output: InspectResponseFixture::containerId()); @@ -1970,17 +1793,40 @@ public function testRunIfNotExistsWhenReaperListIsWhitespaceThenStartsReaperCont ); } + public function testRunIfNotExistsWhenContainerIsReusedThenWaitsAfterStartedAreSkipped(): void + { + /** @Given a wait strategy applied after a new container starts */ + $wait = $this->createMock(ContainerWaitAfterStarted::class); + $wait->expects(self::never())->method('waitAfter'); + + /** @And a container that already exists */ + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'reused-waits') + ->withWaitAfterStarted(wait: $wait); + + /** @And the Docker list returns the existing container ID */ + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + + /** @And the Docker inspect returns the container details */ + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'reused-waits') + ); + + /** @When the container is asked for */ + $started = $container->runIfNotExists(); + + /** @Then the existing container should be reused without applying the wait strategy */ + self::assertTrue($started->wasReused()); + } + public function testStopContainerWhenCustomTimeoutThenProcessTimeoutExceedsGracePeriod(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'stop-buffer', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'stop-buffer'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build(hostname: 'stop-buffer') ); @@ -1990,7 +1836,7 @@ public function testStopContainerWhenCustomTimeoutThenProcessTimeoutExceedsGrace $started = $container->run(); /** @When the container is stopped with a custom graceful timeout */ - $started->stop(timeoutInWholeSeconds: 45); + $started->stop(gracePeriod: Duration::fromSeconds(seconds: 45)); /** @Then the process timeout requested exceeds the graceful period by the safety buffer */ self::assertSame([55], $this->client->getRequestedTimeouts()); @@ -1999,14 +1845,11 @@ public function testStopContainerWhenCustomTimeoutThenProcessTimeoutExceedsGrace public function testStopContainerWhenCustomTimeoutThenCommandCarriesTimeoutAsStringArgument(): void { /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - name: 'stop-args', - image: 'alpine:latest', - client: $this->client - ); + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'stop-args'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-args')); $this->client->withDockerStopResponse(output: ''); @@ -2014,10 +1857,10 @@ public function testStopContainerWhenCustomTimeoutThenCommandCarriesTimeoutAsStr $started = $container->run(); /** @When the container is stopped with a custom timeout */ - $started->stop(timeoutInWholeSeconds: 45); + $started->stop(gracePeriod: Duration::fromSeconds(seconds: 45)); /** @Then the stop command arguments should carry the timeout as a string */ - $stopArguments = $this->client->getExecutedArguments()[2]; + $stopArguments = $this->client->getExecutedArguments()[3]; self::assertSame( ['docker', 'stop', '--time', '45', InspectResponseFixture::shortContainerId()], $stopArguments diff --git a/tests/Unit/Internal/Client/CommandLineDockerClientTest.php b/tests/Unit/Internal/Client/CommandLineDockerClientTest.php new file mode 100644 index 0000000..8fb3c1a --- /dev/null +++ b/tests/Unit/Internal/Client/CommandLineDockerClientTest.php @@ -0,0 +1,73 @@ +client = new CommandLineDockerClient(); + } + + public function testExecuteWhenCommandSucceedsThenOutputIsReturned(): void + { + /** @Given a command that will succeed */ + $command = new CommandMock(command: 'echo Hello'); + + /** @When the command is executed */ + $execution = $this->client->execute(command: $command); + + /** @Then the output should contain the expected result */ + self::assertTrue($execution->isSuccessful()); + self::assertStringContainsString('Hello', $execution->getOutput()); + } + + public function testExecuteWhenCommandFailsThenErrorOutputIsReturned(): void + { + /** @Given a command that will fail */ + $command = new CommandMock(command: 'cat /nonexistent/file/path'); + + /** @When the command is executed */ + $execution = $this->client->execute(command: $command); + + /** @Then the execution should indicate failure and carry the error output */ + self::assertFalse($execution->isSuccessful()); + self::assertStringContainsString('No such file or directory', $execution->getOutput()); + } + + public function testExecuteWithTimeoutWhenCommandEndsWithinTimeoutThenItSucceeds(): void + { + /** @Given a command bounded by a timeout it does not reach */ + $command = new CommandWithTimeoutMock(command: 'sleep 1', timeout: Duration::fromSeconds(seconds: 5)); + + /** @When the command is executed within its timeout */ + $execution = $this->client->executeWithTimeout(command: $command); + + /** @Then the execution should succeed */ + self::assertTrue($execution->isSuccessful()); + } + + public function testExecuteWithTimeoutWhenCommandOutlastsTimeoutThenDockerCommandExecutionFailed(): void + { + /** @Given a command that outlasts its timeout */ + $command = new CommandWithTimeoutMock(command: 'sleep 5', timeout: Duration::fromSeconds(seconds: 1)); + + /** @Then a failure naming the command and the exceeded timeout should be thrown */ + $this->expectException(DockerCommandExecutionFailed::class); + $this->expectExceptionMessageMatches("/<'sleep' '5'>.*Reason: .*exceeded the timeout of 1 seconds/"); + + /** @When the command is executed within its timeout */ + $this->client->executeWithTimeout(command: $command); + } +} diff --git a/tests/Unit/Internal/Client/DockerClientTest.php b/tests/Unit/Internal/Client/DockerClientTest.php deleted file mode 100644 index c3c7f3b..0000000 --- a/tests/Unit/Internal/Client/DockerClientTest.php +++ /dev/null @@ -1,72 +0,0 @@ -client = new DockerClient(); - } - - public function testExecuteCommandSuccessfully(): void - { - /** @Given a command that will succeed */ - $command = new CommandMock(command: 'echo Hello'); - - /** @When the command is executed */ - $execution = $this->client->execute(command: $command); - - /** @Then the output should contain the expected result */ - self::assertTrue($execution->isSuccessful()); - self::assertStringContainsString('Hello', $execution->getOutput()); - } - - public function testExecuteCommandWithValidTimeout(): void - { - /** @Given a command with a valid timeout */ - $command = new CommandWithTimeoutMock(command: 'echo Hello', timeoutInWholeSeconds: 10); - - /** @When the command is executed */ - $execution = $this->client->execute(command: $command); - - /** @Then the execution should succeed */ - self::assertTrue($execution->isSuccessful()); - } - - public function testExecuteCommandReturnsErrorOutput(): void - { - /** @Given a command that will fail */ - $command = new CommandMock(command: 'cat /nonexistent/file/path'); - - /** @When the command is executed */ - $execution = $this->client->execute(command: $command); - - /** @Then the execution should indicate failure */ - self::assertFalse($execution->isSuccessful()); - self::assertNotEmpty($execution->getOutput()); - } - - 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->expectExceptionMessageMatches('/Failed to execute command .* Reason: .*timeout/i'); - - /** @When the command is executed */ - $this->client->execute(command: $command); - } -} diff --git a/tests/Unit/MySQLDockerContainerTest.php b/tests/Unit/MySQLDockerContainerTest.php index 241540d..ef21e9b 100644 --- a/tests/Unit/MySQLDockerContainerTest.php +++ b/tests/Unit/MySQLDockerContainerTest.php @@ -4,13 +4,16 @@ namespace Test\Unit; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use Test\Models\InspectResponseFixture; -use TinyBlocks\DockerContainer\Internal\Exceptions\ContainerWaitTimeout; -use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; +use TinyBlocks\DockerContainer\Exceptions\ContainerWaitTimeout; +use TinyBlocks\DockerContainer\Exceptions\DockerCommandExecutionFailed; use TinyBlocks\DockerContainer\MySQL\MySQLContainerStarted; +use TinyBlocks\DockerContainer\MySQLDockerContainer; use TinyBlocks\DockerContainer\Waits\Conditions\ContainerReady; use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency; +use TinyBlocks\Time\Duration; final class MySQLDockerContainerTest extends TestCase { @@ -61,17 +64,14 @@ public function testGetJdbcUrlWithCustomOptions(): void public function testCustomReadinessTimeoutIsUsed(): void { /** @Given a MySQL container with a custom readiness timeout */ - $container = TestableMySQLDockerContainer::createWith( - name: 'timeout-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'timeout-db') ->withDatabase(database: 'test_db') ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 60); + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 60)); /** @And the Docker daemon starts the container */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'timeout-db', @@ -118,11 +118,8 @@ public function testGetJdbcUrlWithDefaultOptions(): void public function testRunMySQLContainerSuccessfully(): void { /** @Given a MySQL container with full configuration */ - $container = TestableMySQLDockerContainer::createWith( - name: 'test-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'test-db') ->withNetwork(name: 'my-net') ->withTimezone(timezone: 'America/Sao_Paulo') ->withUsername(user: 'app_user') @@ -135,7 +132,7 @@ public function testRunMySQLContainerSuccessfully(): void ->withVolumeMapping(pathOnHost: '/var/lib/mysql', pathOnContainer: '/var/lib/mysql'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'test-db', @@ -207,7 +204,7 @@ public function testRunMySQLContainerSuccessfully(): void self::assertStringContainsString('MYSQL_ROOT_PASSWORD=root', $runCommand); /** @And the readiness probe should execute env-prefixed mysqladmin ping via docker exec */ - $readinessCommand = $commandLines[4]; + $readinessCommand = $commandLines[5]; self::assertStringContainsString( 'docker exec test-db env MYSQL_PWD=root mysqladmin ping -h 127.0.0.1', @@ -215,7 +212,7 @@ public function testRunMySQLContainerSuccessfully(): void ); /** @And the database setup should include CREATE DATABASE, GRANT, and FLUSH */ - $setupCommand = $commandLines[5]; + $setupCommand = $commandLines[6]; self::assertStringContainsString('CREATE DATABASE IF NOT EXISTS test_adm', $setupCommand); self::assertStringContainsString('GRANT ALL PRIVILEGES', $setupCommand); @@ -226,52 +223,15 @@ public function testRunMySQLContainerSuccessfully(): void self::assertStringContainsString("'root'@'172.%'", $setupCommand); } - public function testRunMySQLContainerWithPullImage(): void - { - /** @Given a MySQL container with image pulling enabled */ - $container = TestableMySQLDockerContainer::createWith( - name: 'pull-db', - image: 'mysql:8.1', - client: $this->client - ) - ->withRootPassword(rootPassword: 'root') - ->pullImage(); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'pull-db', - environment: ['MYSQL_ROOT_PASSWORD=root'] - ) - ); - - /** @And the MySQL readiness check succeeds */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - - /** @When the container is started (waiting for the image pull to complete first) */ - $started = $container->run(); - - /** @Then the container should be running */ - self::assertSame('pull-db', $started->getName()); - - /** @And the docker pull command should have been executed */ - $commandLines = $this->client->getExecutedCommandLines(); - - self::assertStringContainsString('docker pull mysql:8.1', implode(PHP_EOL, $commandLines)); - } - public function testRunMySQLContainerWithoutDatabase(): void { /** @Given a MySQL container without a database configured */ - $container = TestableMySQLDockerContainer::createWith( - name: 'no-db', - image: 'mysql:8.1', - client: $this->client - )->withRootPassword(rootPassword: 'root'); + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'no-db') + ->withRootPassword(rootPassword: 'root'); /** @And the Docker daemon returns valid responses with no MYSQL_DATABASE */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'no-db', @@ -316,18 +276,15 @@ public function testRunMySQLContainerWithWaitBeforeRun(): void $condition->expects(self::once())->method('isReady')->willReturn(true); /** @And the container is configured */ - $container = TestableMySQLDockerContainer::createWith( - name: 'wait-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'wait-db') ->withRootPassword(rootPassword: 'root') ->withWaitBeforeRun( wait: ContainerWaitForDependency::untilReady(condition: $condition) ); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'wait-db', @@ -348,17 +305,14 @@ public function testRunMySQLContainerWithWaitBeforeRun(): void public function testExceptionWhenMySQLNeverBecomesReady(): void { /** @Given a MySQL container with a very short readiness timeout */ - $container = TestableMySQLDockerContainer::createWith( - name: 'stuck-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'stuck-db') ->withDatabase(database: 'test_db') ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 1); + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 1)); /** @And the Docker daemon starts the container */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'stuck-db', @@ -393,7 +347,7 @@ public function testMySQLContainerDelegatesStopCorrectly(): void $this->client->withDockerStopResponse(output: ''); /** @When the container is stopped */ - $stopped = $started->stop(); + $stopped = $started->stop(gracePeriod: Duration::fromSeconds(seconds: 10)); /** @Then the stop should be successful */ self::assertTrue($stopped->isSuccessful()); @@ -414,7 +368,7 @@ public function testRemoveDelegatesToUnderlyingContainer(): void /** @Then the docker rm command should have been executed */ $commandLines = $this->client->getExecutedCommandLines(); - $removeCommand = $commandLines[4]; + $removeCommand = $commandLines[5]; self::assertStringContainsString('docker rm --force --volumes', $removeCommand); } @@ -422,16 +376,13 @@ public function testRemoveDelegatesToUnderlyingContainer(): void public function testRunMySQLContainerWithCopyToContainer(): void { /** @Given a MySQL container with files to copy */ - $container = TestableMySQLDockerContainer::createWith( - name: 'copy-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'copy-db') ->withRootPassword(rootPassword: 'root') ->copyToContainer(pathOnHost: '/host/init', pathOnContainer: '/docker-entrypoint-initdb.d'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'copy-db', @@ -462,16 +413,13 @@ public function testRunMySQLContainerWithCopyToContainer(): void public function testRunMySQLContainerWithoutGrantedHosts(): void { /** @Given a MySQL container without granted hosts */ - $container = TestableMySQLDockerContainer::createWith( - name: 'no-grants', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'no-grants') ->withDatabase(database: 'test_db') ->withRootPassword(rootPassword: 'root'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'no-grants', @@ -491,7 +439,7 @@ public function testRunMySQLContainerWithoutGrantedHosts(): void /** @And the setup should include CREATE DATABASE but no GRANT statements */ $commandLines = $this->client->getExecutedCommandLines(); - $setupCommand = $commandLines[3]; + $setupCommand = $commandLines[4]; self::assertStringContainsString('CREATE DATABASE IF NOT EXISTS test_db', $setupCommand); self::assertStringNotContainsString('GRANT ALL PRIVILEGES', $setupCommand); @@ -500,11 +448,8 @@ public function testRunMySQLContainerWithoutGrantedHosts(): void public function testRunIfNotExistsCreatesNewMySQLContainer(): void { /** @Given a MySQL container that does not exist */ - $container = TestableMySQLDockerContainer::createWith( - name: 'new-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'new-db') ->withDatabase(database: 'new_db') ->withRootPassword(rootPassword: 'root'); @@ -512,7 +457,7 @@ public function testRunIfNotExistsCreatesNewMySQLContainer(): void $this->client->withDockerListResponse(output: ''); /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'new-db', @@ -534,17 +479,14 @@ public function testRunIfNotExistsCreatesNewMySQLContainer(): void public function testRunMySQLContainerWithSingleGrantedHost(): void { /** @Given a MySQL container with a single granted host */ - $container = TestableMySQLDockerContainer::createWith( - name: 'single-grant', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'single-grant') ->withDatabase(database: 'test_db') ->withRootPassword(rootPassword: 'root') ->withGrantedHosts(hosts: ['%']); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'single-grant', @@ -587,11 +529,8 @@ public function testMySQLContainerDelegatesExecuteAfterStarted(): void public function testRunIfNotExistsReturnsMySQLContainerStarted(): void { /** @Given a MySQL container */ - $container = TestableMySQLDockerContainer::createWith( - name: 'existing-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'existing-db') ->withDatabase(database: 'my_db') ->withRootPassword(rootPassword: 'root'); @@ -626,37 +565,37 @@ public function testMySQLContainerDelegatesStopWithCustomTimeout(): void $this->client->withDockerStopResponse(output: ''); /** @When the container is stopped with a custom timeout */ - $stopped = $started->stop(timeoutInWholeSeconds: 10); + $stopped = $started->stop(gracePeriod: Duration::fromSeconds(seconds: 10)); /** @Then the stop should be successful */ self::assertTrue($stopped->isSuccessful()); } + #[RunInSeparateProcess] public function testStopOnShutdownDelegatesToUnderlyingContainer(): void { - /** @Given a ShutdownHook that tracks registration */ - $shutdownHook = new ShutdownHookMock(); + $template = '%s/Internal/Containers/Overrides/register_shutdown_function_spy.php'; + require_once sprintf($template, __DIR__); - /** @And a running MySQL container using the tracked hook */ - $container = TestableMySQLDockerContainer::createWith( - name: 'shutdown-db', - image: 'mysql:8.1', - client: $this->client, - shutdownHook: $shutdownHook - ) + /** @Given a Docker client */ + $client = new ClientMock(); + + /** @And a MySQL container */ + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $client) + ->withName(name: 'shutdown-db') ->withDatabase(database: 'test_adm') ->withRootPassword(rootPassword: 'root'); - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( + /** @And the Docker daemon creates the container */ + $client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And the inspect response carries the MySQL settings */ + $client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'shutdown-db', - environment: ['MYSQL_DATABASE=test_adm', 'MYSQL_ROOT_PASSWORD=root'], - exposedPorts: ['3306/tcp' => (object)[]] + environment: ['MYSQL_DATABASE=test_adm', 'MYSQL_ROOT_PASSWORD=root'] ) ); - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - $this->client->withDockerExecuteResponse(output: ''); /** @And the container is started */ $started = $container->run(); @@ -664,8 +603,8 @@ public function testStopOnShutdownDelegatesToUnderlyingContainer(): void /** @When stopOnShutdown is called */ $started->stopOnShutdown(); - /** @Then the shutdown hook should have registered the remove callback */ - self::assertSame(1, $shutdownHook->getRegistrationCount()); + /** @Then exactly one shutdown callback should have been registered */ + self::assertCount(1, $GLOBALS['registeredShutdownCallbacks']); } public function testGetJdbcUrlDefaultsToPort3306WhenNoPortExposed(): void @@ -687,16 +626,13 @@ public function testGetJdbcUrlDefaultsToPort3306WhenNoPortExposed(): void public function testMySQLContainerWithEnvironmentVariableDirectly(): void { /** @Given a MySQL container with a custom environment variable */ - $container = TestableMySQLDockerContainer::createWith( - name: 'env-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'env-db') ->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(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'env-db', @@ -727,16 +663,13 @@ public function testMySQLContainerWithEnvironmentVariableDirectly(): void public function testRunMySQLContainerWithGrantedHostsButNoDatabase(): void { /** @Given a MySQL container with granted hosts but no database */ - $container = TestableMySQLDockerContainer::createWith( - name: 'grants-only', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'grants-only') ->withRootPassword(rootPassword: 'root') ->withGrantedHosts(hosts: ['%']); /** @And the Docker daemon returns valid responses without MYSQL_DATABASE */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'grants-only', @@ -756,7 +689,7 @@ public function testRunMySQLContainerWithGrantedHostsButNoDatabase(): void /** @And the setup should include GRANT and FLUSH but no CREATE DATABASE */ $commandLines = $this->client->getExecutedCommandLines(); - $setupCommand = $commandLines[3]; + $setupCommand = $commandLines[4]; self::assertStringNotContainsString('CREATE DATABASE', $setupCommand); self::assertStringContainsString('GRANT ALL PRIVILEGES', $setupCommand); @@ -780,53 +713,12 @@ public function testGetJdbcUrlUsesExposedPortWhenDifferentFromDefault(): void self::assertSame('jdbc:mysql://custom-port-db:3307/test_adm', $jdbcUrl); } - public function testRunWhenGateHoldsThenMySQLStartedIsPassedToCallback(): void - { - /** @Given a MySQL container */ - $container = TestableMySQLDockerContainer::createWith( - name: 'gate-db', - image: 'mysql:8.1', - client: $this->client - )->withRootPassword(rootPassword: 'root'); - - /** @And the Docker list is empty so a fresh container is started */ - $this->client->withDockerListResponse(output: ''); - - /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'gate-db', - environment: ['MYSQL_ROOT_PASSWORD=root'] - ) - ); - - /** @And a callback that captures the started container it receives */ - $received = null; - - /** @When runWhen is invoked with a gate that holds */ - $container->runWhen( - gate: static fn(): bool => true, - then: static function (MySQLContainerStarted $started) use (&$received): void { - $received = $started; - } - ); - - /** @Then the callback should have received a MySQL started container */ - self::assertInstanceOf(MySQLContainerStarted::class, $received); - - /** @And the received container should carry the expected name */ - self::assertSame('gate-db', $received->getName()); - } - public function testRunIfNotExistsWhenContainerExistsThenStartedWasReused(): void { /** @Given a MySQL container that already exists */ - $container = TestableMySQLDockerContainer::createWith( - name: 'reused-db', - image: 'mysql:8.1', - client: $this->client - )->withRootPassword(rootPassword: 'root'); + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'reused-db') + ->withRootPassword(rootPassword: 'root'); /** @And the Docker list returns the existing container ID */ $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); @@ -849,16 +741,13 @@ public function testRunIfNotExistsWhenContainerExistsThenStartedWasReused(): voi public function testExceptionWhenMySQLReadinessCheckAlwaysThrowsExceptions(): void { /** @Given a MySQL container with a very short readiness timeout */ - $container = TestableMySQLDockerContainer::createWith( - name: 'crash-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'crash-db') ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 1); + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 1)); /** @And the Docker daemon starts the container */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'crash-db', @@ -883,17 +772,14 @@ public function testExceptionWhenMySQLReadinessCheckAlwaysThrowsExceptions(): vo public function testRunMySQLContainerRetriesReadinessCheckBeforeSucceeding(): void { /** @Given a MySQL container */ - $container = TestableMySQLDockerContainer::createWith( - name: 'retry-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'retry-db') ->withDatabase(database: 'test_db') ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 10); + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 10)); /** @And the Docker daemon starts the container */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'retry-db', @@ -916,20 +802,45 @@ public function testRunMySQLContainerRetriesReadinessCheckBeforeSucceeding(): vo self::assertSame('retry-db', $started->getName()); } + public function testWithGrantedHostsThenLeavesTheOriginalDefinitionUntouched(): void + { + /** @Given a MySQL container without granted hosts */ + $container = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'plain-db') + ->withRootPassword(rootPassword: 'root'); + + /** @And a copy of it granting root access from any host */ + $container->withGrantedHosts(); + + /** @And the Docker daemon creates the container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And the inspect response carries the MySQL settings */ + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'plain-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @When the original definition runs */ + $container->run(); + + /** @Then no grant should have been issued */ + self::assertStringNotContainsString('GRANT', implode(PHP_EOL, $this->client->getExecutedCommandLines())); + } + public function testRunMySQLContainerRetriesWhenReadinessCheckThrowsException(): void { /** @Given a MySQL container */ - $container = TestableMySQLDockerContainer::createWith( - name: 'exception-db', - image: 'mysql:8.1', - client: $this->client - ) + $container = MySQLDockerContainer::from(image: 'mysql:8.1', client: $this->client) + ->withName(name: 'exception-db') ->withDatabase(database: 'test_db') ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 10); + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 10)); /** @And the Docker daemon starts the container */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: 'exception-db', @@ -952,4 +863,194 @@ public function testRunMySQLContainerRetriesWhenReadinessCheckThrowsException(): /** @Then the container should start after the exception was caught and retried */ self::assertSame('exception-db', $started->getName()); } + + public function testRunIfNotExistsWhenContainerIsNewThenWaitsUntilMySQLIsReady(): void + { + /** @Given a MySQL container that does not exist yet */ + $container = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'booting-db') + ->withDatabase(database: 'booting_db') + ->withRootPassword(rootPassword: 'root'); + + /** @And the Docker list returns empty */ + $this->client->withDockerListResponse(output: ''); + + /** @And the Docker daemon creates the container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And the inspect response carries the MySQL settings */ + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'booting-db', + environment: ['MYSQL_DATABASE=booting_db', 'MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And MySQL accepts connections only on the second ping */ + $this->client->withDockerExecuteResponse(output: 'mysqld is not ready', isSuccessful: false); + + /** @When runIfNotExists is called */ + $container->runIfNotExists(); + + /** @Then MySQL should have been pinged until ready, and only then the database set up */ + self::assertSame( + [ + 'docker exec booting-db env MYSQL_PWD=root mysqladmin ping -h 127.0.0.1', + 'docker exec booting-db env MYSQL_PWD=root mysqladmin ping -h 127.0.0.1', + implode(' ', [ + 'docker exec booting-db env MYSQL_PWD=root mysql -uroot -e', + 'CREATE DATABASE IF NOT EXISTS booting_db; FLUSH PRIVILEGES;' + ]) + ], + array_values( + array_filter( + $this->client->getExecutedCommandLines(), + static fn(string $commandLine): bool => str_starts_with($commandLine, 'docker exec') + ) + ) + ); + } + + public function testGetJdbcUrlWhenMySQLContainerIsSharedThenPointsToItsDatabase(): void + { + /** @Given a MySQL container reused from another process */ + $started = SharedMySQLContainer::reusedWith( + client: $this->client, + database: 'shared_adm', + hostname: 'shared-db' + ); + + /** @When getting the JDBC URL */ + $jdbcUrl = $started->getJdbcUrl(options: []); + + /** @Then the URL should point to the database of the shared container */ + self::assertSame('jdbc:mysql://shared-db:3306/shared_adm', $jdbcUrl); + } + + public function testExecuteAfterStartedWhenMySQLContainerIsSharedThenRunsInsideIt(): void + { + /** @Given a MySQL container reused from another process */ + $started = SharedMySQLContainer::reusedWith( + client: $this->client, + database: 'shared_adm', + hostname: 'shared-db' + ); + + /** @And the Docker daemon answers the next command */ + $this->client->withDockerExecuteResponse(output: 'shared_adm'); + + /** @When a command runs inside the shared container */ + $execution = $started->executeAfterStarted(commands: ['mysql', '-e', 'SHOW DATABASES;']); + + /** @Then the command output should be returned */ + self::assertSame('shared_adm', $execution->getOutput()); + } + + public function testRunIfNotExistsWhenContainerIsReusedThenWaitsUntilMySQLIsReady(): void + { + /** @Given a MySQL container another process has just started */ + $container = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'shared-db') + ->withRootPassword(rootPassword: 'root'); + + /** @And the Docker list returns the existing container ID */ + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + + /** @And the inspect response carries the MySQL settings */ + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'shared-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And MySQL accepts connections only on the second ping */ + $this->client->withDockerExecuteResponse(output: 'mysqld is not ready', isSuccessful: false); + + /** @When runIfNotExists is called */ + $container->runIfNotExists(); + + /** @Then MySQL should have been pinged until it accepted connections */ + self::assertSame( + 2, + count( + array_filter( + $this->client->getExecutedCommandLines(), + static fn(string $commandLine): bool => str_contains($commandLine, 'mysqladmin ping') + ) + ) + ); + } + + public function testStopWhenMySQLContainerIsSharedThenStopsTheUnderlyingContainer(): void + { + /** @Given a MySQL container reused from another process */ + $started = SharedMySQLContainer::reusedWith( + client: $this->client, + database: 'shared_adm', + hostname: 'shared-db' + ); + + /** @When the shared container is stopped */ + $started->stop(gracePeriod: Duration::fromSeconds(seconds: 5)); + + /** @Then a docker stop command should target the existing container */ + self::assertContains( + sprintf('docker stop --time 5 %s', InspectResponseFixture::shortContainerId()), + $this->client->getExecutedCommandLines() + ); + } + + public function testRunIfNotExistsWhenMySQLNeverBecomesReadyThenContainerWaitTimeout(): void + { + /** @Given a MySQL container with a readiness timeout of one second */ + $container = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'never-db') + ->withRootPassword(rootPassword: 'root') + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 1)); + + /** @And the Docker list returns empty */ + $this->client->withDockerListResponse(output: ''); + + /** @And the Docker daemon creates the container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And the inspect response carries the MySQL settings */ + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'never-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And MySQL never accepts connections */ + for ($index = 0; $index < 10; $index++) { + $this->client->withDockerExecuteResponse(output: 'mysqld is not ready', isSuccessful: false); + } + + /** @Then a timeout exception carrying the readiness timeout should be thrown */ + $this->expectException(ContainerWaitTimeout::class); + $this->expectExceptionMessage('Container readiness check timed out after <1> seconds.'); + + /** @When runIfNotExists is called */ + $container->runIfNotExists(); + } + + public function testGetAddressWhenMySQLContainerIsSharedThenIsTheAddressOfTheExistingContainer(): void + { + /** @Given a MySQL container reused from another process */ + $started = SharedMySQLContainer::reusedWith( + client: $this->client, + database: 'shared_adm', + hostname: 'shared-db' + ); + + /** @When reading its address */ + $address = $started->getAddress(); + + /** @Then the address, the ID and the environment should be those of the existing container */ + self::assertSame('shared-db', $address->getHostname()); + self::assertSame(InspectResponseFixture::shortContainerId(), $started->getId()); + self::assertSame('shared_adm', $started->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE')); + } } diff --git a/tests/Unit/RegisteredShutdownHookTest.php b/tests/Unit/RegisteredShutdownHookTest.php deleted file mode 100644 index b3da5eb..0000000 --- a/tests/Unit/RegisteredShutdownHookTest.php +++ /dev/null @@ -1,31 +0,0 @@ - true; - - /** @And a registered shutdown hook */ - $shutdownHook = new RegisteredShutdownHook(); - - /** @When the callback is registered */ - $shutdownHook->register(callback: $callback); - - /** @Then the callback is captured for shutdown exactly once */ - self::assertSame([$callback], $GLOBALS['registeredShutdownCallbacks']); - } -} diff --git a/tests/Unit/RunningMySQLContainer.php b/tests/Unit/RunningMySQLContainer.php index 6a84d05..045a7e4 100644 --- a/tests/Unit/RunningMySQLContainer.php +++ b/tests/Unit/RunningMySQLContainer.php @@ -5,7 +5,9 @@ namespace Test\Unit; use Test\Models\InspectResponseFixture; +use TinyBlocks\DockerContainer\DisposableContainer; use TinyBlocks\DockerContainer\MySQL\MySQLContainerStarted; +use TinyBlocks\DockerContainer\MySQLDockerContainer; final class RunningMySQLContainer { @@ -18,12 +20,9 @@ public static function startWith( string $database, string $hostname, ?int $port = null - ): MySQLContainerStarted { - $container = TestableMySQLDockerContainer::createWith( - name: $hostname, - image: 'mysql:8.4', - client: $client - ) + ): MySQLContainerStarted&DisposableContainer { + $container = MySQLDockerContainer::from(image: 'mysql:8.4', client: $client) + ->withName(name: $hostname) ->withDatabase(database: $database) ->withRootPassword(rootPassword: 'root'); @@ -32,7 +31,7 @@ public static function startWith( $databaseTemplate = 'MYSQL_DATABASE=%s'; - $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); $client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( hostname: $hostname, diff --git a/tests/Unit/SharedMySQLContainer.php b/tests/Unit/SharedMySQLContainer.php new file mode 100644 index 0000000..5416d41 --- /dev/null +++ b/tests/Unit/SharedMySQLContainer.php @@ -0,0 +1,47 @@ +withName(name: $hostname) + ->withDatabase(database: $database) + ->withRootPassword(rootPassword: 'root'); + + $databaseTemplate = 'MYSQL_DATABASE=%s'; + + $client->withDockerListResponse(output: InspectResponseFixture::containerId()); + $client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: $hostname, + environment: [ + sprintf($databaseTemplate, $database), + 'MYSQL_ROOT_PASSWORD=root' + ], + exposedPorts: ['3306/tcp' => (object)[]] + ) + ); + + $client->withDockerExecuteResponse(output: 'mysqld is alive'); + $client->withDockerExecuteResponse(output: ''); + + return $container->runIfNotExists(); + } +} diff --git a/tests/Unit/ShutdownHookMock.php b/tests/Unit/ShutdownHookMock.php deleted file mode 100644 index 8008c57..0000000 --- a/tests/Unit/ShutdownHookMock.php +++ /dev/null @@ -1,22 +0,0 @@ -registrations++; - } - - public function getRegistrationCount(): int - { - return $this->registrations; - } -} diff --git a/tests/Unit/TestableFlywayDockerContainer.php b/tests/Unit/TestableFlywayDockerContainer.php deleted file mode 100644 index 00ae74d..0000000 --- a/tests/Unit/TestableFlywayDockerContainer.php +++ /dev/null @@ -1,18 +0,0 @@ -createStub(ContainerReady::class); - $condition->method('isReady')->willReturn(false); + /** @Given a condition that is immediately ready */ + $condition = $this->createMock(ContainerReady::class); + $condition->expects(self::once())->method('isReady')->willReturn(true); - /** @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 zero timeout */ + ContainerWaitForDependency::untilReady(condition: $condition) + ->withTimeout(timeout: Duration::zero()) + ->waitBefore(); - /** @When waiting with a short timeout */ - $wait = ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: 1, - pollIntervalInMicroseconds: 50_000 - ); - $wait->waitBefore(); + /** @Then the condition should still have been checked once */ + self::assertTrue(true); } - public function testWaitBeforeRetriesUntilReady(): void + public function testWaitBeforeWhenConditionHoldsLaterThenChecksUntilItHolds(): void { - /** @Given a condition that becomes ready after two retries */ + /** @Given a condition that becomes ready on the third check */ $condition = $this->createMock(ContainerReady::class); $condition->expects(self::exactly(3)) ->method('isReady') ->willReturnOnConsecutiveCalls(false, false, true); - /** @When waiting for the dependency with a generous timeout */ - $wait = ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: 10, - pollIntervalInMicroseconds: 1_000 - ); - $wait->waitBefore(); + /** @When waiting for the dependency within the default timeout */ + ContainerWaitForDependency::untilReady(condition: $condition)->waitBefore(); /** @Then the condition should have been checked three times */ self::assertTrue(true); } - public function testCustomPollIntervalIsRespected(): void + public function testWaitBeforeWhenConditionIsImmediatelyReadyThenChecksOnce(): void { - /** @Given a condition that becomes ready after some retries */ - $callCount = 0; - $condition = $this->createStub(ContainerReady::class); - $condition->method('isReady')->willReturnCallback(function () use (&$callCount): bool { - $callCount++; - return $callCount >= 3; - }); + /** @Given a condition that is immediately ready */ + $condition = $this->createMock(ContainerReady::class); + $condition->expects(self::once())->method('isReady')->willReturn(true); - /** @When waiting with a very fast poll interval */ - $start = microtime(true); - $wait = ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: 5, - pollIntervalInMicroseconds: 10_000 - ); - $wait->waitBefore(); - $elapsed = (microtime(true) - $start); + /** @When waiting for the dependency */ + ContainerWaitForDependency::untilReady(condition: $condition)->waitBefore(); - /** @Then the wait should complete quickly (well under 1 second) */ - self::assertLessThan(1.0, $elapsed); - self::assertSame(3, $callCount); + /** @Then the condition should have been checked exactly once */ + self::assertTrue(true); } - public function testWaitBeforeSleepsBetweenReadinessChecks(): void + public function testWaitBeforeWhenConditionHoldsLaterThenSleepsBetweenChecks(): void { - /** @Given a condition that only becomes ready after the configured poll interval elapses */ + /** @Given the moment the wait starts */ $start = microtime(true); + + /** @And a condition that holds only after the first poll interval has elapsed */ $condition = $this->createStub(ContainerReady::class); - $condition->method('isReady')->willReturnCallback(static function () use ($start): bool { - return (microtime(true) - $start) >= 0.2; - }); - - /** @When waiting with a timeout that would expire instantly if sleeps were skipped */ - ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: 2, - pollIntervalInMicroseconds: 100_000 - )->waitBefore(); - - /** @Then the wait should have taken at least the poll interval to observe readiness */ - self::assertGreaterThanOrEqual(0.2, (microtime(true) - $start)); + $condition->method('isReady')->willReturnCallback( + static fn(): bool => (microtime(true) - $start) >= 0.2 + ); + + /** @When waiting for the dependency */ + ContainerWaitForDependency::untilReady(condition: $condition)->waitBefore(); + + /** @Then the wait should have slept a full poll interval before observing readiness */ + self::assertGreaterThanOrEqual(0.25, (microtime(true) - $start)); } - public function testWaitBeforeWhenConditionIsImmediatelyReady(): void + public function testWaitBeforeWhenConditionNeverHoldsThenContainerWaitTimeout(): void { - /** @Given a condition that is immediately ready */ - $condition = $this->createMock(ContainerReady::class); - $condition->expects(self::once())->method('isReady')->willReturn(true); + /** @Given a condition that never becomes ready */ + $condition = $this->createStub(ContainerReady::class); + $condition->method('isReady')->willReturn(false); + + /** @And a wait bounded by one second */ + $wait = ContainerWaitForDependency::untilReady(condition: $condition) + ->withTimeout(timeout: Duration::fromSeconds(seconds: 1)); + + /** @Then a timeout exception carrying the timeout should be thrown */ + $this->expectException(ContainerWaitTimeout::class); + $this->expectExceptionMessage('Container readiness check timed out after <1> seconds.'); /** @When waiting for the dependency */ - $wait = ContainerWaitForDependency::untilReady(condition: $condition); $wait->waitBefore(); - - /** @Then the condition should have been checked exactly once */ - self::assertTrue(true); } - public function testWaitBeforeGuaranteesAtLeastOneReadinessCheckEvenWhenTimeoutIsZero(): void + public function testWaitBeforeWhenConditionNeverHoldsThenChecksEveryQuarterSecond(): void { - /** @Given a condition that is immediately ready */ - $condition = $this->createStub(ContainerReady::class); - $condition->method('isReady')->willReturn(true); + /** @Given a condition that never becomes ready and counts its checks */ + $condition = $this->createMock(ContainerReady::class); + $condition->expects(self::exactly(4))->method('isReady')->willReturn(false); - /** @When waiting with a zero-second timeout against a ready condition */ - ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: 0, - pollIntervalInMicroseconds: 1 - )->waitBefore(); + /** @And a wait bounded by one second */ + $wait = ContainerWaitForDependency::untilReady(condition: $condition) + ->withTimeout(timeout: Duration::fromSeconds(seconds: 1)); - /** @Then the wait should complete without throwing */ - self::assertTrue(true); + /** @Then a timeout exception should be thrown once the four checks of the second are spent */ + $this->expectException(ContainerWaitTimeout::class); + + /** @When waiting for the dependency */ + $wait->waitBefore(); } - public function testWaitBeforeThrowsWhenSingleAttemptFailsAndConditionIsReadyOnSecondCheck(): void + public function testWaitBeforeWhenTimeoutIsZeroAndConditionFailsThenContainerWaitTimeout(): void { /** @Given a condition that becomes ready only on the second check */ - $callCount = 0; $condition = $this->createStub(ContainerReady::class); - $condition->method('isReady')->willReturnCallback(function () use (&$callCount): bool { - $callCount++; - return $callCount >= 2; - }); + $condition->method('isReady')->willReturnOnConsecutiveCalls(false, true); - /** @Then a ContainerWaitTimeout should be thrown because a single attempt is exhausted */ + /** @And a wait with a zero timeout, which allows a single check */ + $wait = ContainerWaitForDependency::untilReady(condition: $condition)->withTimeout(timeout: Duration::zero()); + + /** @Then a timeout exception should be thrown */ $this->expectException(ContainerWaitTimeout::class); - /** @When waiting with a budget that allows exactly one readiness check */ - ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: 0, - pollIntervalInMicroseconds: 1 - )->waitBefore(); + /** @When waiting for the dependency */ + $wait->waitBefore(); } } diff --git a/tests/Unit/Waits/ContainerWaitForTimeTest.php b/tests/Unit/Waits/ContainerWaitForTimeTest.php index 8f925fa..2ece2f0 100644 --- a/tests/Unit/Waits/ContainerWaitForTimeTest.php +++ b/tests/Unit/Waits/ContainerWaitForTimeTest.php @@ -7,51 +7,52 @@ use PHPUnit\Framework\TestCase; use TinyBlocks\DockerContainer\ContainerStarted; use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime; +use TinyBlocks\Time\Duration; final class ContainerWaitForTimeTest extends TestCase { public function testWaitAfterPausesForSpecifiedDuration(): void { /** @Given a wait-for-time of 1 second */ - $wait = ContainerWaitForTime::forSeconds(seconds: 1); + $wait = ContainerWaitForTime::forDuration(duration: Duration::fromSeconds(seconds: 1)); - /** @And a container started stub */ - $containerStarted = $this->createStub(ContainerStarted::class); - - /** @When waiting after */ + /** @And the moment the wait starts */ $start = microtime(true); - $wait->waitAfter(containerStarted: $containerStarted); - $elapsed = (microtime(true) - $start); + + /** @When waiting after a container started */ + $wait->waitAfter(containerStarted: $this->createStub(ContainerStarted::class)); /** @Then at least 0.9 seconds should have elapsed */ - self::assertGreaterThanOrEqual(0.9, $elapsed); + self::assertGreaterThanOrEqual(0.9, (microtime(true) - $start)); } public function testWaitBeforePausesForSpecifiedDuration(): void { /** @Given a wait-for-time of 1 second */ - $wait = ContainerWaitForTime::forSeconds(seconds: 1); + $wait = ContainerWaitForTime::forDuration(duration: Duration::fromSeconds(seconds: 1)); - /** @When waiting before */ + /** @And the moment the wait starts */ $start = microtime(true); + + /** @When waiting before a container runs */ $wait->waitBefore(); - $elapsed = (microtime(true) - $start); /** @Then at least 0.9 seconds should have elapsed */ - self::assertGreaterThanOrEqual(0.9, $elapsed); + self::assertGreaterThanOrEqual(0.9, (microtime(true) - $start)); } public function testWaitForZeroSecondsReturnsImmediately(): void { - /** @Given a wait-for-time of 0 seconds */ - $wait = ContainerWaitForTime::forSeconds(seconds: 0); + /** @Given a wait-for-time of zero */ + $wait = ContainerWaitForTime::forDuration(duration: Duration::zero()); - /** @When waiting before */ + /** @And the moment the wait starts */ $start = microtime(true); + + /** @When waiting before a container runs */ $wait->waitBefore(); - $elapsed = (microtime(true) - $start); - /** @Then the wait should complete almost instantly */ - self::assertLessThan(0.1, $elapsed); + /** @Then the wait should return at once */ + self::assertLessThan(0.1, (microtime(true) - $start)); } } From d92ca5a8c54fc2bd9399d1223fa87631bb353164 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Wed, 14 Oct 2026 22:07:44 -0300 Subject: [PATCH 2/2] docs: Document the 4.0 API and the migration from 3.x. --- README.md | 472 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 314 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 7baadb8..0f96376 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,8 @@ + [Creating a container](#creating-a-container) + [Running a container](#running-a-container) + [Running if not exists](#running-if-not-exists) - + [Running conditionally](#running-conditionally) + + [Running to completion](#running-to-completion) + [Checking if the container was reused](#checking-if-the-container-was-reused) - + [Pulling images in parallel](#pulling-images-in-parallel) + [Setting network](#setting-network) + [Setting port mappings](#setting-port-mappings) + [Setting volume mappings](#setting-volume-mappings) @@ -33,8 +32,13 @@ - [Configuring migrations](#configuring-migrations) - [Configuring Flyway options](#configuring-flyway-options) - [Running Flyway commands](#running-flyway-commands) + + [Handling failures](#handling-failures) + + [Using a custom Docker client](#using-a-custom-docker-client) + [Usage examples](#usage-examples) - [MySQL with Flyway migrations](#mysql-with-flyway-migrations) + - [Integration test bootstrap shared by every process of a run](#integration-test-bootstrap-shared-by-every-process-of-a-run) + + [Upgrading from 3.x](#upgrading-from-3x) +* [FAQ](#faq) * [License](#license) * [Contributing](#contributing) @@ -42,7 +46,11 @@ Manages Docker containers programmatically for PHP, enabling integration tests and disposable infrastructure to be orchestrated directly from code. Supports container definitions, port and volume mappings, environment variables, -readiness probes, shutdown hooks, and orphan reaping. +readiness probes, one-shot containers run to completion, shutdown hooks, and orphan reaping. + +Container definitions are immutable: every `with*` method returns a new definition and leaves the receiver +untouched, so a base definition can be shared and specialized safely. Durations are expressed with `Duration` from +[tiny-blocks/time](https://github.com/tiny-blocks/time). ## Installation @@ -54,7 +62,8 @@ composer require tiny-blocks/docker-container ### Creating a container -Creates a container from a specified image and an optional name. +Creates a container definition from an image. The container gets a unique generated name until `withName()` replaces +it. ```php withName(name: 'my-container'); +``` + +Since definitions are immutable, keep the value each `with*` method returns: + +```php +$container = $container->withEnvironmentVariable(key: 'APP_ENV', value: 'testing'); ``` ### Running a container -Starts a container. Optionally accepts commands to run on startup and a wait strategy applied after the container -starts. +Creates and starts a container, optionally with the command to run on startup. The returned `DisposableContainer` is +owned by this process, so it can be removed once the work is done (see [Stopping on shutdown](#stopping-on-shutdown)). ```php $container->run(); @@ -81,58 +96,49 @@ With commands: $container->run(commands: ['ls', '-la']); ``` -With commands and a wait strategy: - -```php -run(commands: ['ls', '-la'], waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 5)); -``` +To wait after the container starts, register a wait strategy on the definition (see +[Wait strategies](#wait-strategies)). ### Running if not exists -Starts a container only if a container with the same name is not already running. +Starts a container only if a container with the same name does not exist yet, and reuses it otherwise. The returned +`SharedContainer` is shared by every PHP process that asks for the same name (as in mutation testing), so it offers +neither `stopOnShutdown()` nor `remove()`: no single process owns it. When the code runs inside Docker, a reaper +container removes the shared container once the test runner container exits. The wait strategies registered with +`withWaitAfterStarted()` apply only when a new container starts. ```php $container->runIfNotExists(); ``` -### Running conditionally +### Running to completion -Runs the container only when a predicate holds, reusing an existing one if present, then hands the started container -to a callback. Useful to gate side effects such as running migrations behind a flag, without scattering `if` -statements through the bootstrap. When the predicate does not hold, nothing runs. +Runs a one-shot container until it exits and returns what it wrote to its standard output. The container is created, +receives the files registered with `copyToContainer()`, starts attached, and is removed once it exits, successfully or +not, or once the timeout elapses. ```php runWhen( - gate: EnvironmentFlag::enabled(name: 'RUN_MIGRATIONS'), - then: static function (ContainerStarted $started): void { - # Runs only when RUN_MIGRATIONS=1, with $started ready to use. - } -); -``` +use TinyBlocks\DockerContainer\GenericDockerContainer; +use TinyBlocks\Time\Duration; -The gate is any predicate, so a custom condition works as well: +$execution = GenericDockerContainer::from(image: 'alpine:3.22') + ->runToCompletion(timeout: Duration::fromSeconds(seconds: 60), commands: ['sh', '-c', 'echo done']); -```php -$container->runWhen(gate: static fn(): bool => $featureToggle->isOn(), then: $migrate); +$execution->getOutput(); # "done\n" ``` +A non-zero exit raises `ContainerExitUnsuccessful` carrying what the container wrote to its standard error. A +container still running when the timeout elapses raises `ContainerWaitTimeout`. + ### Checking if the container was reused -Tells whether the started container came from an already-running instance. This distinguishes a fresh start from a -reuse, so first-boot work such as seeding or migrations runs only once. +Tells whether the shared container came from an already-running instance. This distinguishes a fresh start from a +reuse, so first-boot work such as seeding runs only once. Only the `SharedContainer` returned by `runIfNotExists()` +answers it, since a container returned by `run()` is always new. ```php $started = $container->runIfNotExists(); @@ -140,55 +146,23 @@ $started = $container->runIfNotExists(); $alreadyRunning = $started->wasReused(); ``` -### Pulling images in parallel - -Calling `pullImage()` starts downloading the image in the background via a non-blocking process. When `run()` or -`runIfNotExists()` is called, it waits for the pull to complete before starting the container. - -To pull multiple images in parallel, call `pullImage()` on all containers **before** calling `run()` on any of -them. This way the downloads happen concurrently: - -```php -pullImage() - ->withRootPassword(rootPassword: 'root'); - -$flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine') - ->pullImage() - ->withMigrations(pathOnHost: '/path/to/migrations'); - -# Both images are downloading in the background. -# MySQL pull completes here, container starts and becomes ready. -$mySQLStarted = $mysql->runIfNotExists(); - -# Flyway pull already finished while MySQL was starting. -$flyway->withSource(password: 'root', username: 'root', container: $mySQLStarted) - ->cleanAndMigrate(); -``` - ### Setting network -Sets the Docker network the container should join. The network is created automatically when the container is -started via `run()` or `runIfNotExists()`, if it does not already exist. Networks created by the library are -labeled with `tiny-blocks.docker-container=true` for safe cleanup. +Sets the Docker network the container should join. The network is created automatically when the container runs, if +it does not already exist. Networks created by the library are labeled with `tiny-blocks.docker-container=true` for +safe cleanup. ```php -$container->withNetwork(name: 'my-network'); +$container = $container->withNetwork(name: 'my-network'); ``` ### Setting port mappings -Maps a port from the host to the container. +Maps a port from the host to the container. A host port of `0` lets Docker pick a free one, which avoids collisions +between parallel runs. ```php -$container->withPortMapping(portOnHost: 8080, portOnContainer: 80); +$container = $container->withPortMapping(portOnHost: 8080, portOnContainer: 80); ``` After the container starts, both ports are available through the `Address`: @@ -205,7 +179,7 @@ $ports->firstHostPort(); # 8080 (host-accessible) Mounts a directory from the host into the container. ```php -$container->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/container/data'); +$container = $container->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/container/data'); ``` ### Setting environment variables @@ -213,7 +187,7 @@ $container->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/conta Adds an environment variable to the container. ```php -$container->withEnvironmentVariable(key: 'APP_ENV', value: 'testing'); +$container = $container->withEnvironmentVariable(key: 'APP_ENV', value: 'testing'); ``` ### Disabling auto-remove @@ -221,37 +195,37 @@ $container->withEnvironmentVariable(key: 'APP_ENV', value: 'testing'); By default, containers are removed when stopped. This disables that behavior. ```php -$container->withoutAutoRemove(); +$container = $container->withoutAutoRemove(); ``` ### Copying files to a container -Registers files or directories to be copied from the host into the container after it starts. +Registers files or directories to be copied from the host into the container. The files are copied after the +container is created and before it starts, so its command already sees them. ```php -$container->copyToContainer(pathOnHost: '/path/to/files', pathOnContainer: '/path/in/container'); +$container = $container->copyToContainer(pathOnHost: '/path/to/files', pathOnContainer: '/path/in/container'); ``` ### Stopping a container -Stops a running container. An optional timeout (in seconds) controls how long to wait before forcing the stop. -The default timeout is 10 seconds. +Stops a running container. Docker forces the stop once the grace period elapses. ```php -$started = $container->run(); -$result = $started->stop(); -``` +stop(timeoutInWholeSeconds: 60); +use TinyBlocks\Time\Duration; + +$started = $container->run(); +$result = $started->stop(gracePeriod: Duration::fromSeconds(seconds: 10)); ``` ### Stopping on shutdown -Registers the container to be forcefully removed when the PHP process exits. On shutdown, the following cleanup -is performed automatically: +Registers a container started by `run()` to be forcefully removed when the PHP process exits. On shutdown, the +following cleanup is performed automatically: - The container is killed and removed (`docker rm --force --volumes`). - Anonymous volumes created by the container (e.g., MySQL's `/var/lib/mysql`) are removed. @@ -283,9 +257,13 @@ $result->isSuccessful(); ### Wait strategies +Wait strategies are part of the definition. `withWaitBeforeRun()` adds a `ContainerWaitBeforeStarted`, applied before +the container runs, and `withWaitAfterStarted()` adds a `ContainerWaitAfterStarted`, applied after `run()` starts the +container. Each method adds to the strategies already registered, and they apply in the order they were added. + #### Waiting for a fixed time -Pauses execution for a specified number of seconds before or after starting a container. +Pauses for a fixed duration before a container runs or after it starts. ```php withWaitBeforeRun(wait: ContainerWaitForTime::forSeconds(seconds: 3)); +$pause = ContainerWaitForTime::forDuration(duration: Duration::fromSeconds(seconds: 3)); + +$container = $container + ->withWaitBeforeRun(wait: $pause) + ->withWaitAfterStarted(wait: $pause); ``` #### Waiting for a dependency -Blocks until a readiness condition is satisfied, with a configurable timeout. This is useful when one container -depends on another being fully ready. +Polls a readiness condition every 250 milliseconds until it holds, and raises `ContainerWaitTimeout` once the timeout +(30 seconds by default) elapses. This is useful when one container depends on another being fully ready. ```php withRootPassword(rootPassword: 'root') @@ -318,18 +302,18 @@ $mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4') $container = GenericDockerContainer::from(image: 'my-app:latest') ->withWaitBeforeRun( - wait: ContainerWaitForDependency::untilReady( - condition: MySQLReady::from(container: $mySQLStarted), - timeoutInSeconds: 30 - ) + wait: ContainerWaitForDependency::untilReady(condition: MySQLReady::from(container: $mySQLStarted)) + ->withTimeout(timeout: Duration::fromSeconds(seconds: 60)) ) ->run(); ``` ### MySQL container -`MySQLDockerContainer` provides a specialized container for MySQL databases. It extends the generic container with -MySQL-specific configuration and automatic readiness detection. +`MySQLDockerContainer` provides a specialized container for MySQL databases. Both `run()` and `runIfNotExists()` +hand the container over only once MySQL accepts connections, and create the database and the root grants when they +are configured. Both return a `MySQLContainerStarted`: the one from `run()` is also a `DisposableContainer`, and the +one from `runIfNotExists()` is also a `SharedContainer`. #### Configuring MySQL options @@ -349,12 +333,13 @@ declare(strict_types=1); use TinyBlocks\DockerContainer\MySQLDockerContainer; -$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database') +$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4') + ->withName(name: 'my-database') ->withTimezone(timezone: 'America/Sao_Paulo') ->withUsername(user: 'app_user') ->withPassword(password: 'secret') ->withDatabase(database: 'my_database') - ->withPortMapping(portOnHost: 3306, portOnContainer: 3306) + ->withPortMapping(portOnHost: 0, portOnContainer: 3306) ->withRootPassword(rootPassword: 'root') ->withGrantedHosts() ->run(); @@ -362,7 +347,7 @@ $mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-datab #### Setting readiness timeout -Configures how long the MySQL container waits for the database to become ready before throwing a +Configures how long `run()` and `runIfNotExists()` wait for MySQL to accept connections before throwing a `ContainerWaitTimeout` exception. The default timeout is 30 seconds. ```php @@ -371,10 +356,12 @@ Configures how long the MySQL container waits for the database to become ready b declare(strict_types=1); use TinyBlocks\DockerContainer\MySQLDockerContainer; +use TinyBlocks\Time\Duration; -$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database') +$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4') + ->withName(name: 'my-database') ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 60) + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 60)) ->run(); ``` @@ -387,7 +374,7 @@ After the MySQL container starts, connection details are available through the ` declare(strict_types=1); -$address = $mySQLContainer->getAddress(); +$address = $mySQLStarted->getAddress(); $ip = $address->getIp(); $hostname = $address->getHostname(); @@ -395,12 +382,12 @@ $ports = $address->getPorts(); $containerPort = $ports->firstExposedPort(); # e.g. 3306 (container-internal) $hostPort = $ports->firstHostPort(); # e.g. 49153 (host-accessible) -$environmentVariables = $mySQLContainer->getEnvironmentVariables(); +$environmentVariables = $mySQLStarted->getEnvironmentVariables(); $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); $username = $environmentVariables->getValueBy(key: 'MYSQL_USER'); $password = $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD'); -$jdbcUrl = $mySQLContainer->getJdbcUrl(); +$jdbcUrl = $mySQLStarted->getJdbcUrl(); ``` Use `firstExposedPort()` when connecting from another container in the same network. @@ -419,12 +406,13 @@ declare(strict_types=1); use TinyBlocks\DockerContainer\MySQLDockerContainer; -$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database') - ->withRootPassword(rootPassword: 'root') +$started = MySQLDockerContainer::from(image: 'mysql:8.4') + ->withName(name: 'my-database') ->withDatabase(database: 'my_database') - ->withPortMapping(portOnHost: 3306, portOnContainer: 3306); + ->withPortMapping(portOnHost: 0, portOnContainer: 3306) + ->withRootPassword(rootPassword: 'root') + ->runIfNotExists(); -$started = $mySQLContainer->runIfNotExists(); $address = $started->getAddress(); $host = $address->getHostForConnection(); # hostname inside Docker, 127.0.0.1 on host @@ -432,18 +420,18 @@ $port = $address->getPorts()->getPortForConnection(); # container port inside Do ``` This is useful when the same test suite runs both locally (inside a Docker Compose stack) and in CI (on the host). -Instead of manually checking the environment and switching between `getHostname()`/`getIp()` or `firstExposedPort()`/ -`firstHostPort()`, the environment-aware methods handle it transparently. ### Flyway container -`FlywayDockerContainer` provides a specialized container for running Flyway database migrations. It encapsulates -Flyway configuration, database source detection, and migration file management. +`FlywayDockerContainer` runs Flyway database migrations. Each command runs Flyway once in a fresh container with a +unique generated name, blocks until Flyway exits, and removes the container afterward, so parallel test runs never +collide on it. #### Setting the database source -Configures the Flyway container to connect to a running MySQL container. Automatically detects the JDBC URL and -target schema from `MYSQL_DATABASE`, and sets the history table to `schema_history`. +Binds Flyway to a started MySQL container. Configures the JDBC URL, the credentials, the target schema (from +`MYSQL_DATABASE`), and the history table (`schema_history`), and waits until MySQL accepts connections before each +command. ```php withSource(password: 'root', username: 'root', container: $mySQLStarted) ->withNetwork(name: 'my-network') - ->withMigrations(pathOnHost: '/path/to/migrations') - ->withSource(password: 'root', username: 'root', container: $mySQLStarted); + ->withMigrations(pathOnHost: '/path/to/migrations'); ``` The schema and table can be overridden after calling `withSource()`: @@ -465,7 +453,7 @@ The schema and table can be overridden after calling `withSource()`: declare(strict_types=1); -$flywayContainer +$flyway = $flyway ->withSource(password: 'root', username: 'root', container: $mySQLStarted) ->withSchema(schema: 'custom_schema') ->withTable(table: 'custom_history'); @@ -474,34 +462,102 @@ $flywayContainer #### Configuring migrations Sets the host directory containing Flyway migration SQL files. The files are copied into the container at -`/flyway/migrations`. +`/flyway/migrations` before Flyway starts. ```php -$flywayContainer->withMigrations(pathOnHost: '/path/to/migrations'); +$flyway = $flyway->withMigrations(pathOnHost: '/path/to/migrations'); ``` #### Configuring Flyway options -| Method | Parameter | Description | -|-------------------------------|-------------|------------------------------------------------------------------| -| `withTable` | `$table` | Overrides the history table name (default: `schema_history`). | -| `withSchema` | `$schema` | Overrides the target schema (default: auto-detected from MySQL). | -| `withCleanDisabled` | `$disabled` | Enables or disables Flyway's clean command. | -| `withConnectRetries` | `$retries` | Sets the number of database connection retries. | -| `withValidateMigrationNaming` | `$enabled` | Enables or disables migration naming validation. | +| Method | Parameter | Description | +|---------------------------------|------------------|------------------------------------------------------------------| +| `withTable` | `$table` | Overrides the history table name (default: `schema_history`). | +| `withSchema` | `$schema` | Overrides the target schema (default: detected from MySQL). | +| `withTimeout` | `$timeout` | Sets how long each command waits for Flyway to exit (120 s). | +| `withConnectRetries` | `$retries` | Sets the number of database connection retries. | +| `withMigrationNamingValidation` | | Rejects migrations whose file names break the naming convention. | +| `withEnvironmentVariable` | `$key`, `$value` | Sets any other Flyway setting through its `FLYWAY_*` variable. | #### Running Flyway commands -| Method | Flyway command | Description | -|---------------------|-----------------|----------------------------------------------| -| `migrate()` | `migrate` | Applies pending migrations. | -| `validate()` | `validate` | Validates applied migrations against local. | -| `repair()` | `repair` | Repairs the schema history table. | -| `cleanAndMigrate()` | `clean migrate` | Drops all objects and re-applies migrations. | +| Method | Flyway command | Description | +|---------------------|-----------------|-----------------------------------------------------------------| +| `migrate()` | `migrate` | Applies pending migrations. | +| `validate()` | `validate` | Validates applied migrations against local. | +| `repair()` | `repair` | Repairs the schema history table. | +| `cleanAndMigrate()` | `clean migrate` | Enables clean for the run, drops all objects, then re-migrates. | + +```php +$flyway->migrate(); +$flyway->cleanAndMigrate(); +``` + +Each command blocks until Flyway exits, so its work is done when the method returns, and returns the Flyway output +as an `ExecutionCompleted`. It waits up to 120 seconds by default: + +```php +withTimeout(timeout: Duration::fromSeconds(seconds: 300))->cleanAndMigrate(); +``` + +### Handling failures + +The exceptions consumers may catch live in `TinyBlocks\DockerContainer\Exceptions`. + +| Exception | Raised when | +|--------------------------------|--------------------------------------------------------------------------------------| +| `DockerCommandExecutionFailed` | A Docker command cannot run, exceeds its timeout, or reports a failure. | +| `ContainerWaitTimeout` | A readiness condition does not hold, or a one-shot container does not exit, in time. | +| `ContainerExitUnsuccessful` | A one-shot container, such as Flyway, exits with a non-zero status. | +| `ImageNameEmpty` | A container is created from an empty image name. | + +`ContainerExitUnsuccessful` carries what the container wrote to its standard error, so a failed migration reports the +Flyway error itself: + +```text +Container <2xG1...> did not exit successfully. Output: . +``` + +### Using a custom Docker client + +Every factory accepts a `DockerClient`, the single boundary through which the library executes Docker commands. It +defaults to the local `docker` executable. A custom client can route the commands to another Docker-compatible engine, +or stand in for the engine in the unit tests of code that builds containers. A `DockerCommand` carries only its +arguments and runs through `execute()`, within the default timeout of the client. A `DockerCommandWithTimeout`, such as +a container run to completion or a stop, also carries the timeout it must finish within and runs through +`executeWithTimeout()`: ```php -$flywayContainer->migrate(); -$flywayContainer->cleanAndMigrate(); +toArguments(), then answer with an ExecutionCompleted. + } + + public function executeWithTimeout(DockerCommandWithTimeout $command): ExecutionCompleted + { + # Inspect $command->toArguments() and $command->timeout(), then answer with an ExecutionCompleted. + } +} + +$container = GenericDockerContainer::from(image: 'alpine:3.22', client: new RecordingDockerClient()); ``` ### Usage examples @@ -513,41 +569,141 @@ $flywayContainer->cleanAndMigrate(); #### MySQL with Flyway migrations -Configure both containers and start image pulls in parallel before running either one: - ```php pullImage() +$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4') + ->withName(name: 'test-database') ->withNetwork(name: 'my-network') ->withTimezone(timezone: 'America/Sao_Paulo') ->withPassword(password: 'secret') ->withDatabase(database: 'test_adm') ->withRootPassword(rootPassword: 'root') - ->withGrantedHosts(); + ->withGrantedHosts() + ->runIfNotExists(); -$flywayContainer = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine') - ->pullImage() +FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine') + ->withSource(password: 'root', username: 'root', container: $mySQLStarted) ->withNetwork(name: 'my-network') ->withMigrations(pathOnHost: '/path/to/migrations') - ->withCleanDisabled(disabled: false) ->withConnectRetries(retries: 5) - ->withValidateMigrationNaming(enabled: true); - -$mySQLStarted = $mySQLContainer->runIfNotExists(); -$mySQLStarted->stopOnShutdown(); - -$flywayContainer - ->withSource(password: 'root', username: 'root', container: $mySQLStarted) + ->withMigrationNamingValidation() ->cleanAndMigrate(); ``` +#### Integration test bootstrap shared by every process of a run + +A PHPUnit bootstrap runs once per PHP process, and mutation testing starts many processes for one test run. The +pattern below starts MySQL and migrates it only in the process that asks for it (`RUN_MIGRATIONS=1`), while every +process derives the same database host without calling Docker. The container name is scoped to the test runner +(`gethostname()` is the runner container ID inside Docker), so two runs in parallel never share, clean, or reap each +other's database. + +```php +withName(name: $databaseHost) + ->withNetwork(name: $network) + ->withDatabase(database: 'my_service_test') + ->withRootPassword(rootPassword: 'root') + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 120)) + ->runIfNotExists(); + + FlywayDockerContainer::from(image: 'flyway/flyway:13.9.0-alpine') + ->withSource(password: 'root', username: 'root', container: $mySQLStarted) + ->withNetwork(name: $network) + ->withMigrations(pathOnHost: sprintf('%s/../database/migrations', __DIR__)) + ->withConnectRetries(retries: 60) + ->withMigrationNamingValidation() + ->cleanAndMigrate(); +} +``` + +### Upgrading from 3.x + +Version 4.0 makes definitions immutable, types durations, makes Flyway commands one-shot runs, and splits a started +container by ownership. The changes below are the ones a 3.x caller meets. + +| 3.x | 4.0 | +|------------------------------------------------------------------|-------------------------------------------------------------------------------------| +| `$container->withX(...)` mutated the receiver | `with*` returns a new definition, keep the returned value | +| `DockerContainer`, `MySQLContainer`, `FlywayContainer` | Removed, type against the concrete `*DockerContainer` classes | +| `GenericDockerContainer::from(image: ..., name: ...)` | `GenericDockerContainer::from(image: ...)->withName(name: ...)` | +| `MySQLDockerContainer::from(image: ..., name: ...)` | `MySQLDockerContainer::from(image: ...)->withName(name: ...)` | +| `FlywayDockerContainer::from(image: ..., name: ...)` | `FlywayDockerContainer::from(image: ...)`, names are generated per run | +| `runWhen(gate: ..., then: ...)` and `EnvironmentFlag` | Removed, use a plain `if (getenv('FLAG') === '1')` around the run | +| `pullImage()` | Removed, `docker create` pulls a missing image (it never ran in parallel) | +| `run(commands: ..., waitAfterStarted: ...)` | `withWaitAfterStarted(wait: ...)->run(commands: ...)` | +| `runIfNotExists(commands: ..., waitAfterStarted: ...)` | `withWaitAfterStarted(wait: ...)->runIfNotExists(commands: ...)` | +| `withWaitBeforeRun(wait: ...)` replaced the previous strategy | It adds to the registered strategies, which apply in order | +| `run()` and `runIfNotExists()` returned `ContainerStarted` | `run()` returns `DisposableContainer`, `runIfNotExists()` returns `SharedContainer` | +| `remove()` and `stopOnShutdown()` on a `runIfNotExists()` result | Removed, they did nothing there. Only a `DisposableContainer` offers them | +| `wasReused()` on a `run()` result | Removed, it was always false. Only a `SharedContainer` offers it | +| `stop()` | `stop(gracePeriod: Duration::fromSeconds(seconds: 10))` | +| `stop(timeoutInWholeSeconds: 60)` | `stop(gracePeriod: Duration::fromSeconds(seconds: 60))` | +| `cleanAndMigrate(timeoutInSeconds: 300)` and the other commands | `withTimeout(timeout: Duration::fromSeconds(seconds: 300))` | +| Flyway commands returned `ContainerStarted` | They return `ExecutionCompleted`, carrying the Flyway output | +| `withCleanDisabled(disabled: false)` | Removed, `cleanAndMigrate()` enables clean for its own run | +| `withValidateMigrationNaming(enabled: true)` | `withMigrationNamingValidation()` | +| `withReadinessTimeout(timeoutInSeconds: 60)` | `withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 60))` | +| `ContainerWaitForTime::forSeconds(seconds: 3)` | `ContainerWaitForTime::forDuration(duration: Duration::fromSeconds(seconds: 3))` | +| `untilReady(condition: ..., timeoutInSeconds: 60)` | `untilReady(condition: ...)->withTimeout(timeout: ...)` | +| `MySQLDockerContainer::run(waitAfterStarted: ...)` | Removed, MySQL readiness is built in | +| Exceptions under `Internal\Exceptions` | Public ones under `Exceptions` (see [Handling failures](#handling-failures)) | + +A variable typed `MySQLContainerStarted` keeps accepting the result of either `run()` or `runIfNotExists()`, and +`FlywayDockerContainer::withSource()` still takes it. + +Two behaviors change without a signature change. `MySQLDockerContainer::runIfNotExists()` now waits until MySQL +accepts connections and runs the database setup, as `run()` always did. A failed Flyway command now reports the +Flyway error instead of the bare exit status. + +## FAQ + +### 01. Why is the shared container name scoped to the test runner? + +`runIfNotExists()` shares a container among every process that asks for the same name. With a fixed name, two test +runs in parallel (two terminals, two CI jobs on one host) share one database: one run cleans the schema the other is +using, and the first run to finish reaps the container from under the other. Suffixing the name with `gethostname()` +keeps the sharing inside one run, because inside Docker the host name is the ID of the runner container, unique per +`docker run`, and still identical in every process that run starts. + +### 02. Why does each Flyway command run in its own container? + +Flyway is a one-shot program. Creating the container without automatic removal, starting it attached, and removing +it after it exits lets the library observe the exit status and the error output reliably, which a container removed +by Docker on exit cannot guarantee. A unique generated name per definition keeps parallel runs from colliding. + +### 03. Why do run() and runIfNotExists() return different types? + +A container from `run()` belongs to the process that started it, so that process may remove it. A container from +`runIfNotExists()` is shared by every process of a run, so no single process may remove it, and only a shared +container can have been reused. Each type offers only what holds for it, instead of one contract whose methods do +nothing or always answer the same on one side. + +> Robert C. Martin, *Agile Software Development, Principles, Patterns, and Practices* (Prentice Hall, 2002), Chapter +> 12, "The Interface Segregation Principle". + ## License Docker container is licensed under [MIT](LICENSE).