Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 112 additions & 90 deletions README.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/Exceptions/DockerCommandExecutionFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
92 changes: 92 additions & 0 deletions src/FlywayContainerGated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\DockerContainer;

use TinyBlocks\DockerContainer\Exceptions\ContainerExitUnsuccessful;
use TinyBlocks\DockerContainer\Exceptions\ContainerWaitTimeout;
use TinyBlocks\DockerContainer\Exceptions\DockerCommandExecutionFailed;
use TinyBlocks\DockerContainer\Internal\Containers\Environment\EnvironmentFlag;

/**
* Flyway container gated by an environment variable, whose commands run only while the variable is enabled.
*
* <p>The variable is enabled when its value is <code>1</code>, and it is read each time a command is called. While
* it is not enabled, a command skips the run entirely and returns <code>null</code>, so no Docker command is issued,
* neither for Flyway nor for its MySQL source.</p>
*/
final readonly class FlywayContainerGated
{
private function __construct(private EnvironmentFlag $flag, private FlywayDockerContainer $flyway)
{
}

/**
* Creates a FlywayContainerGated from a Flyway definition and the variable that enables it.
*
* @param FlywayDockerContainer $flyway The Flyway definition each command runs.
* @param string $variable The name of the environment variable that enables the commands.
* @return FlywayContainerGated The created gated container.
*/
public static function from(FlywayDockerContainer $flyway, string $variable): FlywayContainerGated
{
return new FlywayContainerGated(flag: EnvironmentFlag::from(variable: $variable), flyway: $flyway);
}

/**
* Runs the Flyway repair command when the variable is enabled, and blocks until Flyway exits.
*
* @return ExecutionCompleted|null The successful outcome, or null when the variable is not enabled and nothing
* ran.
* @throws DockerCommandExecutionFailed If a Docker command fails.
* @throws ContainerWaitTimeout If the MySQL source is not ready or Flyway does not exit within the timeout.
* @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, carrying the Flyway error output.
*/
public function repair(): ?ExecutionCompleted
{
return $this->flag->isEnabled() ? $this->flyway->repair() : null;
}

/**
* Runs the Flyway migrate command when the variable is enabled, and blocks until Flyway exits.
*
* @return ExecutionCompleted|null The successful outcome, or null when the variable is not enabled and nothing
* ran.
* @throws DockerCommandExecutionFailed If a Docker command fails.
* @throws ContainerWaitTimeout If the MySQL source is not ready or Flyway does not exit within the timeout.
* @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, carrying the Flyway error output.
*/
public function migrate(): ?ExecutionCompleted
{
return $this->flag->isEnabled() ? $this->flyway->migrate() : null;
}

/**
* Runs the Flyway validate command when the variable is enabled, and blocks until Flyway exits.
*
* @return ExecutionCompleted|null The successful outcome, or null when the variable is not enabled and nothing
* ran.
* @throws DockerCommandExecutionFailed If a Docker command fails.
* @throws ContainerWaitTimeout If the MySQL source is not ready or Flyway does not exit within the timeout.
* @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, including a failed validation.
*/
public function validate(): ?ExecutionCompleted
{
return $this->flag->isEnabled() ? $this->flyway->validate() : null;
}

/**
* Runs the Flyway clean command followed by migrate when the variable is enabled, and blocks until Flyway exits.
*
* @return ExecutionCompleted|null The successful outcome, or null when the variable is not enabled and nothing
* ran.
* @throws DockerCommandExecutionFailed If a Docker command fails.
* @throws ContainerWaitTimeout If the MySQL source is not ready or Flyway does not exit within the timeout.
* @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, carrying the Flyway error output.
*/
public function cleanAndMigrate(): ?ExecutionCompleted
{
return $this->flag->isEnabled() ? $this->flyway->cleanAndMigrate() : null;
}
}
126 changes: 80 additions & 46 deletions src/FlywayDockerContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
use TinyBlocks\DockerContainer\Exceptions\DockerCommandExecutionFailed;
use TinyBlocks\DockerContainer\Exceptions\ImageNameEmpty;
use TinyBlocks\DockerContainer\Internal\Client\CommandLineDockerClient;
use TinyBlocks\DockerContainer\MySQL\MySQLContainerStarted;
use TinyBlocks\DockerContainer\Waits\Conditions\MySQL\MySQLReady;
use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency;
use TinyBlocks\DockerContainer\Internal\Containers\Drivers\Flyway\FlywaySettings;
use TinyBlocks\Time\Duration;

/**
Expand All @@ -26,8 +24,11 @@
private const string MIGRATIONS_ON_CONTAINER = '/flyway/migrations';
private const int DEFAULT_TIMEOUT_IN_SECONDS = 120;

private function __construct(private Duration $timeout, private GenericDockerContainer $container)
{
private function __construct(
private Duration $timeout,
private FlywaySettings $settings,
private GenericDockerContainer $container
) {
}

/**
Expand All @@ -45,6 +46,7 @@ public static function from(
): FlywayDockerContainer {
return new FlywayDockerContainer(
timeout: Duration::fromSeconds(seconds: self::DEFAULT_TIMEOUT_IN_SECONDS),
settings: FlywaySettings::create(),
container: GenericDockerContainer::from(image: $image, client: $client)
);
}
Expand All @@ -53,39 +55,45 @@ public static function from(
* Runs the Flyway repair command and blocks until Flyway exits.
*
* @return ExecutionCompleted The successful outcome, carrying the Flyway output.
* @throws DockerCommandExecutionFailed If the Flyway container cannot be created.
* @throws ContainerWaitTimeout If the database source is not ready, or Flyway does not exit within the timeout.
* @throws DockerCommandExecutionFailed If a Docker command fails.
* @throws ContainerWaitTimeout If the MySQL source is not ready or Flyway does not exit within the timeout.
* @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, carrying the Flyway error output.
*/
public function repair(): ExecutionCompleted
{
return $this->container->runToCompletion(timeout: $this->timeout, commands: ['repair']);
return $this->settings
->applyTo(container: $this->container)
->runToCompletion(timeout: $this->timeout, commands: ['repair']);
}

/**
* Runs the Flyway migrate command and blocks until Flyway exits.
*
* @return ExecutionCompleted The successful outcome, carrying the Flyway output.
* @throws DockerCommandExecutionFailed If the Flyway container cannot be created.
* @throws ContainerWaitTimeout If the database source is not ready, or Flyway does not exit within the timeout.
* @throws DockerCommandExecutionFailed If a Docker command fails.
* @throws ContainerWaitTimeout If the MySQL source is not ready or Flyway does not exit within the timeout.
* @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, carrying the Flyway error output.
*/
public function migrate(): ExecutionCompleted
{
return $this->container->runToCompletion(timeout: $this->timeout, commands: ['migrate']);
return $this->settings
->applyTo(container: $this->container)
->runToCompletion(timeout: $this->timeout, commands: ['migrate']);
}

/**
* Runs the Flyway validate command and blocks until Flyway exits.
*
* @return ExecutionCompleted The successful outcome, carrying the Flyway output.
* @throws DockerCommandExecutionFailed If the Flyway container cannot be created.
* @throws ContainerWaitTimeout If the database source is not ready, or Flyway does not exit within the timeout.
* @throws DockerCommandExecutionFailed If a Docker command fails.
* @throws ContainerWaitTimeout If the MySQL source is not ready or Flyway does not exit within the timeout.
* @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, including a failed validation.
*/
public function validate(): ExecutionCompleted
{
return $this->container->runToCompletion(timeout: $this->timeout, commands: ['validate']);
return $this->settings
->applyTo(container: $this->container)
->runToCompletion(timeout: $this->timeout, commands: ['validate']);
}

/**
Expand All @@ -111,34 +119,24 @@ public function withSchema(string $schema): FlywayDockerContainer
}

/**
* Returns a copy of the FlywayDockerContainer that migrates the database of a started MySQL container.
* Returns a copy of the FlywayDockerContainer that migrates the database of a MySQL container.
*
* <p>Configures the JDBC URL, the credentials, the target schema (from <code>MYSQL_DATABASE</code>) and the
* history table (<code>schema_history</code>), and waits until MySQL accepts connections before each command.
* Call withSchema() or withTable() after this method to override the defaults.</p>
* <p>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 <code>MYSQL_ROOT_PASSWORD</code>), the target schema (from
* <code>MYSQL_DATABASE</code>) and the history table (<code>schema_history</code>). withCredentials(),
* withSchema() and withTable() override these defaults, whether they are called before or after this method.</p>
*
* @param string $password The database password.
* @param string $username The database username.
* @param MySQLContainerStarted $container The started MySQL container.
* @param MySQLDockerContainer $container The MySQL container definition holding the database to migrate.
* @return FlywayDockerContainer The copy bound to the MySQL container.
*/
public function withSource(
string $password,
string $username,
MySQLContainerStarted $container
): FlywayDockerContainer {
$schema = $container->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE');
$mySQLReady = ContainerWaitForDependency::untilReady(condition: MySQLReady::from(container: $container));

public function withSource(MySQLDockerContainer $container): FlywayDockerContainer
{
return new FlywayDockerContainer(
timeout: $this->timeout,
settings: $this->settings->withSource(source: $container),
container: $this->container
->withWaitBeforeRun(wait: $mySQLReady)
->withEnvironmentVariable(key: 'FLYWAY_URL', value: $container->getJdbcUrl())
->withEnvironmentVariable(key: 'FLYWAY_USER', value: $username)
->withEnvironmentVariable(key: 'FLYWAY_TABLE', value: 'schema_history')
->withEnvironmentVariable(key: 'FLYWAY_SCHEMAS', value: $schema)
->withEnvironmentVariable(key: 'FLYWAY_PASSWORD', value: $password)
);
}

Expand All @@ -152,6 +150,7 @@ public function withNetwork(string $name): FlywayDockerContainer
{
return new FlywayDockerContainer(
timeout: $this->timeout,
settings: $this->settings,
container: $this->container->withNetwork(name: $name)
);
}
Expand All @@ -166,7 +165,7 @@ public function withNetwork(string $name): FlywayDockerContainer
*/
public function withTimeout(Duration $timeout): FlywayDockerContainer
{
return new FlywayDockerContainer(timeout: $timeout, container: $this->container);
return new FlywayDockerContainer(timeout: $timeout, settings: $this->settings, container: $this->container);
}

/**
Expand All @@ -183,12 +182,14 @@ public function withMigrations(string $pathOnHost): FlywayDockerContainer

return new FlywayDockerContainer(
timeout: $this->timeout,
container: $this->container
->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: self::MIGRATIONS_ON_CONTAINER)
->withEnvironmentVariable(
key: 'FLYWAY_LOCATIONS',
value: sprintf($template, self::MIGRATIONS_ON_CONTAINER)
)
settings: $this->settings->with(
key: 'FLYWAY_LOCATIONS',
value: sprintf($template, self::MIGRATIONS_ON_CONTAINER)
),
container: $this->container->copyToContainer(
pathOnHost: $pathOnHost,
pathOnContainer: self::MIGRATIONS_ON_CONTAINER
)
);
}

Expand All @@ -199,17 +200,49 @@ public function withMigrations(string $pathOnHost): FlywayDockerContainer
* applied when this method returns.</p>
*
* @return ExecutionCompleted The successful outcome, carrying the Flyway output.
* @throws DockerCommandExecutionFailed If the Flyway container cannot be created.
* @throws ContainerWaitTimeout If the database source is not ready, or Flyway does not exit within the timeout.
* @throws DockerCommandExecutionFailed If a Docker command fails.
* @throws ContainerWaitTimeout If the MySQL source is not ready or Flyway does not exit within the timeout.
* @throws ContainerExitUnsuccessful If Flyway exits with a non-zero status, carrying the Flyway error output.
*/
public function cleanAndMigrate(): ExecutionCompleted
{
return $this->container
return $this->settings
->applyTo(container: $this->container)
->withEnvironmentVariable(key: 'FLYWAY_CLEAN_DISABLED', value: 'false')
->runToCompletion(timeout: $this->timeout, commands: ['clean', 'migrate']);
}

/**
* Returns a gated view of the FlywayDockerContainer whose commands run only while an environment
* variable is enabled.
*
* <p>The variable is enabled when its value is <code>1</code>, and it is read each time a command is called.
* While it is not enabled, a command returns <code>null</code> 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.</p>
*
* @param string $variable The name of the environment variable that enables the commands.
* @return FlywayContainerGated The gated view of this definition.
*/
public function onlyWhenEnabled(string $variable): FlywayContainerGated
{
return FlywayContainerGated::from(flyway: $this, variable: $variable);
}

/**
* Returns a copy of the FlywayDockerContainer with the database credentials replaced.
*
* @param string $password The database password.
* @param string $username The database username.
* @return FlywayDockerContainer The copy with the new credentials.
*/
public function withCredentials(string $password, string $username): FlywayDockerContainer
{
return $this
->withEnvironmentVariable(key: 'FLYWAY_USER', value: $username)
->withEnvironmentVariable(key: 'FLYWAY_PASSWORD', value: $password);
}

/**
* Returns a copy of the FlywayDockerContainer with the number of database connection retries replaced.
*
Expand All @@ -234,7 +267,8 @@ public function withEnvironmentVariable(string $key, string $value): FlywayDocke
{
return new FlywayDockerContainer(
timeout: $this->timeout,
container: $this->container->withEnvironmentVariable(key: $key, value: $value)
settings: $this->settings->with(key: $key, value: $value),
container: $this->container
);
}

Expand Down
Loading