diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 7e76f9a..40a90e9 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -65,8 +65,7 @@ Verify every item before producing any PHP code. If any item fails, revise befor ## Comparisons 1. Null checks: use `is_null($variable)`, never `$variable === null`. -2. Empty string checks on typed `string` parameters: use `$variable === ''`. Avoid `empty()` on typed strings because - `empty('0')` returns `true`. +2. Empty string checks on typed `string` parameters: use `empty($variable)`, never `$variable === ''`. 3. Mixed or untyped checks (value may be `null`, empty string, `0`, or `false`): use `empty($variable)`. ## American English diff --git a/Makefile b/Makefile index 75c08aa..0a34e8b 100644 --- a/Makefile +++ b/Makefile @@ -41,9 +41,8 @@ unit-test-no-coverage: ## Run unit tests without coverage .PHONY: configure-test-environment configure-test-environment: @if ! docker network inspect tiny-blocks > /dev/null 2>&1; then \ - docker network create tiny-blocks > /dev/null 2>&1; \ + docker network create --label tiny-blocks.docker-container=true tiny-blocks > /dev/null 2>&1; \ fi - @docker volume create test-adm-migrations > /dev/null 2>&1 .PHONY: review review: ## Run static code analysis diff --git a/README.md b/README.md index 8698d2c..03e7a42 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ * [Creating a container](#creating-a-container) * [Running a container](#running-a-container) * [Running if not exists](#running-if-not-exists) - * [Pulling an image](#pulling-an-image) + * [Pulling images in parallel](#pulling-images-in-parallel) * [Setting network](#setting-network) * [Setting port mappings](#setting-port-mappings) * [Setting volume mappings](#setting-volume-mappings) @@ -16,12 +16,18 @@ * [Disabling auto-remove](#disabling-auto-remove) * [Copying files to a container](#copying-files-to-a-container) * [Stopping a container](#stopping-a-container) + * [Stopping on shutdown](#stopping-on-shutdown) * [Executing commands after startup](#executing-commands-after-startup) * [Wait strategies](#wait-strategies) * [MySQL container](#mysql-container) * [Configuring MySQL options](#configuring-mysql-options) * [Setting readiness timeout](#setting-readiness-timeout) * [Retrieving connection data](#retrieving-connection-data) +* [Flyway container](#flyway-container) + * [Setting the database source](#setting-the-database-source) + * [Configuring migrations](#configuring-migrations) + * [Configuring Flyway options](#configuring-flyway-options) + * [Running Flyway commands](#running-flyway-commands) * [Usage examples](#usage-examples) * [MySQL with Flyway migrations](#mysql-with-flyway-migrations) * [License](#license) @@ -80,24 +86,37 @@ Starts a container only if a container with the same name is not already running $container->runIfNotExists(); ``` -### Pulling an image +### Pulling images in parallel -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 on multiple containers before running them enables -parallel image pulls. +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 -$alpine = GenericDockerContainer::from(image: 'alpine:latest')->pullImage(); -$nginx = GenericDockerContainer::from(image: 'nginx:latest')->pullImage(); +$mysql = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database') + ->pullImage() + ->withRootPassword(rootPassword: 'root'); + +$flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine') + ->pullImage() + ->withMigrations(pathOnHost: '/path/to/migrations'); -$alpineStarted = $alpine->run(); -$nginxStarted = $nginx->run(); +// 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(container: $mySQLStarted, username: 'root', password: 'root') + ->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. +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. ```php $container->withNetwork(name: 'my-network'); @@ -105,24 +124,23 @@ $container->withNetwork(name: 'my-network'); ### Setting port mappings -Maps ports between the host and the container. Multiple port mappings are supported. +Maps a port from the host to the container. ```php -$container->withPortMapping(portOnHost: 9000, portOnContainer: 9000); $container->withPortMapping(portOnHost: 8080, portOnContainer: 80); ``` ### Setting volume mappings -Maps a volume from the host to the container. +Mounts a directory from the host into the container. ```php -$container->withVolumeMapping(pathOnHost: '/path/on/host', pathOnContainer: '/path/in/container'); +$container->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/container/data'); ``` ### Setting environment variables -Sets environment variables inside the container. +Adds an environment variable to the container. ```php $container->withEnvironmentVariable(key: 'APP_ENV', value: 'testing'); @@ -130,7 +148,7 @@ $container->withEnvironmentVariable(key: 'APP_ENV', value: 'testing'); ### Disabling auto-remove -Prevents the container from being automatically removed when stopped. +By default, containers are removed when stopped. This disables that behavior. ```php $container->withoutAutoRemove(); @@ -160,6 +178,23 @@ With a custom timeout: $result = $started->stop(timeoutInWholeSeconds: 60); ``` +### Stopping on shutdown + +Registers the container 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. +- Unused networks created by the library are pruned. + +Only resources labeled with `tiny-blocks.docker-container=true` are affected. Containers, volumes, and networks +from other environments are never touched. + +```php +$started = $container->run(); +$started->stopOnShutdown(); +``` + ### Executing commands after startup Runs commands inside an already-started container. @@ -192,11 +227,11 @@ Blocks until a readiness condition is satisfied, with a configurable timeout. Th depends on another being fully ready. ```php -$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.1') +$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4') ->withRootPassword(rootPassword: 'root') ->run(); -$flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.1.0') +$container = GenericDockerContainer::from(image: 'my-app:latest') ->withWaitBeforeRun( wait: ContainerWaitForDependency::untilReady( condition: MySQLReady::from(container: $mySQLStarted), @@ -223,7 +258,7 @@ MySQL-specific configuration and automatic readiness detection. | `withGrantedHosts` | `$hosts` | Sets hosts granted root privileges (default: `['%', '172.%']`). | ```php -$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'my-database') +$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database') ->withTimezone(timezone: 'America/Sao_Paulo') ->withUsername(user: 'app_user') ->withPassword(password: 'secret') @@ -240,7 +275,7 @@ Configures how long the MySQL container waits for the database to become ready b `ContainerWaitTimeout` exception. The default timeout is 30 seconds. ```php -$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'my-database') +$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'my-database') ->withRootPassword(rootPassword: 'root') ->withReadinessTimeout(timeoutInSeconds: 60) ->run(); @@ -264,6 +299,65 @@ $password = $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD'); $jdbcUrl = $mySQLContainer->getJdbcUrl(); ``` +## Flyway container + +`FlywayDockerContainer` provides a specialized container for running Flyway database migrations. It encapsulates +Flyway configuration, database source detection, and migration file management. + +### 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`. + +```php +$flywayContainer = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine') + ->withNetwork(name: 'my-network') + ->withMigrations(pathOnHost: '/path/to/migrations') + ->withSource(container: $mySQLStarted, username: 'root', password: 'root'); +``` + +The schema and table can be overridden after calling `withSource()`: + +```php +$flywayContainer + ->withSource(container: $mySQLStarted, username: 'root', password: 'root') + ->withSchema(schema: 'custom_schema') + ->withTable(table: 'custom_history'); +``` + +### Configuring migrations + +Sets the host directory containing Flyway migration SQL files. The files are copied into the container at +`/flyway/migrations`. + +```php +$flywayContainer->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. | + +### 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. | + +```php +$flywayContainer->migrate(); +$flywayContainer->cleanAndMigrate(); +``` + ## Usage examples - When running the containers from the library on a host (your local machine), map the volume @@ -273,59 +367,32 @@ $jdbcUrl = $mySQLContainer->getJdbcUrl(); ### MySQL with Flyway migrations -The MySQL container is configured and started: +Configure both containers and start image pulls in parallel before running either one: ```php -$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'test-database') - ->withNetwork(name: 'tiny-blocks') +$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'test-database') + ->pullImage() + ->withNetwork(name: 'my-network') ->withTimezone(timezone: 'America/Sao_Paulo') - ->withUsername(user: 'xpto') - ->withPassword(password: '123') + ->withPassword(password: 'secret') ->withDatabase(database: 'test_adm') - ->withPortMapping(portOnHost: 3306, portOnContainer: 3306) ->withRootPassword(rootPassword: 'root') - ->withGrantedHosts() - ->withReadinessTimeout(timeoutInSeconds: 60) - ->withoutAutoRemove() - ->runIfNotExists(); -``` - -With the MySQL container started, retrieve the connection data: - -```php -$environmentVariables = $mySQLContainer->getEnvironmentVariables(); -$jdbcUrl = $mySQLContainer->getJdbcUrl(); -$database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); -$username = $environmentVariables->getValueBy(key: 'MYSQL_USER'); -$password = $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD'); -``` - -The Flyway container is configured and only starts after the MySQL container is **ready**: - -```php -$flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.1.0') - ->withNetwork(name: 'tiny-blocks') - ->copyToContainer(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql') - ->withVolumeMapping(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql') - ->withWaitBeforeRun( - wait: ContainerWaitForDependency::untilReady( - condition: MySQLReady::from(container: $mySQLContainer), - timeoutInSeconds: 30 - ) - ) - ->withEnvironmentVariable(key: 'FLYWAY_URL', value: $jdbcUrl) - ->withEnvironmentVariable(key: 'FLYWAY_USER', value: $username) - ->withEnvironmentVariable(key: 'FLYWAY_TABLE', value: 'schema_history') - ->withEnvironmentVariable(key: 'FLYWAY_SCHEMAS', value: $database) - ->withEnvironmentVariable(key: 'FLYWAY_EDITION', value: 'community') - ->withEnvironmentVariable(key: 'FLYWAY_PASSWORD', value: $password) - ->withEnvironmentVariable(key: 'FLYWAY_LOCATIONS', value: 'filesystem:/flyway/sql') - ->withEnvironmentVariable(key: 'FLYWAY_CLEAN_DISABLED', value: 'false') - ->withEnvironmentVariable(key: 'FLYWAY_VALIDATE_MIGRATION_NAMING', value: 'true') - ->run( - commands: ['-connectRetries=15', 'clean', 'migrate'], - waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 5) - ); + ->withGrantedHosts(); + +$flywayContainer = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine') + ->pullImage() + ->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(container: $mySQLStarted, username: 'root', password: 'root') + ->cleanAndMigrate(); ``` ## License diff --git a/src/Contracts/ContainerStarted.php b/src/Contracts/ContainerStarted.php index 0000e19..e2bd6a9 100644 --- a/src/Contracts/ContainerStarted.php +++ b/src/Contracts/ContainerStarted.php @@ -45,7 +45,7 @@ public function getAddress(): Address; public function getEnvironmentVariables(): EnvironmentVariables; /** - * Stops the running container. + * Stops the running container gracefully. * * @param int $timeoutInWholeSeconds The maximum time in seconds to wait for the container to stop. * @return ExecutionCompleted The result of the stop command execution. @@ -53,6 +53,17 @@ public function getEnvironmentVariables(): EnvironmentVariables; */ public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted; + /** + * Forcefully removes the container and its anonymous volumes, then prunes + * unused networks created by the library. + */ + public function remove(): void; + + /** + * Registers the container to be removed when the PHP process exits. + */ + public function stopOnShutdown(): void; + /** * Executes commands inside the running container. * diff --git a/src/FlywayContainer.php b/src/FlywayContainer.php new file mode 100644 index 0000000..96d253e --- /dev/null +++ b/src/FlywayContainer.php @@ -0,0 +1,133 @@ +container->run(commands: ['migrate']); + } + + public function repair(): ContainerStarted + { + return $this->container->run(commands: ['repair']); + } + + public function validate(): ContainerStarted + { + return $this->container->run(commands: ['validate']); + } + + public function pullImage(): static + { + $this->container->pullImage(); + + return $this; + } + + public function withTable(string $table): static + { + $this->container->withEnvironmentVariable(key: 'FLYWAY_TABLE', value: $table); + + return $this; + } + + public function withSchema(string $schema): static + { + $this->container->withEnvironmentVariable(key: 'FLYWAY_SCHEMAS', value: $schema); + + return $this; + } + + public function withNetwork(string $name): static + { + $this->container->withNetwork(name: $name); + + return $this; + } + + public function withCleanDisabled(bool $disabled): static + { + $this->container->withEnvironmentVariable(key: 'FLYWAY_CLEAN_DISABLED', value: $disabled ? 'true' : 'false'); + + return $this; + } + + public function withConnectRetries(int $retries): static + { + $this->container->withEnvironmentVariable(key: 'FLYWAY_CONNECT_RETRIES', value: (string)$retries); + + return $this; + } + + public function withValidateMigrationNaming(bool $enabled): static + { + $this->container->withEnvironmentVariable( + key: 'FLYWAY_VALIDATE_MIGRATION_NAMING', + value: $enabled ? 'true' : 'false' + ); + + return $this; + } + + public function cleanAndMigrate(): ContainerStarted + { + return $this->container->run( + commands: ['clean', 'migrate'], + waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 10) + ); + } + + public function withMigrations(string $pathOnHost): static + { + $this->container->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: '/flyway/migrations'); + $this->container->withEnvironmentVariable(key: 'FLYWAY_LOCATIONS', value: 'filesystem:/flyway/migrations'); + + return $this; + } + + public function withSource(MySQLContainerStarted $container, string $username, string $password): static + { + $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)) + ); + + return $this; + } +} diff --git a/src/GenericDockerContainer.php b/src/GenericDockerContainer.php index 87d5433..5a12cca 100644 --- a/src/GenericDockerContainer.php +++ b/src/GenericDockerContainer.php @@ -23,12 +23,9 @@ class GenericDockerContainer implements DockerContainer private ?ContainerWaitBeforeStarted $waitBeforeStarted = null; - private CommandHandler $commandHandler; - - protected function __construct(ContainerDefinition $definition, CommandHandler $commandHandler) + protected function __construct(ContainerDefinition $definition, private CommandHandler $commandHandler) { $this->definition = $definition; - $this->commandHandler = $commandHandler; } public static function from(string $image, ?string $name = null): static diff --git a/src/Internal/Commands/DockerNetworkCreate.php b/src/Internal/Commands/DockerNetworkCreate.php index 2137a50..3cef5a1 100644 --- a/src/Internal/Commands/DockerNetworkCreate.php +++ b/src/Internal/Commands/DockerNetworkCreate.php @@ -17,6 +17,10 @@ public static function from(string $network): DockerNetworkCreate public function toCommandLine(): string { - return sprintf('docker network create %s 2>/dev/null || true', $this->network); + return sprintf( + 'docker network create --label %s %s 2>/dev/null || true', + DockerRun::MANAGED_LABEL, + $this->network + ); } } diff --git a/src/Internal/Commands/DockerNetworkPrune.php b/src/Internal/Commands/DockerNetworkPrune.php new file mode 100644 index 0000000..3b5875c --- /dev/null +++ b/src/Internal/Commands/DockerNetworkPrune.php @@ -0,0 +1,22 @@ +id->value); + } +} diff --git a/src/Internal/Commands/DockerRun.php b/src/Internal/Commands/DockerRun.php index 960e6e2..c9dcf70 100644 --- a/src/Internal/Commands/DockerRun.php +++ b/src/Internal/Commands/DockerRun.php @@ -12,6 +12,8 @@ final readonly class DockerRun implements Command { + public const string MANAGED_LABEL = 'tiny-blocks.docker-container=true'; + private function __construct(private Collection $commands, public ContainerDefinition $definition) { } @@ -28,7 +30,8 @@ public function toCommandLine(): string $parts = Collection::createFrom(elements: [ 'docker run --user root', sprintf('--name %s', $name), - sprintf('--hostname %s', $name) + sprintf('--hostname %s', $name), + sprintf('--label %s', self::MANAGED_LABEL) ]); $parts = $parts->merge( diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php b/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php index 9c0fcc1..d289307 100644 --- a/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php +++ b/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php @@ -6,8 +6,8 @@ final readonly class MySQLCommands { - private const string USER_ROOT = 'root'; 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 CREATE_DATABASE = 'CREATE DATABASE IF NOT EXISTS %s;'; diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php b/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php index 60dee2f..b19b902 100644 --- a/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php +++ b/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php @@ -38,6 +38,16 @@ public function getAddress(): Address return $this->containerStarted->getAddress(); } + public function remove(): void + { + $this->containerStarted->remove(); + } + + public function stopOnShutdown(): void + { + $this->containerStarted->stopOnShutdown(); + } + public function getEnvironmentVariables(): EnvironmentVariables { return $this->containerStarted->getEnvironmentVariables(); diff --git a/src/Internal/Containers/Models/ContainerId.php b/src/Internal/Containers/Models/ContainerId.php index d4180ea..42d8f6b 100644 --- a/src/Internal/Containers/Models/ContainerId.php +++ b/src/Internal/Containers/Models/ContainerId.php @@ -8,8 +8,8 @@ final readonly class ContainerId { - private const int CONTAINER_ID_OFFSET = 0; private const int CONTAINER_ID_LENGTH = 12; + private const int CONTAINER_ID_OFFSET = 0; private function __construct(public string $value) { diff --git a/src/Internal/Containers/Started.php b/src/Internal/Containers/Started.php index b8585a2..1938822 100644 --- a/src/Internal/Containers/Started.php +++ b/src/Internal/Containers/Started.php @@ -10,6 +10,8 @@ use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler; use TinyBlocks\DockerContainer\Internal\Commands\DockerExecute; +use TinyBlocks\DockerContainer\Internal\Commands\DockerNetworkPrune; +use TinyBlocks\DockerContainer\Internal\Commands\DockerRemove; use TinyBlocks\DockerContainer\Internal\Commands\DockerStop; use TinyBlocks\DockerContainer\Internal\Containers\Address\Address as ContainerAddress; use TinyBlocks\DockerContainer\Internal\Containers\Environment\EnvironmentVariables as ContainerEnvironmentVariables; @@ -54,6 +56,17 @@ public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE return $this->commandHandler->execute(command: $command); } + public function remove(): void + { + $this->commandHandler->execute(command: DockerRemove::from(id: $this->id)); + $this->commandHandler->execute(command: DockerNetworkPrune::create()); + } + + public function stopOnShutdown(): void + { + register_shutdown_function([$this, 'remove']); + } + public function executeAfterStarted(array $commands): ExecutionCompleted { $command = DockerExecute::from(name: $this->name, commands: $commands); diff --git a/src/MySQLDockerContainer.php b/src/MySQLDockerContainer.php index 0cd4141..2ac1385 100644 --- a/src/MySQLDockerContainer.php +++ b/src/MySQLDockerContainer.php @@ -20,11 +20,8 @@ class MySQLDockerContainer implements MySQLContainer private int $readinessTimeoutInSeconds; - private GenericDockerContainer $container; - - protected function __construct(GenericDockerContainer $container) + protected function __construct(private GenericDockerContainer $container) { - $this->container = $container; $this->readinessTimeoutInSeconds = ContainerWait::DEFAULT_TIMEOUT_IN_SECONDS; } @@ -165,11 +162,13 @@ public function run( if (!empty($database) || !empty($this->grantedHosts)) { $containerStarted->executeAfterStarted( - commands: [MySQLCommands::setupDatabase( - database: $database, - rootPassword: $rootPassword, - grantedHosts: $this->grantedHosts - )] + commands: [ + MySQLCommands::setupDatabase( + database: $database, + rootPassword: $rootPassword, + grantedHosts: $this->grantedHosts + ) + ] ); } diff --git a/tests/Integration/DockerContainerTest.php b/tests/Integration/DockerContainerTest.php index af52187..7d98ac3 100644 --- a/tests/Integration/DockerContainerTest.php +++ b/tests/Integration/DockerContainerTest.php @@ -5,10 +5,9 @@ namespace Test\Integration; use PHPUnit\Framework\TestCase; +use TinyBlocks\DockerContainer\FlywayDockerContainer; use TinyBlocks\DockerContainer\GenericDockerContainer; use TinyBlocks\DockerContainer\MySQLDockerContainer; -use TinyBlocks\DockerContainer\Waits\Conditions\MySQL\MySQLReady; -use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency; use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime; final class DockerContainerTest extends TestCase @@ -18,8 +17,9 @@ final class DockerContainerTest extends TestCase public function testMultipleContainersAreRunSuccessfully(): void { - /** @Given a MySQL container is set up with a database */ - $mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'test-database') + /** @Given a MySQL container is configured */ + $mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.4', name: 'test-database') + ->pullImage() ->withNetwork(name: 'tiny-blocks') ->withTimezone(timezone: 'America/Sao_Paulo') ->withUsername(user: self::ROOT) @@ -28,56 +28,42 @@ public function testMultipleContainersAreRunSuccessfully(): void ->withPortMapping(portOnHost: 3306, portOnContainer: 3306) ->withRootPassword(rootPassword: self::ROOT) ->withGrantedHosts() - ->withReadinessTimeout(timeoutInSeconds: 60) - ->withoutAutoRemove() - ->runIfNotExists(); - - /** @And the MySQL container is running */ - $environmentVariables = $mySQLContainer->getEnvironmentVariables(); - $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); - $username = $environmentVariables->getValueBy(key: 'MYSQL_USER'); - $password = $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD'); - $address = $mySQLContainer->getAddress(); - $port = $address->getPorts()->firstExposedPort(); - - self::assertSame(expected: 'test-database', actual: $mySQLContainer->getName()); - self::assertSame(expected: 3306, actual: $port); - self::assertSame(expected: self::DATABASE, actual: $database); + ->withReadinessTimeout(timeoutInSeconds: 60); - /** @Given a Flyway container is configured to perform database migrations */ - $jdbcUrl = $mySQLContainer->getJdbcUrl(); - - $flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.1.0') + /** @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') - ->copyToContainer(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql') - ->withVolumeMapping(pathOnHost: '/test-adm-migrations', pathOnContainer: '/flyway/sql') - ->withWaitBeforeRun( - wait: ContainerWaitForDependency::untilReady( - condition: MySQLReady::from(container: $mySQLContainer), - timeoutInSeconds: 30 - ) + ->withMigrations(pathOnHost: '/test-adm-migrations') + ->withCleanDisabled(disabled: false) + ->withConnectRetries(retries: 15) + ->withValidateMigrationNaming(enabled: true); + + /** @When the MySQL container is started */ + $mySQLStarted = $mySQLContainer->runIfNotExists(); + $mySQLStarted->stopOnShutdown(); + + /** @Then the MySQL container should be running */ + $environmentVariables = $mySQLStarted->getEnvironmentVariables(); + $address = $mySQLStarted->getAddress(); + + self::assertSame(expected: 'test-database', actual: $mySQLStarted->getName()); + self::assertSame(expected: 3306, actual: $address->getPorts()->firstExposedPort()); + self::assertSame(expected: self::DATABASE, actual: $environmentVariables->getValueBy(key: 'MYSQL_DATABASE')); + + /** @And when Flyway runs migrations against the started MySQL container */ + $flywayStarted = $flywayContainer + ->withSource( + container: $mySQLStarted, + username: $environmentVariables->getValueBy(key: 'MYSQL_USER'), + password: $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD') ) - ->withEnvironmentVariable(key: 'FLYWAY_URL', value: $jdbcUrl) - ->withEnvironmentVariable(key: 'FLYWAY_USER', value: $username) - ->withEnvironmentVariable(key: 'FLYWAY_TABLE', value: 'schema_history') - ->withEnvironmentVariable(key: 'FLYWAY_SCHEMAS', value: $database) - ->withEnvironmentVariable(key: 'FLYWAY_EDITION', value: 'community') - ->withEnvironmentVariable(key: 'FLYWAY_PASSWORD', value: $password) - ->withEnvironmentVariable(key: 'FLYWAY_LOCATIONS', value: 'filesystem:/flyway/sql') - ->withEnvironmentVariable(key: 'FLYWAY_CLEAN_DISABLED', value: 'false') - ->withEnvironmentVariable(key: 'FLYWAY_VALIDATE_MIGRATION_NAMING', value: 'true'); - - /** @When the Flyway container runs the migration commands */ - $flywayContainer = $flywayContainer->run( - commands: ['-connectRetries=15', 'clean', 'migrate'], - waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 7) - ); + ->cleanAndMigrate(); - /** @And the Flyway container should be running */ - self::assertNotEmpty($flywayContainer->getName()); + /** @Then the migrations should have populated the database */ + self::assertNotEmpty($flywayStarted->getName()); - /** @Then the Flyway container should execute the migrations successfully */ - $records = MySQLRepository::connectFrom(container: $mySQLContainer)->allRecordsFrom(table: 'xpto'); + $records = MySQLRepository::connectFrom(container: $mySQLStarted)->allRecordsFrom(table: 'xpto'); self::assertCount(expectedCount: 10, haystack: $records); } @@ -92,6 +78,7 @@ public function testRunCalledTwiceForSameContainerDoesNotStartTwice(): void /** @When the container is started for the first time */ $firstRun = $container->runIfNotExists(); + $firstRun->stopOnShutdown(); /** @Then the container should be successfully started */ self::assertSame(expected: '123', actual: $firstRun->getEnvironmentVariables()->getValueBy(key: 'TEST')); diff --git a/tests/Unit/FlywayDockerContainerTest.php b/tests/Unit/FlywayDockerContainerTest.php new file mode 100644 index 0000000..8600493 --- /dev/null +++ b/tests/Unit/FlywayDockerContainerTest.php @@ -0,0 +1,495 @@ +client = new ClientMock(); + } + + public function testMigrateRunsFlywayMigrateCommand(): void + { + /** @Given a Flyway container */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-migrate', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'flyway-migrate') + ); + + /** @When migrate is called */ + $started = $container->migrate(); + + /** @Then the container should have executed the migrate command */ + self::assertSame(expected: 'flyway-migrate', actual: $started->getName()); + self::assertCommandLineContains(needle: 'migrate', commandLines: $this->client->getExecutedCommandLines()); + } + + public function testRepairRunsFlywayRepairCommand(): void + { + /** @Given a Flyway container */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-repair', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'flyway-repair') + ); + + /** @When repair is called */ + $started = $container->repair(); + + /** @Then the container should have executed the repair command */ + self::assertSame(expected: 'flyway-repair', actual: $started->getName()); + self::assertCommandLineContains(needle: 'repair', commandLines: $this->client->getExecutedCommandLines()); + } + + public function testValidateRunsFlywayValidateCommand(): void + { + /** @Given a Flyway container */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-validate', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'flyway-validate') + ); + + /** @When validate is called */ + $started = $container->validate(); + + /** @Then the container should have executed the validate command */ + self::assertSame(expected: 'flyway-validate', actual: $started->getName()); + self::assertCommandLineContains(needle: 'validate', commandLines: $this->client->getExecutedCommandLines()); + } + + public function testCleanAndMigrateRunsBothCommands(): void + { + /** @Given a Flyway container */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-clean-migrate', + 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') + ); + + /** @When cleanAndMigrate is called */ + $started = $container->cleanAndMigrate(); + + /** @Then the container should have executed clean followed by migrate */ + self::assertSame(expected: 'flyway-clean-migrate', actual: $started->getName()); + self::assertCommandLineContains( + needle: 'clean migrate', + commandLines: $this->client->getExecutedCommandLines() + ); + } + + public function testWithSourceAutoDetectsSchemaFromMySQLContainer(): void + { + /** @Given a running MySQL container with database "products" */ + $mySQLStarted = $this->createRunningMySQLContainer( + hostname: 'schema-db', + database: 'products' + ); + + /** @And a Flyway container configured with the MySQL source */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-schema', + client: $this->client + )->withSource(container: $mySQLStarted, username: 'root', password: 'root'); + + /** @And the MySQL readiness check succeeds during Flyway startup */ + $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-schema') + ); + + /** @When migrate is called */ + $container->migrate(); + + /** @Then FLYWAY_SCHEMAS should be auto-detected from the MySQL database name */ + self::assertCommandLineContains( + needle: "FLYWAY_SCHEMAS='products'", + commandLines: $this->client->getExecutedCommandLines() + ); + } + + public function testWithSourceSetsDefaultSchemaHistoryTable(): void + { + /** @Given a running MySQL container */ + $mySQLStarted = $this->createRunningMySQLContainer( + hostname: 'table-db', + database: 'test_db' + ); + + /** @And a Flyway container configured with the MySQL source */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-table', + client: $this->client + )->withSource(container: $mySQLStarted, username: 'root', password: 'root'); + + /** @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-table') + ); + + /** @When migrate is called */ + $container->migrate(); + + /** @Then FLYWAY_TABLE should default to "schema_history" */ + self::assertCommandLineContains( + needle: "FLYWAY_TABLE='schema_history'", + commandLines: $this->client->getExecutedCommandLines() + ); + } + + public function testWithSourceConfiguresJdbcUrlAndCredentials(): void + { + /** @Given a running MySQL container */ + $mySQLStarted = $this->createRunningMySQLContainer( + hostname: 'source-db', + database: 'app_database' + ); + + /** @And a Flyway container configured with the MySQL source */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-source', + client: $this->client + )->withSource(container: $mySQLStarted, username: 'admin', password: 'secret'); + + /** @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-source') + ); + + /** @When migrate is called */ + $container->migrate(); + + /** @Then the docker run command should include the JDBC URL and credentials */ + $commandLines = $this->client->getExecutedCommandLines(); + self::assertCommandLineContains( + needle: "FLYWAY_URL='jdbc:mysql://source-db:3306/app_database", + commandLines: $commandLines + ); + self::assertCommandLineContains(needle: "FLYWAY_USER='admin'", commandLines: $commandLines); + self::assertCommandLineContains(needle: "FLYWAY_PASSWORD='secret'", commandLines: $commandLines); + } + + public function testWithSchemaOverridesAutoDetectedSchema(): void + { + /** @Given a running MySQL container with database "original" */ + $mySQLStarted = $this->createRunningMySQLContainer( + hostname: 'override-db', + database: 'original' + ); + + /** @And a Flyway container with source and a schema override */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-override-schema', + client: $this->client + ) + ->withSource(container: $mySQLStarted, username: 'root', password: 'root') + ->withSchema(schema: 'custom_schema'); + + /** @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-schema') + ); + + /** @When migrate is called */ + $container->migrate(); + + /** @Then the overridden schema should be present in the command */ + self::assertCommandLineContains( + needle: "FLYWAY_SCHEMAS='custom_schema'", + commandLines: $this->client->getExecutedCommandLines() + ); + } + + public function testWithTableOverridesDefaultTable(): void + { + /** @Given a running MySQL container */ + $mySQLStarted = $this->createRunningMySQLContainer( + hostname: 'custom-table-db', + database: 'test_db' + ); + + /** @And a Flyway container with source and a table override */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-override-table', + client: $this->client + ) + ->withSource(container: $mySQLStarted, username: 'root', password: 'root') + ->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') + ); + + /** @When migrate is called */ + $container->migrate(); + + /** @Then the overridden table should be present in the command */ + self::assertCommandLineContains( + needle: "FLYWAY_TABLE='flyway_history'", + commandLines: $this->client->getExecutedCommandLines() + ); + } + + public function testWithMigrationsConfiguresCopyAndLocation(): void + { + /** @Given a Flyway container with migrations configured */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-migrations', + 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') + ); + + /** @When migrate is called */ + $container->migrate(); + + /** @Then the FLYWAY_LOCATIONS should point to the container migrations path */ + $commandLines = $this->client->getExecutedCommandLines(); + self::assertCommandLineContains( + needle: "FLYWAY_LOCATIONS='filesystem:/flyway/migrations'", + commandLines: $commandLines + ); + self::assertCommandLineContains(needle: 'docker cp /host/migrations', commandLines: $commandLines); + } + + public function testWithCleanDisabledSetsEnvironmentVariable(): void + { + /** @Given a Flyway container with clean disabled */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-clean-disabled', + 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') + ); + + /** @When migrate is called */ + $container->migrate(); + + /** @Then FLYWAY_CLEAN_DISABLED should be set to true */ + self::assertCommandLineContains( + needle: "FLYWAY_CLEAN_DISABLED='true'", + commandLines: $this->client->getExecutedCommandLines() + ); + } + + public function testWithConnectRetriesSetsEnvironmentVariable(): void + { + /** @Given a Flyway container with connect retries configured */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-retries', + 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') + ); + + /** @When migrate is called */ + $container->migrate(); + + /** @Then FLYWAY_CONNECT_RETRIES should be set to 10 */ + self::assertCommandLineContains( + needle: "FLYWAY_CONNECT_RETRIES='10'", + commandLines: $this->client->getExecutedCommandLines() + ); + } + + public function testWithValidateMigrationNamingSetsEnvironmentVariable(): void + { + /** @Given a Flyway container with migration naming validation enabled */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-naming', + 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') + ); + + /** @When migrate is called */ + $container->migrate(); + + /** @Then FLYWAY_VALIDATE_MIGRATION_NAMING should be set to true */ + self::assertCommandLineContains( + needle: "FLYWAY_VALIDATE_MIGRATION_NAMING='true'", + commandLines: $this->client->getExecutedCommandLines() + ); + } + + public function testWithNetworkConfiguresDockerNetwork(): void + { + /** @Given a Flyway container with a network */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-network', + 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' + ) + ); + + /** @When migrate is called */ + $container->migrate(); + + /** @Then the docker run command should include the network and auto-creation */ + $commandLines = $this->client->getExecutedCommandLines(); + self::assertCommandLineContains(needle: '--network=test-network', commandLines: $commandLines); + self::assertCommandLineContains( + needle: 'docker network create --label tiny-blocks.docker-container=true test-network', + commandLines: $commandLines + ); + } + + public function testPullImageStartsBackgroundPull(): void + { + /** @Given a Flyway container with image pulling enabled */ + $container = TestableFlywayDockerContainer::createWith( + image: 'flyway/flyway:12-alpine', + name: 'flyway-pull', + 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') + ); + + /** @When migrate is called */ + $started = $container->migrate(); + + /** @Then the container should start successfully after the pull completes */ + self::assertSame(expected: 'flyway-pull', actual: $started->getName()); + } + + protected function createRunningMySQLContainer(string $hostname, string $database): MySQLContainerStarted + { + $container = TestableMySQLDockerContainer::createWith( + image: 'mysql:8.4', + name: $hostname, + client: $this->client + ) + ->withDatabase(database: $database) + ->withRootPassword(rootPassword: 'root'); + + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: $hostname, + environment: [ + sprintf('MYSQL_DATABASE=%s', $database), + 'MYSQL_ROOT_PASSWORD=root' + ], + exposedPorts: ['3306/tcp' => (object)[]] + ) + ); + + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + $this->client->withDockerExecuteResponse(output: ''); + + return $container->run(); + } + + protected static function assertCommandLineContains(string $needle, array $commandLines): void + { + foreach ($commandLines as $commandLine) { + if (str_contains((string)$commandLine, $needle)) { + self::assertTrue(true); + return; + } + } + + self::fail( + sprintf( + 'Expected command containing "%s" not found in executed commands:%s%s', + $needle, + PHP_EOL, + implode(PHP_EOL, $commandLines) + ) + ); + } +} diff --git a/tests/Unit/GenericDockerContainerTest.php b/tests/Unit/GenericDockerContainerTest.php index e4e8936..e52e01e 100644 --- a/tests/Unit/GenericDockerContainerTest.php +++ b/tests/Unit/GenericDockerContainerTest.php @@ -818,7 +818,10 @@ public function testRunCommandLineIncludesNetwork(): void /** @Then the first command should be the network creation */ $networkCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString(needle: 'docker network create my-network', haystack: $networkCommand); + self::assertStringContainsString( + needle: 'docker network create --label tiny-blocks.docker-container=true my-network', + haystack: $networkCommand + ); /** @And the docker run command should contain the network argument */ $runCommand = $this->client->getExecutedCommandLines()[1]; @@ -981,4 +984,95 @@ public function testRunContainerWithPullImage(): void /** @Then the container should be running */ self::assertSame(expected: 'pull-test', actual: $started->getName()); } + + public function testRemoveExecutesDockerRmAndNetworkPrune(): void + { + /** @Given a running container */ + $container = TestableGenericDockerContainer::createWith( + image: 'alpine:latest', + name: 'force-remove', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'force-remove') + ); + + /** @And the container is started */ + $started = $container->run(); + + /** @When remove is called */ + $started->remove(); + + /** @Then a docker rm command should have been executed with the container ID */ + $commandLines = $this->client->getExecutedCommandLines(); + $removeCommand = $commandLines[2]; + + self::assertStringContainsString(needle: 'docker rm --force --volumes', haystack: $removeCommand); + self::assertStringContainsString( + needle: InspectResponseFixture::shortContainerId(), + haystack: $removeCommand + ); + + /** @And a docker network prune command should have been executed with the managed label */ + $pruneCommand = $commandLines[3]; + + self::assertStringContainsString( + needle: 'docker network prune --force --filter label=tiny-blocks.docker-container=true', + haystack: $pruneCommand + ); + } + + public function testRemoveCanBeCalledMultipleTimes(): void + { + /** @Given a running container */ + $container = TestableGenericDockerContainer::createWith( + image: 'alpine:latest', + name: 'already-removed', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'already-removed') + ); + + /** @And the container is started */ + $started = $container->run(); + + /** @When remove is called twice */ + $started->remove(); + $started->remove(); + + /** @Then no exception should be thrown */ + self::assertTrue(true); + } + + public function testStopOnShutdownRegistersRemove(): void + { + /** @Given a running container */ + $container = TestableGenericDockerContainer::createWith( + image: 'alpine:latest', + name: 'shutdown-test', + client: $this->client + ); + + /** @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 function should be registered without errors */ + self::assertSame(expected: 'shutdown-test', actual: $started->getName()); + } } diff --git a/tests/Unit/Internal/Commands/DockerCommandsTest.php b/tests/Unit/Internal/Commands/DockerCommandsTest.php new file mode 100644 index 0000000..f9db0db --- /dev/null +++ b/tests/Unit/Internal/Commands/DockerCommandsTest.php @@ -0,0 +1,91 @@ +toCommandLine(); + + /** @Then the command should pull the specified image */ + self::assertSame(expected: 'docker pull mysql:8.4', actual: $commandLine); + } + + public function testDockerRemoveGeneratesCorrectCommand(): void + { + /** @Given a Docker remove command for a specific container */ + $containerId = ContainerId::from(value: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8'); + $command = DockerRemove::from(id: $containerId); + + /** @When the command line is generated */ + $commandLine = $command->toCommandLine(); + + /** @Then the command should force-remove the container with its volumes */ + self::assertSame(expected: 'docker rm --force --volumes 6acae5967be0', actual: $commandLine); + } + + public function testDockerNetworkCreateGeneratesCommandWithLabel(): void + { + /** @Given a Docker network create command */ + $command = DockerNetworkCreate::from(network: 'my-network'); + + /** @When the command line is generated */ + $commandLine = $command->toCommandLine(); + + /** @Then the command should create the network with the managed label */ + self::assertSame( + expected: 'docker network create --label tiny-blocks.docker-container=true my-network 2>/dev/null || true', + actual: $commandLine + ); + } + + public function testDockerNetworkPruneGeneratesCommandFilteredByLabel(): void + { + /** @Given a Docker network prune command */ + $command = DockerNetworkPrune::create(); + + /** @When the command line is generated */ + $commandLine = $command->toCommandLine(); + + /** @Then the command should prune only networks with the managed label */ + self::assertSame( + expected: 'docker network prune --force --filter label=tiny-blocks.docker-container=true', + actual: $commandLine + ); + } + + public function testDockerRunIncludesManagedLabel(): void + { + /** @Given a Docker run command built from a container definition */ + $definition = ContainerDefinition::create( + image: 'alpine:latest', + name: 'test-label' + ); + $command = DockerRun::from(definition: $definition); + + /** @When the command line is generated */ + $commandLine = $command->toCommandLine(); + + /** @Then the command should include the managed label */ + self::assertStringContainsString( + needle: sprintf('--label %s', DockerRun::MANAGED_LABEL), + haystack: $commandLine + ); + } +} diff --git a/tests/Unit/Mocks/ClientMock.php b/tests/Unit/Mocks/ClientMock.php index 5a7e559..81166a8 100644 --- a/tests/Unit/Mocks/ClientMock.php +++ b/tests/Unit/Mocks/ClientMock.php @@ -8,6 +8,7 @@ use TinyBlocks\DockerContainer\Contracts\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; @@ -71,6 +72,10 @@ public function execute(Command $command): ExecutionCompleted { $this->executedCommandLines[] = $command->toCommandLine(); + if ($command instanceof CommandWithTimeout) { + $command->getTimeoutInWholeSeconds(); + } + if ($command instanceof DockerExecute) { $response = array_shift($this->executeResponses); diff --git a/tests/Unit/Mocks/TestableFlywayDockerContainer.php b/tests/Unit/Mocks/TestableFlywayDockerContainer.php new file mode 100644 index 0000000..dea2a0a --- /dev/null +++ b/tests/Unit/Mocks/TestableFlywayDockerContainer.php @@ -0,0 +1,18 @@ +getName()); } + public function testFromCreatesMySQLContainerInstance(): void + { + /** @Given a valid MySQL image name */ + $image = 'mysql:8.1'; + + /** @When creating a MySQL container from the image */ + $container = MySQLDockerContainer::from(image: $image, name: 'from-mysql'); + + /** @Then the container should be an instance of MySQLDockerContainer */ + self::assertInstanceOf(expected: MySQLDockerContainer::class, actual: $container); + } + + public function testStopOnShutdownDelegatesToUnderlyingContainer(): void + { + /** @Given a running MySQL container */ + $started = $this->createRunningMySQLContainer( + hostname: 'shutdown-db', + database: 'test_adm', + port: 3306 + ); + + /** @When stopOnShutdown is called */ + $started->stopOnShutdown(); + + /** @Then the container should still be accessible (the shutdown handler is deferred) */ + self::assertSame(expected: 'shutdown-db', actual: $started->getName()); + } + + public function testRemoveDelegatesToUnderlyingContainer(): void + { + /** @Given a running MySQL container */ + $started = $this->createRunningMySQLContainer( + hostname: 'remove-db', + database: 'test_adm', + port: 3306 + ); + + /** @When remove is called */ + $started->remove(); + + /** @Then the docker rm command should have been executed */ + $commandLines = $this->client->getExecutedCommandLines(); + $removeCommand = $commandLines[4]; + + self::assertStringContainsString(needle: 'docker rm --force --volumes', haystack: $removeCommand); + } + protected function createRunningMySQLContainer( string $hostname, string $database, diff --git a/tests/bootstrap.php b/tests/bootstrap.php index db4e5e1..6408be9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,5 +1,7 @@