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).