diff --git a/README.md b/README.md
index 0f96376..3e80876 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@
+ [Setting network](#setting-network)
+ [Setting port mappings](#setting-port-mappings)
+ [Setting volume mappings](#setting-volume-mappings)
+ + [Setting tmpfs mounts](#setting-tmpfs-mounts)
+ [Setting environment variables](#setting-environment-variables)
+ [Disabling auto-remove](#disabling-auto-remove)
+ [Copying files to a container](#copying-files-to-a-container)
@@ -25,6 +26,7 @@
+ [MySQL container](#mysql-container)
- [Configuring MySQL options](#configuring-mysql-options)
- [Setting readiness timeout](#setting-readiness-timeout)
+ - [Using ephemeral storage](#using-ephemeral-storage)
- [Retrieving connection data](#retrieving-connection-data)
- [Environment-aware connection](#environment-aware-connection)
+ [Flyway container](#flyway-container)
@@ -32,12 +34,12 @@
- [Configuring migrations](#configuring-migrations)
- [Configuring Flyway options](#configuring-flyway-options)
- [Running Flyway commands](#running-flyway-commands)
+ - [Running only when enabled](#running-only-when-enabled)
+ [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)
@@ -182,6 +184,15 @@ Mounts a directory from the host into the container.
$container = $container->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/container/data');
```
+### Setting tmpfs mounts
+
+Mounts a directory inside the container in memory. It starts empty, and its content is discarded when the container
+stops.
+
+```php
+$container = $container->withTmpfsMount(pathOnContainer: '/container/cache');
+```
+
### Setting environment variables
Adds an environment variable to the container.
@@ -365,6 +376,32 @@ $mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4')
->run();
```
+#### Using ephemeral storage
+
+Keeps the MySQL data in memory. The data directory `/var/lib/mysql` becomes a tmpfs mount, and the server starts
+without the binary log (`--skip-log-bin`), which would otherwise grow in that memory. MySQL initializes faster, and
+statements that write, such as the `TRUNCATE` and `INSERT` a test runs between cases, no longer wait for the disk.
+
+The data is lost when the container stops, so use it only for databases a test run creates and throws away.
+
+```php
+withName(name: 'test-database')
+ ->withDatabase(database: 'test_adm')
+ ->withRootPassword(rootPassword: 'root')
+ ->withEphemeralStorage()
+ ->runIfNotExists();
+```
+
+The server arguments come after any command given to `run()` or `runIfNotExists()`, so they also apply when the
+command names `mysqld` explicitly.
+
#### Retrieving connection data
After the MySQL container starts, connection details are available through the `MySQLContainerStarted` instance.
@@ -429,9 +466,11 @@ collide on it.
#### Setting the database source
-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.
+Binds Flyway to a MySQL container definition. Defining the source starts nothing. The first Flyway command starts the
+MySQL container through `runIfNotExists()`, which reuses a running container of the same name, and waits until MySQL
+accepts connections. Flyway then connects with the JDBC URL of that container, the root credentials (from
+`MYSQL_ROOT_PASSWORD`), the target schema (from `MYSQL_DATABASE`), and the history table (`schema_history`), so it
+needs no connection retries and no readiness wait of its own.
```php
withName(name: 'test-database')
+ ->withNetwork(name: 'my-network')
+ ->withDatabase(database: 'test_adm')
+ ->withRootPassword(rootPassword: 'root');
$flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine')
- ->withSource(password: 'root', username: 'root', container: $mySQLStarted)
+ ->withSource(container: $mySQL)
->withNetwork(name: 'my-network')
->withMigrations(pathOnHost: '/path/to/migrations');
```
-The schema and table can be overridden after calling `withSource()`:
+The credentials, the schema, and the table can be overridden before or after calling `withSource()`:
```php
withSource(password: 'root', username: 'root', container: $mySQLStarted)
+ ->withTable(table: 'custom_history')
->withSchema(schema: 'custom_schema')
- ->withTable(table: 'custom_history');
+ ->withSource(container: $mySQL)
+ ->withCredentials(password: 'secret', username: 'app_user');
```
#### Configuring migrations
@@ -470,14 +517,15 @@ $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: 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. |
+| 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). |
+| `withCredentials` | `$password`, `$username` | Overrides the credentials (default: root, from MySQL). |
+| `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
@@ -506,6 +554,25 @@ use TinyBlocks\Time\Duration;
$flyway->withTimeout(timeout: Duration::fromSeconds(seconds: 300))->cleanAndMigrate();
```
+#### Running only when enabled
+
+`onlyWhenEnabled()` returns a `FlywayContainerGated`, whose commands run only while an environment
+variable holds `1`. The variable is read when a command is called. While the variable holds anything else, or is not
+set, the command returns `null` and issues no Docker command, so neither Flyway nor its MySQL source is started.
+
+```php
+onlyWhenEnabled(variable: 'RUN_MIGRATIONS')
+ ->cleanAndMigrate(); # ExecutionCompleted when RUN_MIGRATIONS=1, null otherwise
+```
+
+The gated container offers the same four commands, with the same exceptions. A command returns an
+`ExecutionCompleted` only when Flyway did run, and `null` tells the caller that nothing ran.
+
### Handling failures
The exceptions consumers may catch live in `TinyBlocks\DockerContainer\Exceptions`.
@@ -577,32 +644,36 @@ declare(strict_types=1);
use TinyBlocks\DockerContainer\FlywayDockerContainer;
use TinyBlocks\DockerContainer\MySQLDockerContainer;
-$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4')
+$mySQL = 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')
+ ->withPassword(password: 'secret')
+ ->withTimezone(timezone: 'America/Sao_Paulo')
->withGrantedHosts()
- ->runIfNotExists();
+ ->withRootPassword(rootPassword: 'root');
FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine')
- ->withSource(password: 'root', username: 'root', container: $mySQLStarted)
+ ->withSource(container: $mySQL)
->withNetwork(name: 'my-network')
->withMigrations(pathOnHost: '/path/to/migrations')
- ->withConnectRetries(retries: 5)
->withMigrationNamingValidation()
->cleanAndMigrate();
+
+$mySQLStarted = $mySQL->runIfNotExists();
```
+MySQL starts when Flyway runs. The last call hands over the same container, reused rather than started again, for
+the tests that need its address or its environment variables.
+
#### 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.
+process derives the same database host without calling Docker. In every other process, `onlyWhenEnabled()` makes
+`cleanAndMigrate()` return `null` before any Docker command, so the whole chain runs unchanged in each process. 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();
-}
+FlywayDockerContainer::from(image: 'flyway/flyway:13.9.0-alpine')
+ ->withSource(
+ container: MySQLDockerContainer::from(image: 'mysql:8.4')
+ ->withName(name: $databaseHost)
+ ->withNetwork(name: $network)
+ ->withDatabase(database: 'my_service_test')
+ ->withRootPassword(rootPassword: 'root')
+ ->withEphemeralStorage()
+ )
+ ->withNetwork(name: $network)
+ ->withMigrations(pathOnHost: sprintf('%s/../database/migrations', __DIR__))
+ ->withMigrationNamingValidation()
+ ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS')
+ ->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?
@@ -688,13 +716,7 @@ using, and the first run to finish reaps the container from under the other. Suf
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?
+### 02. 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
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 99a44b7..d75e44a 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -26,6 +26,7 @@ parameters:
- src/Internal/Containers/Definitions/ContainerDefinition.php
- src/Internal/Containers/Definitions/CopyInstruction.php
- src/Internal/Containers/Drivers/MySQL/JdbcUrl.php
+ - src/Internal/Containers/Drivers/Flyway/FlywaySettings.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.
@@ -53,6 +54,12 @@ parameters:
# MySQLCommands assembles the setup argument list as a raw array, where PHPDoc is prohibited.
- identifier: argument.type
path: src/Internal/Containers/Drivers/MySQL/MySQLStartup.php
+ # FlywaySettings applies its raw settings map, whose values phpstan reads as mixed, where PHPDoc is prohibited.
+ - identifier: argument.type
+ path: src/Internal/Containers/Drivers/Flyway/FlywaySettings.php
+ # MySQLStartup appends the server arguments as a raw array, where PHPDoc is prohibited inside Internal.
+ - identifier: argument.type
+ path: src/MySQLDockerContainer.php
# docker inspect json_decode produces mixed offsets read in ContainerInspection.
- identifier: offsetAccess.nonOffsetAccessible
path: src/Internal/Containers/ContainerInspection.php
@@ -75,6 +82,7 @@ parameters:
- identifier: argument.type
paths:
- tests/Unit/ClientMock.php
+ - tests/Unit/FlywayContainerGatedTest.php
- tests/Unit/FlywayDockerContainerTest.php
- tests/Unit/GenericDockerContainerTest.php
- tests/Unit/MySQLDockerContainerTest.php
diff --git a/src/Exceptions/DockerCommandExecutionFailed.php b/src/Exceptions/DockerCommandExecutionFailed.php
index 5a19aed..4d4ab29 100644
--- a/src/Exceptions/DockerCommandExecutionFailed.php
+++ b/src/Exceptions/DockerCommandExecutionFailed.php
@@ -13,13 +13,25 @@
*/
final class DockerCommandExecutionFailed extends RuntimeException
{
- public function __construct(string $reason, string $command)
+ private function __construct(string $reason, string $command)
{
$template = 'Failed to execute command <%s> in Docker container. Reason: %s';
parent::__construct(message: sprintf($template, $command, $reason));
}
+ /**
+ * Creates a DockerCommandExecutionFailed from a failure reason and the command line that produced it.
+ *
+ * @param string $reason The reason the command failed.
+ * @param string $command The command line that failed.
+ * @return DockerCommandExecutionFailed The created exception.
+ */
+ public static function from(string $reason, string $command): DockerCommandExecutionFailed
+ {
+ return new DockerCommandExecutionFailed(reason: $reason, command: $command);
+ }
+
/**
* Creates a DockerCommandExecutionFailed from a command and the unsuccessful outcome it produced.
*
diff --git a/src/FlywayContainerGated.php b/src/FlywayContainerGated.php
new file mode 100644
index 0000000..fb33587
--- /dev/null
+++ b/src/FlywayContainerGated.php
@@ -0,0 +1,92 @@
+The variable is enabled when its value is 1, and it is read each time a command is called. While
+ * it is not enabled, a command skips the run entirely and returns null, so no Docker command is issued,
+ * neither for Flyway nor for its MySQL source.
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.
The MySQL container is started by the first command that runs, through
+ * {@see MySQLDockerContainer::runIfNotExists()}, so it is reused when a container with its name is already
+ * running. Defining the source starts nothing. Flyway then connects with the JDBC URL of the started container,
+ * the root credentials (from MYSQL_ROOT_PASSWORD), the target schema (from
+ * MYSQL_DATABASE) and the history table (schema_history). withCredentials(),
+ * withSchema() and withTable() override these defaults, whether they are called before or after this method.
The variable is enabled when its value is 1, and it is read each time a command is called.
+ * While it is not enabled, a command returns null without issuing any Docker command, so neither
+ * Flyway nor its MySQL source is started. This keeps the migrations of a test run in the single process that
+ * sets the variable.
The mounted directory lives in memory, starts empty, and its content is discarded when the container + * stops.
+ * + * @param string $pathOnContainer The directory inside the container backed by memory. + * @return GenericDockerContainer The copy with the added tmpfs mount. + */ + public function withTmpfsMount(string $pathOnContainer): GenericDockerContainer + { + return new GenericDockerContainer( + waits: $this->waits, + client: $this->client, + definition: $this->definition->withTmpfsMount(pathOnContainer: $pathOnContainer) + ); + } + /** * Returns a copy of the GenericDockerContainer with a file or directory added to copy into the container. * diff --git a/src/Internal/Client/CommandLine.php b/src/Internal/Client/CommandLine.php index f30fc7a..bac8450 100644 --- a/src/Internal/Client/CommandLine.php +++ b/src/Internal/Client/CommandLine.php @@ -19,7 +19,7 @@ public function run(): Execution try { $this->process->run(); } catch (Throwable $exception) { - throw new DockerCommandExecutionFailed( + throw DockerCommandExecutionFailed::from( reason: $exception->getMessage(), command: $this->process->getCommandLine() ); diff --git a/src/Internal/Containers/Definitions/ContainerDefinition.php b/src/Internal/Containers/Definitions/ContainerDefinition.php index b9431cf..6cbe2d1 100644 --- a/src/Internal/Containers/Definitions/ContainerDefinition.php +++ b/src/Internal/Containers/Definitions/ContainerDefinition.php @@ -15,6 +15,7 @@ private function __construct( public Image $image, public Removal $removal, public Collection $networks, + public Collection $tmpfsMounts, public Collection $portMappings, public Collection $volumeMappings, public Collection $copyInstructions, @@ -29,6 +30,7 @@ public static function create(string $image): ContainerDefinition image: Image::from(image: $image), removal: Removal::AUTOMATIC, networks: Collection::createFromEmpty(), + tmpfsMounts: Collection::createFromEmpty(), portMappings: Collection::createFromEmpty(), volumeMappings: Collection::createFromEmpty(), copyInstructions: Collection::createFromEmpty(), @@ -43,6 +45,7 @@ public function withName(string $name): ContainerDefinition image: $this->image, removal: $this->removal, networks: $this->networks, + tmpfsMounts: $this->tmpfsMounts, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, copyInstructions: $this->copyInstructions, @@ -55,6 +58,7 @@ public function toArguments(): array return $this->portMappings ->merge(other: $this->networks) ->merge(other: $this->volumeMappings) + ->merge(other: $this->tmpfsMounts) ->add($this->removal) ->merge(other: $this->environmentVariables) ->reduce( @@ -73,6 +77,22 @@ public function withNetwork(string $name): ContainerDefinition image: $this->image, removal: $this->removal, networks: Collection::createFrom(elements: [Network::from(name: $name)]), + tmpfsMounts: $this->tmpfsMounts, + portMappings: $this->portMappings, + volumeMappings: $this->volumeMappings, + copyInstructions: $this->copyInstructions, + environmentVariables: $this->environmentVariables + ); + } + + public function withTmpfsMount(string $pathOnContainer): ContainerDefinition + { + return new ContainerDefinition( + name: $this->name, + image: $this->image, + removal: $this->removal, + networks: $this->networks, + tmpfsMounts: $this->tmpfsMounts->add(TmpfsMount::from(pathOnContainer: $pathOnContainer)), portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, copyInstructions: $this->copyInstructions, @@ -87,6 +107,7 @@ public function withPortMapping(int $portOnHost, int $portOnContainer): Containe image: $this->image, removal: $this->removal, networks: $this->networks, + tmpfsMounts: $this->tmpfsMounts, portMappings: $this->portMappings->add( PortMapping::from(portOnHost: $portOnHost, portOnContainer: $portOnContainer) ), @@ -103,6 +124,7 @@ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): image: $this->image, removal: $this->removal, networks: $this->networks, + tmpfsMounts: $this->tmpfsMounts, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings->add( VolumeMapping::from(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer) @@ -119,6 +141,7 @@ public function withoutAutoRemove(): ContainerDefinition image: $this->image, removal: Removal::MANUAL, networks: $this->networks, + tmpfsMounts: $this->tmpfsMounts, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, copyInstructions: $this->copyInstructions, @@ -133,6 +156,7 @@ public function withCopyInstruction(string $pathOnHost, string $pathOnContainer) image: $this->image, removal: $this->removal, networks: $this->networks, + tmpfsMounts: $this->tmpfsMounts, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, copyInstructions: $this->copyInstructions->add( @@ -149,6 +173,7 @@ public function withEnvironmentVariable(string $key, string $value): ContainerDe image: $this->image, removal: $this->removal, networks: $this->networks, + tmpfsMounts: $this->tmpfsMounts, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, copyInstructions: $this->copyInstructions, diff --git a/src/Internal/Containers/Definitions/TmpfsMount.php b/src/Internal/Containers/Definitions/TmpfsMount.php new file mode 100644 index 0000000..3d6f9b9 --- /dev/null +++ b/src/Internal/Containers/Definitions/TmpfsMount.php @@ -0,0 +1,22 @@ +pathOnContainer]; + } +} diff --git a/src/Internal/Containers/Drivers/Flyway/FlywaySettings.php b/src/Internal/Containers/Drivers/Flyway/FlywaySettings.php new file mode 100644 index 0000000..9306eaf --- /dev/null +++ b/src/Internal/Containers/Drivers/Flyway/FlywaySettings.php @@ -0,0 +1,63 @@ +source, settings: [...$this->settings, $key => $value]); + } + + public function applyTo(GenericDockerContainer $container): GenericDockerContainer + { + $settings = [...$this->sourceSettings(), ...$this->settings]; + + return array_reduce( + array_keys($settings), + static fn(GenericDockerContainer $container, string $key): GenericDockerContainer => $container + ->withEnvironmentVariable(key: $key, value: $settings[$key]), + $container + ); + } + + public function withSource(MySQLDockerContainer $source): FlywaySettings + { + return new FlywaySettings(source: $source, settings: $this->settings); + } + + private function sourceSettings(): array + { + if (is_null($this->source)) { + return []; + } + + $sourceStarted = $this->source->runIfNotExists(); + $environmentVariables = $sourceStarted->getEnvironmentVariables(); + + return [ + 'FLYWAY_URL' => $sourceStarted->getJdbcUrl(), + 'FLYWAY_USER' => self::ROOT_USERNAME, + 'FLYWAY_TABLE' => self::HISTORY_TABLE, + 'FLYWAY_SCHEMAS' => $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'), + 'FLYWAY_PASSWORD' => $environmentVariables->getValueBy(key: 'MYSQL_ROOT_PASSWORD') + ]; + } +} diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLStartup.php b/src/Internal/Containers/Drivers/MySQL/MySQLStartup.php index 1b81d55..4fb40f2 100644 --- a/src/Internal/Containers/Drivers/MySQL/MySQLStartup.php +++ b/src/Internal/Containers/Drivers/MySQL/MySQLStartup.php @@ -11,16 +11,21 @@ final readonly class MySQLStartup { + private const array EPHEMERAL_SERVER_ARGUMENTS = ['--skip-log-bin']; private const int DEFAULT_READINESS_TIMEOUT_IN_SECONDS = 30; - private function __construct(private array $grantedHosts, private Duration $readinessTimeout) - { + private function __construct( + private array $grantedHosts, + private array $serverArguments, + private Duration $readinessTimeout + ) { } public static function create(): MySQLStartup { return new MySQLStartup( grantedHosts: [], + serverArguments: [], readinessTimeout: Duration::fromSeconds(seconds: self::DEFAULT_READINESS_TIMEOUT_IN_SECONDS) ); } @@ -44,13 +49,35 @@ public function complete(ContainerStarted $containerStarted): void } } + public function commandsFor(array $commands): array + { + return [...$commands, ...$this->serverArguments]; + } + public function withGrantedHosts(array $hosts): MySQLStartup { - return new MySQLStartup(grantedHosts: $hosts, readinessTimeout: $this->readinessTimeout); + return new MySQLStartup( + grantedHosts: $hosts, + serverArguments: $this->serverArguments, + readinessTimeout: $this->readinessTimeout + ); + } + + public function withEphemeralStorage(): MySQLStartup + { + return new MySQLStartup( + grantedHosts: $this->grantedHosts, + serverArguments: self::EPHEMERAL_SERVER_ARGUMENTS, + readinessTimeout: $this->readinessTimeout + ); } public function withReadinessTimeout(Duration $timeout): MySQLStartup { - return new MySQLStartup(grantedHosts: $this->grantedHosts, readinessTimeout: $timeout); + return new MySQLStartup( + grantedHosts: $this->grantedHosts, + serverArguments: $this->serverArguments, + readinessTimeout: $timeout + ); } } diff --git a/src/Internal/Containers/Environment/EnvironmentFlag.php b/src/Internal/Containers/Environment/EnvironmentFlag.php new file mode 100644 index 0000000..e500a47 --- /dev/null +++ b/src/Internal/Containers/Environment/EnvironmentFlag.php @@ -0,0 +1,24 @@ +variable) === self::ENABLED; + } +} diff --git a/src/MySQLDockerContainer.php b/src/MySQLDockerContainer.php index 0ec61b7..e05e4c7 100644 --- a/src/MySQLDockerContainer.php +++ b/src/MySQLDockerContainer.php @@ -22,6 +22,8 @@ */ final readonly class MySQLDockerContainer { + private const string DATA_DIRECTORY = '/var/lib/mysql'; + private function __construct(private MySQLStartup $startup, private GenericDockerContainer $container) { } @@ -61,7 +63,7 @@ public static function from( */ public function run(array $commands = []): MySQLContainerStarted&DisposableContainer { - $containerStarted = $this->container->run(commands: $commands); + $containerStarted = $this->container->run(commands: $this->startup->commandsFor(commands: $commands)); $this->startup->complete(containerStarted: $containerStarted); return MySQLStarted::from(containerStarted: $containerStarted); @@ -150,7 +152,9 @@ public function withUsername(string $user): MySQLDockerContainer */ public function runIfNotExists(array $commands = []): MySQLContainerStarted&SharedContainer { - $containerStarted = $this->container->runIfNotExists(commands: $commands); + $containerStarted = $this->container->runIfNotExists( + commands: $this->startup->commandsFor(commands: $commands) + ); $this->startup->complete(containerStarted: $containerStarted); return MySQLShared::from(containerStarted: $containerStarted); @@ -250,6 +254,23 @@ public function withoutAutoRemove(): MySQLDockerContainer return new MySQLDockerContainer(startup: $this->startup, container: $this->container->withoutAutoRemove()); } + /** + * Returns a copy of the MySQLDockerContainer that keeps its data in memory. + * + *The data directory is a tmpfs mount, so the data is discarded when the container stops, and the server runs + * without the binary log, which would otherwise grow in that memory. Meant for test databases, never for data + * that must outlive the container.
+ * + * @return MySQLDockerContainer The copy with ephemeral storage. + */ + public function withEphemeralStorage(): MySQLDockerContainer + { + return new MySQLDockerContainer( + startup: $this->startup->withEphemeralStorage(), + container: $this->container->withTmpfsMount(pathOnContainer: self::DATA_DIRECTORY) + ); + } + /** * Returns a copy of the MySQLDockerContainer with the readiness timeout replaced. * diff --git a/tests/Integration/DockerContainerTest.php b/tests/Integration/DockerContainerTest.php index d43bff0..dc593f7 100644 --- a/tests/Integration/DockerContainerTest.php +++ b/tests/Integration/DockerContainerTest.php @@ -18,13 +18,15 @@ final class DockerContainerTest extends TestCase private const string ROOT = 'xpto'; private const string DATABASE = 'test_adm'; + private static MySQLDockerContainer $mySQL; + private static MySQLContainerStarted $mySQLStarted; public static function setUpBeforeClass(): void { $template = 'test-database-%s'; - self::$mySQLStarted = MySQLDockerContainer::from(image: 'mysql:8.4') + self::$mySQL = MySQLDockerContainer::from(image: 'mysql:8.4') ->withName(name: sprintf($template, gethostname())) ->withNetwork(name: 'tiny-blocks') ->withDatabase(database: self::DATABASE) @@ -34,8 +36,9 @@ public static function setUpBeforeClass(): void ->withPortMapping(portOnHost: 0, portOnContainer: 3306) ->withGrantedHosts() ->withRootPassword(rootPassword: self::ROOT) - ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 60)) - ->runIfNotExists(); + ->withEphemeralStorage(); + + self::$mySQLStarted = self::$mySQL->runIfNotExists(); } public function testMySQLWhenStartedThenAcceptsConnectionsToItsDatabase(): void @@ -122,10 +125,9 @@ public function testCleanAndMigrateWhenMigrationIsSlowThenWaitsForFlywayToFinish /** @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) + ->withSource(container: self::$mySQL) ->withNetwork(name: 'tiny-blocks') - ->withMigrations(pathOnHost: sprintf($template, __DIR__)) - ->withConnectRetries(retries: 15); + ->withMigrations(pathOnHost: sprintf($template, __DIR__)); /** @When Flyway cleans the schema and runs the slow migration */ $flyway->cleanAndMigrate(); @@ -144,10 +146,9 @@ public function testCleanAndMigrateWhenMigrationsAreGivenThenDatabaseIsPopulated /** @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) + ->withSource(container: self::$mySQL) ->withNetwork(name: 'tiny-blocks') ->withMigrations(pathOnHost: sprintf($template, __DIR__)) - ->withConnectRetries(retries: 15) ->withMigrationNamingValidation(); /** @When Flyway cleans the schema and migrates it */ diff --git a/tests/Unit/DefinedMySQLContainer.php b/tests/Unit/DefinedMySQLContainer.php new file mode 100644 index 0000000..6b3c534 --- /dev/null +++ b/tests/Unit/DefinedMySQLContainer.php @@ -0,0 +1,39 @@ +withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: $hostname, + environment: [ + sprintf($databaseTemplate, $database), + 'MYSQL_ROOT_PASSWORD=root' + ] + ) + ); + + $client->withDockerExecuteResponse(output: 'mysqld is alive'); + $client->withDockerExecuteResponse(output: ''); + + return MySQLDockerContainer::from(image: 'mysql:8.4', client: $client) + ->withName(name: $hostname) + ->withDatabase(database: $database) + ->withRootPassword(rootPassword: 'root'); + } +} diff --git a/tests/Unit/FlywayContainerGatedTest.php b/tests/Unit/FlywayContainerGatedTest.php new file mode 100644 index 0000000..b5e2f70 --- /dev/null +++ b/tests/Unit/FlywayContainerGatedTest.php @@ -0,0 +1,223 @@ +client = new ClientMock(); + } + + protected function tearDown(): void + { + putenv('RUN_MIGRATIONS'); + } + + public function testMigrateWhenVariableIsNotSetThenNothingRuns(): void + { + /** @Given the variable that enables the migrations is not set */ + putenv('RUN_MIGRATIONS'); + + /** @And a Flyway container that runs only while the variable is enabled */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS'); + + /** @When migrate is called */ + $execution = $flyway->migrate(); + + /** @Then nothing should have run, and no Docker command should have been issued */ + self::assertSame([null, []], [$execution, $this->client->getExecutedArguments()]); + } + + public function testRepairWhenVariableIsDisabledThenNothingRuns(): void + { + /** @Given the variable that enables the migrations is set to 0 */ + putenv('RUN_MIGRATIONS=0'); + + /** @And a Flyway container that runs only while the variable is enabled */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS'); + + /** @When repair is called */ + $execution = $flyway->repair(); + + /** @Then nothing should have run, and no Docker command should have been issued */ + self::assertSame([null, []], [$execution, $this->client->getExecutedArguments()]); + } + + public function testValidateWhenVariableIsDisabledThenNothingRuns(): void + { + /** @Given the variable that enables the migrations is set to 0 */ + putenv('RUN_MIGRATIONS=0'); + + /** @And a Flyway container that runs only while the variable is enabled */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS'); + + /** @When validate is called */ + $execution = $flyway->validate(); + + /** @Then nothing should have run, and no Docker command should have been issued */ + self::assertSame([null, []], [$execution, $this->client->getExecutedArguments()]); + } + + public function testRepairWhenVariableIsEnabledThenRunsFlywayRepair(): void + { + /** @Given the variable that enables the migrations is set to 1 */ + putenv('RUN_MIGRATIONS=1'); + + /** @And a Flyway container that runs only while the variable is enabled */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS'); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And Flyway exits successfully */ + $this->client->withDockerAttachedStartResponse(output: 'Successfully repaired schema history table'); + + /** @When repair is called */ + $execution = $flyway->repair(); + + /** @Then Flyway should have run the repair command and returned its output */ + self::assertStringEndsWith('flyway/flyway:12-alpine repair', $this->client->getExecutedCommandLines()[0]); + self::assertSame('Successfully repaired schema history table', $execution?->getOutput()); + } + + public function testMigrateWhenVariableIsEnabledThenRunsFlywayMigrate(): void + { + /** @Given the variable that enables the migrations is set to 1 */ + putenv('RUN_MIGRATIONS=1'); + + /** @And a Flyway container that runs only while the variable is enabled */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS'); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And Flyway exits successfully */ + $this->client->withDockerAttachedStartResponse(output: 'Successfully applied 2 migrations'); + + /** @When migrate is called */ + $execution = $flyway->migrate(); + + /** @Then Flyway should have run the migrate command and returned its output */ + self::assertStringEndsWith('flyway/flyway:12-alpine migrate', $this->client->getExecutedCommandLines()[0]); + self::assertSame('Successfully applied 2 migrations', $execution?->getOutput()); + } + + public function testValidateWhenVariableIsEnabledThenRunsFlywayValidate(): void + { + /** @Given the variable that enables the migrations is set to 1 */ + putenv('RUN_MIGRATIONS=1'); + + /** @And a Flyway container that runs only while the variable is enabled */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS'); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And Flyway exits successfully */ + $this->client->withDockerAttachedStartResponse(output: 'Successfully validated 2 migrations'); + + /** @When validate is called */ + $execution = $flyway->validate(); + + /** @Then Flyway should have run the validate command and returned its output */ + self::assertStringEndsWith('flyway/flyway:12-alpine validate', $this->client->getExecutedCommandLines()[0]); + self::assertSame('Successfully validated 2 migrations', $execution?->getOutput()); + } + + public function testMigrateWhenVariableIsOneWithLeadingZeroThenNothingRuns(): void + { + /** @Given the variable that enables the migrations is set to 01, which is not 1 */ + putenv('RUN_MIGRATIONS=01'); + + /** @And a Flyway container that runs only while the variable is enabled */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS'); + + /** @When migrate is called */ + $execution = $flyway->migrate(); + + /** @Then nothing should have run, and no Docker command should have been issued */ + self::assertSame([null, []], [$execution, $this->client->getExecutedArguments()]); + } + + public function testCleanAndMigrateWhenVariableIsDisabledThenNeitherMySQLNorFlywayRuns(): void + { + /** @Given the variable that enables the migrations is set to 0 */ + putenv('RUN_MIGRATIONS=0'); + + /** @And a MySQL container definition */ + $mySQL = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client)->withName(name: 'skipped-db'); + + /** @And a Flyway container with the MySQL source that runs only while the variable is enabled */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withSource(container: $mySQL) + ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS'); + + /** @When cleanAndMigrate is called */ + $execution = $flyway->cleanAndMigrate(); + + /** @Then nothing should have run, and no Docker command should have been issued for MySQL or Flyway */ + self::assertSame([null, []], [$execution, $this->client->getExecutedArguments()]); + } + + public function testCleanAndMigrateWhenVariableIsEnabledThenRunsCleanFollowedByMigrate(): void + { + /** @Given the variable that enables the migrations is set to 1 */ + putenv('RUN_MIGRATIONS=1'); + + /** @And a Flyway container that runs only while the variable is enabled */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS'); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @And Flyway exits successfully */ + $this->client->withDockerAttachedStartResponse(output: 'Successfully applied 2 migrations'); + + /** @When cleanAndMigrate is called */ + $execution = $flyway->cleanAndMigrate(); + + /** @Then Flyway should have run the clean and migrate commands and returned its output */ + self::assertStringEndsWith( + 'flyway/flyway:12-alpine clean migrate', + $this->client->getExecutedCommandLines()[0] + ); + self::assertSame('Successfully applied 2 migrations', $execution?->getOutput()); + } + + public function testMigrateWhenVariableIsEnabledAfterTheDefinitionThenRunsFlywayMigrate(): void + { + /** @Given a Flyway container that runs only while the variable is enabled */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->onlyWhenEnabled(variable: 'RUN_MIGRATIONS'); + + /** @And the variable that enables the migrations is set to 1 afterwards */ + putenv('RUN_MIGRATIONS=1'); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @When migrate is called */ + $flyway->migrate(); + + /** @Then the variable should be read when the command is called, so Flyway should have run */ + self::assertStringEndsWith('flyway/flyway:12-alpine migrate', $this->client->getExecutedCommandLines()[0]); + } +} diff --git a/tests/Unit/FlywayDockerContainerTest.php b/tests/Unit/FlywayDockerContainerTest.php index b993687..f4da040 100644 --- a/tests/Unit/FlywayDockerContainerTest.php +++ b/tests/Unit/FlywayDockerContainerTest.php @@ -10,6 +10,7 @@ use TinyBlocks\DockerContainer\Exceptions\ContainerWaitTimeout; use TinyBlocks\DockerContainer\Exceptions\DockerCommandExecutionFailed; use TinyBlocks\DockerContainer\FlywayDockerContainer; +use TinyBlocks\DockerContainer\MySQLDockerContainer; use TinyBlocks\Time\Duration; final class FlywayDockerContainerTest extends TestCase @@ -59,17 +60,17 @@ public function testWithNetworkThenFlywayJoinsTheNetwork(): void public function testWithTableThenOverridesTheHistoryTable(): void { - /** @Given a running MySQL container */ - $mySQLStarted = RunningMySQLContainer::startWith( + /** @Given a MySQL container definition */ + $mySQL = DefinedMySQLContainer::definedWith( client: $this->client, database: 'test_db', hostname: 'custom-table-db' ); - /** @And a Flyway container with the MySQL source and a table override */ + /** @And a Flyway container with a table override and the MySQL source */ $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) - ->withSource(password: 'root', username: 'root', container: $mySQLStarted) - ->withTable(table: 'flyway_history'); + ->withTable(table: 'flyway_history') + ->withSource(container: $mySQL); /** @And the Docker daemon creates the Flyway container */ $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); @@ -77,11 +78,10 @@ public function testWithTableThenOverridesTheHistoryTable(): void /** @When migrate is called */ $flyway->migrate(); - /** @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()) - ); + /** @Then the overridden table should replace the default one */ + $commandLines = implode(PHP_EOL, $this->client->getExecutedCommandLines()); + self::assertStringContainsString('FLYWAY_TABLE=flyway_history', $commandLines); + self::assertStringNotContainsString('FLYWAY_TABLE=schema_history', $commandLines); } public function testRepairThenRunsFlywayRepairToCompletion(): void @@ -130,17 +130,17 @@ public function testMigrateThenRunsFlywayMigrateToCompletion(): void public function testWithSchemaThenOverridesTheDetectedSchema(): void { - /** @Given a running MySQL container with database "original" */ - $mySQLStarted = RunningMySQLContainer::startWith( + /** @Given a MySQL container definition with database "original" */ + $mySQL = DefinedMySQLContainer::definedWith( client: $this->client, database: 'original', hostname: 'override-db' ); - /** @And a Flyway container with the MySQL source and a schema override */ + /** @And a Flyway container with a schema override and the MySQL source */ $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) - ->withSource(password: 'root', username: 'root', container: $mySQLStarted) - ->withSchema(schema: 'custom_schema'); + ->withSchema(schema: 'custom_schema') + ->withSource(container: $mySQL); /** @And the Docker daemon creates the Flyway container */ $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); @@ -148,17 +148,16 @@ public function testWithSchemaThenOverridesTheDetectedSchema(): void /** @When migrate is called */ $flyway->migrate(); - /** @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()) - ); + /** @Then the overridden schema should replace the detected one */ + $commandLines = implode(PHP_EOL, $this->client->getExecutedCommandLines()); + self::assertStringContainsString('FLYWAY_SCHEMAS=custom_schema', $commandLines); + self::assertStringNotContainsString('FLYWAY_SCHEMAS=original', $commandLines); } public function testWithSourceThenSetsTheDefaultHistoryTable(): void { - /** @Given a running MySQL container */ - $mySQLStarted = RunningMySQLContainer::startWith( + /** @Given a MySQL container definition */ + $mySQL = DefinedMySQLContainer::definedWith( client: $this->client, database: 'test_db', hostname: 'table-db' @@ -166,7 +165,7 @@ public function testWithSourceThenSetsTheDefaultHistoryTable(): void /** @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); + ->withSource(container: $mySQL); /** @And the Docker daemon creates the Flyway container */ $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); @@ -219,32 +218,6 @@ public function testWithConnectRetriesThenSetsTheNumberOfRetries(): void self::assertStringContainsString('FLYWAY_CONNECT_RETRIES=10', $this->client->getExecutedCommandLines()[0]); } - public function testWithSourceThenConfiguresJdbcUrlAndCredentials(): void - { - /** @Given a running MySQL container */ - $mySQLStarted = RunningMySQLContainer::startWith( - client: $this->client, - database: 'app_database', - hostname: 'source-db' - ); - - /** @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 creates the Flyway container */ - $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); - - /** @When migrate is called */ - $flyway->migrate(); - - /** @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 testWithMigrationsThenCopiesThemBeforeFlywayStarts(): void { /** @Given a Flyway container with migrations */ @@ -286,18 +259,19 @@ public function testMigrateWhenFlywaySucceedsThenReturnsFlywayOutput(): void self::assertSame('Successfully applied 2 migrations', $execution->getOutput()); } - public function testWithSourceThenWaitsForMySQLBeforeFlywayIsCreated(): void + public function testWithCredentialsThenOverridesTheSourceCredentials(): void { - /** @Given a running MySQL container, pinged once while it started */ - $mySQLStarted = RunningMySQLContainer::startWith( + /** @Given a MySQL container definition */ + $mySQL = DefinedMySQLContainer::definedWith( client: $this->client, database: 'app_database', - hostname: 'ready-db' + hostname: 'credentials-db' ); - /** @And a Flyway container with the MySQL source */ + /** @And a Flyway container with the MySQL source and other credentials */ $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) - ->withSource(password: 'root', username: 'root', container: $mySQLStarted); + ->withSource(container: $mySQL) + ->withCredentials(password: 'secret', username: 'admin'); /** @And the Docker daemon creates the Flyway container */ $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); @@ -305,14 +279,11 @@ public function testWithSourceThenWaitsForMySQLBeforeFlywayIsCreated(): void /** @When migrate is called */ $flyway->migrate(); - /** @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)) - ] - ); + /** @Then the overriding credentials should replace the root ones */ + $commandLines = implode(PHP_EOL, $this->client->getExecutedCommandLines()); + self::assertStringContainsString('FLYWAY_USER=admin', $commandLines); + self::assertStringContainsString('FLYWAY_PASSWORD=secret', $commandLines); + self::assertStringNotContainsString('FLYWAY_USER=root', $commandLines); } public function testMigrateWhenTimeoutIsGivenThenWaitsUpToThatTimeout(): void @@ -350,6 +321,47 @@ public function testWithEnvironmentVariableThenPassesTheFlywaySetting(): void ); } + public function testWithSourceThenConfiguresJdbcUrlAndRootCredentials(): void + { + /** @Given a MySQL container definition */ + $mySQL = DefinedMySQLContainer::definedWith( + client: $this->client, + database: 'app_database', + hostname: 'source-db' + ); + + /** @And a Flyway container with the MySQL source */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withSource(container: $mySQL); + + /** @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 carry the JDBC URL and the root credentials of the MySQL container */ + $commandLines = implode(PHP_EOL, $this->client->getExecutedCommandLines()); + self::assertStringContainsString('FLYWAY_URL=jdbc:mysql://source-db:3306/app_database', $commandLines); + self::assertStringContainsString('FLYWAY_USER=root', $commandLines); + self::assertStringContainsString('FLYWAY_PASSWORD=root', $commandLines); + } + + public function testWithSourceThenMySQLIsNotStartedBeforeACommandRuns(): void + { + /** @Given a MySQL container definition */ + $mySQL = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client)->withName(name: 'lazy-db'); + + /** @And a Flyway container */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client); + + /** @When the MySQL container is set as the Flyway source */ + $flyway->withSource(container: $mySQL); + + /** @Then no Docker command should have been issued */ + self::assertSame([], $this->client->getExecutedArguments()); + } + public function testMigrateWhenFlywayFailsThenFlywayContainerIsRemoved(): void { /** @Given a Flyway container */ @@ -375,8 +387,8 @@ public function testMigrateWhenFlywayFailsThenFlywayContainerIsRemoved(): void public function testWithSourceThenDetectsTheSchemaFromTheMySQLDatabase(): void { - /** @Given a running MySQL container with database "products" */ - $mySQLStarted = RunningMySQLContainer::startWith( + /** @Given a MySQL container definition with database "products" */ + $mySQL = DefinedMySQLContainer::definedWith( client: $this->client, database: 'products', hostname: 'schema-db' @@ -384,7 +396,7 @@ public function testWithSourceThenDetectsTheSchemaFromTheMySQLDatabase(): void /** @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); + ->withSource(container: $mySQL); /** @And the Docker daemon creates the Flyway container */ $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); @@ -451,6 +463,35 @@ public function testWithMigrationNamingValidationThenEnablesTheValidation(): voi ); } + public function testWithSourceThenFlywayIsCreatedWithoutPingingMySQLAgain(): void + { + /** @Given a MySQL container definition */ + $mySQL = DefinedMySQLContainer::definedWith( + client: $this->client, + database: 'app_database', + hostname: 'ready-db' + ); + + /** @And a Flyway container with the MySQL source */ + $flyway = FlywayDockerContainer::from(image: 'flyway/flyway:12-alpine', client: $this->client) + ->withSource(container: $mySQL); + + /** @And the Docker daemon creates the Flyway container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @When migrate is called */ + $flyway->migrate(); + + /** @Then MySQL should be pinged once while it starts, with no second ping before Flyway is created */ + self::assertCount( + 1, + array_filter( + $this->client->getExecutedCommandLines(), + static fn(string $commandLine): bool => str_contains($commandLine, 'mysqladmin ping') + ) + ); + } + public function testMigrateThenFlywayContainerIsNotRemovedByDockerOnItsOwn(): void { /** @Given a Flyway container */ @@ -502,7 +543,7 @@ public function testMigrateWhenFlywayOutlastsTimeoutThenContainerWaitTimeout(): /** @And the attached run is interrupted when the timeout is reached */ $this->client->withDockerAttachedStartException( - exception: new DockerCommandExecutionFailed( + exception: DockerCommandExecutionFailed::from( reason: 'The process exceeded the timeout of 45 seconds.', command: 'docker start --attach 6acae5967be0' ) @@ -544,7 +585,7 @@ public function testMigrateWhenFlywayOutlastsTimeoutThenFlywayContainerIsRemoved /** @And the attached run is interrupted when the timeout is reached */ $this->client->withDockerAttachedStartException( - exception: new DockerCommandExecutionFailed(reason: 'timeout', command: 'docker start') + exception: DockerCommandExecutionFailed::from(reason: 'timeout', command: 'docker start') ); try { diff --git a/tests/Unit/GenericDockerContainerTest.php b/tests/Unit/GenericDockerContainerTest.php index 60e7a8d..b2783b9 100644 --- a/tests/Unit/GenericDockerContainerTest.php +++ b/tests/Unit/GenericDockerContainerTest.php @@ -429,6 +429,28 @@ public function testRunCommandLineIncludesPortMapping(): void self::assertStringContainsString('--publish 8080:80', $runCommand); } + public function testRunCommandLineIncludesTmpfsMounts(): void + { + /** @Given a container with two tmpfs mounts */ + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withName(name: 'tmpfs-cmd') + ->withTmpfsMount(pathOnContainer: '/app/cache') + ->withTmpfsMount(pathOnContainer: '/app/sessions'); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'tmpfs-cmd')); + + /** @When the container is started */ + $container->run(); + + /** @Then the docker create command should mount both directories in memory */ + self::assertStringContainsString( + '--tmpfs /app/cache --tmpfs /app/sessions', + $this->client->getExecutedCommandLines()[0] + ); + } + public function testRunContainerWithAutoGeneratedName(): void { /** @Given a container without a name */ @@ -1866,4 +1888,24 @@ public function testStopContainerWhenCustomTimeoutThenCommandCarriesTimeoutAsStr $stopArguments ); } + + public function testRunToCompletionWhenWaitBeforeRunIsRegisteredThenWaitsBeforeCreatingTheContainer(): void + { + /** @Given a condition that is immediately ready */ + $condition = $this->createMock(ContainerReady::class); + $condition->expects(self::once())->method('isReady')->willReturn(true); + + /** @And a container with a wait-before-run condition */ + $container = GenericDockerContainer::from(image: 'alpine:latest', client: $this->client) + ->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); + + /** @And the Docker daemon creates the container */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + + /** @When the container runs to completion */ + $container->runToCompletion(timeout: Duration::fromSeconds(seconds: 10)); + + /** @Then the container should be created once the wait is satisfied */ + self::assertStringStartsWith('docker create', $this->client->getExecutedCommandLines()[0]); + } } diff --git a/tests/Unit/MySQLDockerContainerTest.php b/tests/Unit/MySQLDockerContainerTest.php index ef21e9b..f1ccb45 100644 --- a/tests/Unit/MySQLDockerContainerTest.php +++ b/tests/Unit/MySQLDockerContainerTest.php @@ -738,6 +738,33 @@ public function testRunIfNotExistsWhenContainerExistsThenStartedWasReused(): voi self::assertTrue($started->wasReused()); } + public function testRunWhenEphemeralStorageThenDataDirectoryIsATmpfsMount(): void + { + /** @Given a MySQL container with ephemeral storage */ + $container = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'ephemeral-db') + ->withRootPassword(rootPassword: 'root') + ->withEphemeralStorage(); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'ephemeral-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And the MySQL readiness check succeeds */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @When the MySQL container is started */ + $container->run(); + + /** @Then the MySQL data directory should be a tmpfs mount */ + self::assertStringContainsString('--tmpfs /var/lib/mysql', $this->client->getExecutedCommandLines()[0]); + } + public function testExceptionWhenMySQLReadinessCheckAlwaysThrowsExceptions(): void { /** @Given a MySQL container with a very short readiness timeout */ @@ -758,7 +785,7 @@ public function testExceptionWhenMySQLReadinessCheckAlwaysThrowsExceptions(): vo /** @And the MySQL readiness check always throws exceptions */ for ($index = 0; $index < 100; $index++) { $this->client->withDockerExecuteException( - exception: new DockerCommandExecutionFailed(reason: 'container crashed', command: 'docker exec') + exception: DockerCommandExecutionFailed::from(reason: 'container crashed', command: 'docker exec') ); } @@ -802,6 +829,36 @@ public function testRunMySQLContainerRetriesReadinessCheckBeforeSucceeding(): vo self::assertSame('retry-db', $started->getName()); } + public function testRunWhenEphemeralStorageThenServerStartsWithoutBinaryLog(): void + { + /** @Given a MySQL container with ephemeral storage */ + $container = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'fast-db') + ->withRootPassword(rootPassword: 'root') + ->withEphemeralStorage(); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'fast-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And the MySQL readiness check succeeds */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @When the MySQL container is started */ + $container->run(); + + /** @Then the MySQL server should start without the binary log */ + self::assertSame( + ['mysql:8.4', '--skip-log-bin'], + array_slice($this->client->getExecutedArguments()[0], -2) + ); + } + public function testWithGrantedHostsThenLeavesTheOriginalDefinitionUntouched(): void { /** @Given a MySQL container without granted hosts */ @@ -850,7 +907,7 @@ public function testRunMySQLContainerRetriesWhenReadinessCheckThrowsException(): /** @And the MySQL readiness check throws an exception first, then succeeds */ $this->client->withDockerExecuteException( - exception: new DockerCommandExecutionFailed(reason: 'container not running', command: 'docker exec') + exception: DockerCommandExecutionFailed::from(reason: 'container not running', command: 'docker exec') ); $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); @@ -1001,6 +1058,37 @@ public function testStopWhenMySQLContainerIsSharedThenStopsTheUnderlyingContaine ); } + public function testRunWhenGrantedHostsAndEphemeralStorageThenGrantsAreStillApplied(): void + { + /** @Given a MySQL container with granted hosts, then ephemeral storage */ + $container = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'grants-kept-db') + ->withGrantedHosts(hosts: ['10.%']) + ->withRootPassword(rootPassword: 'root') + ->withEphemeralStorage(); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'grants-kept-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And the MySQL readiness check succeeds */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @When the MySQL container is started */ + $container->run(); + + /** @Then the granted host should still be created */ + self::assertStringContainsString( + "CREATE USER IF NOT EXISTS 'root'@'10.%'", + array_last($this->client->getExecutedCommandLines()) + ); + } + public function testRunIfNotExistsWhenMySQLNeverBecomesReadyThenContainerWaitTimeout(): void { /** @Given a MySQL container with a readiness timeout of one second */ @@ -1036,6 +1124,133 @@ public function testRunIfNotExistsWhenMySQLNeverBecomesReadyThenContainerWaitTim $container->runIfNotExists(); } + public function testRunIfNotExistsWhenEphemeralStorageThenServerStartsWithoutBinaryLog(): void + { + /** @Given a MySQL container with ephemeral storage that does not exist yet */ + $container = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'shared-fast-db') + ->withRootPassword(rootPassword: 'root') + ->withEphemeralStorage(); + + /** @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->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'shared-fast-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And the MySQL readiness check succeeds */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @When runIfNotExists is called */ + $container->runIfNotExists(); + + /** @Then the MySQL server should start without the binary log */ + self::assertSame( + ['mysql:8.4', '--skip-log-bin'], + array_slice($this->client->getExecutedArguments()[1], -2) + ); + } + + public function testRunWhenEphemeralStorageAndGrantedHostsThenServerStillSkipsBinaryLog(): void + { + /** @Given a MySQL container with ephemeral storage, then granted hosts and a readiness timeout */ + $ephemeral = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'granted-fast-db') + ->withRootPassword(rootPassword: 'root') + ->withEphemeralStorage(); + $container = $ephemeral + ->withGrantedHosts(hosts: ['%']) + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 5)); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'granted-fast-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And the MySQL readiness check and the grant statements succeed */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @When the MySQL container is started */ + $container->run(); + + /** @Then the MySQL server should still start without the binary log */ + self::assertSame( + ['mysql:8.4', '--skip-log-bin'], + array_slice($this->client->getExecutedArguments()[0], -2) + ); + } + + public function testRunWhenReadinessTimeoutAndEphemeralStorageThenTimeoutIsStillApplied(): void + { + /** @Given a MySQL container with a short readiness timeout, then ephemeral storage */ + $timed = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'timeout-kept-db') + ->withRootPassword(rootPassword: 'root') + ->withReadinessTimeout(timeout: Duration::fromSeconds(seconds: 1)); + $container = $timed->withEphemeralStorage(); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'timeout-kept-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And the MySQL readiness check always fails */ + for ($index = 0; $index < 4; $index++) { + $this->client->withDockerExecuteResponse(output: 'mysqld is not ready', isSuccessful: false); + } + + /** @Then a ContainerWaitTimeout exception should be thrown after the short timeout */ + $this->expectException(ContainerWaitTimeout::class); + $this->expectExceptionMessage('Container readiness check timed out after <1> seconds.'); + + /** @When the MySQL container is started */ + $container->run(); + } + + public function testRunWhenEphemeralStorageAndCommandsThenServerArgumentsFollowTheCommands(): void + { + /** @Given a MySQL container with ephemeral storage */ + $container = MySQLDockerContainer::from(image: 'mysql:8.4', client: $this->client) + ->withName(name: 'command-db') + ->withRootPassword(rootPassword: 'root') + ->withEphemeralStorage(); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerCreateResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'command-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And the MySQL readiness check succeeds */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @When the MySQL container is started with an explicit server command */ + $container->run(commands: ['mysqld', '--character-set-server=utf8mb4']); + + /** @Then the explicit command should come first and the ephemeral server arguments after it */ + self::assertSame( + ['mysql:8.4', 'mysqld', '--character-set-server=utf8mb4', '--skip-log-bin'], + array_slice($this->client->getExecutedArguments()[0], -4) + ); + } + public function testGetAddressWhenMySQLContainerIsSharedThenIsTheAddressOfTheExistingContainer(): void { /** @Given a MySQL container reused from another process */