diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..22aac70
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,14 @@
+/tests export-ignore
+/vendor export-ignore
+
+/LICENSE export-ignore
+/Makefile export-ignore
+/README.md export-ignore
+/phpmd.xml export-ignore
+/phpunit.xml export-ignore
+/phpstan.neon.dist export-ignore
+/infection.json.dist export-ignore
+
+/.github export-ignore
+/.gitignore export-ignore
+/.gitattributes export-ignore
diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml
new file mode 100644
index 0000000..6a9bba4
--- /dev/null
+++ b/.github/workflows/auto-assign.yml
@@ -0,0 +1,22 @@
+name: Auto assign issues
+
+on:
+ issues:
+ types:
+ - opened
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ pull-requests: write
+ steps:
+ - name: Assign issues
+ uses: gustavofreze/auto-assign@1.0.0
+ with:
+ assignees: '${{ secrets.ASSIGNEES }}'
+ github_token: '${{ secrets.GITHUB_TOKEN }}'
+ allow_self_assign: 'true'
+ allow_no_assignees: 'true'
+ assignment_options: 'ISSUE'
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..bc94060
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,62 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+env:
+ PHP_VERSION: '8.3'
+
+jobs:
+ auto-review:
+ name: Auto review
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+
+ - name: Install dependencies
+ run: composer update --no-progress --optimize-autoloader
+
+ - name: Run review
+ run: composer review
+
+ tests:
+ name: Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+
+ - name: Install dependencies
+ run: composer update --no-progress --optimize-autoloader
+
+ - name: Clean up Docker
+ run: docker system prune -f
+
+ - name: Create Docker network
+ run: docker network create tiny-blocks
+
+ - name: Create Docker volume for migrations
+ run: docker volume create migrations
+
+ - name: Run tests
+ run: |
+ docker run --network=tiny-blocks \
+ -v ${PWD}:/app \
+ -v ${PWD}/tests/Integration/Database/Migrations:/migrations \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -w /app \
+ gustavofreze/php:${{ env.PHP_VERSION }} bash -c "composer tests"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3333ef2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.idea
+
+/vendor/
+/report
+*.lock
+.phpunit.*
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3f4732c
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,31 @@
+DOCKER_RUN = docker run -u root --rm -it --network=tiny-blocks --name test-lib -v ${PWD}:/app -v ${PWD}/tests/Integration/Database/Migrations:/migrations -v /var/run/docker.sock:/var/run/docker.sock -w /app gustavofreze/php:8.3
+
+.PHONY: configure test unit-test test-no-coverage create-volume create-network review show-reports clean
+
+configure:
+ @${DOCKER_RUN} composer update --optimize-autoloader
+
+test: create-volume
+ @${DOCKER_RUN} composer tests
+
+unit-test:
+ @${DOCKER_RUN} composer run unit-test
+
+test-no-coverage: create-volume
+ @${DOCKER_RUN} composer tests-no-coverage
+
+create-network:
+ @docker network create tiny-blocks
+
+create-volume:
+ @docker volume create migrations
+
+review:
+ @${DOCKER_RUN} composer review
+
+show-reports:
+ @sensible-browser report/coverage/coverage-html/index.html
+
+clean:
+ @sudo chown -R ${USER}:${USER} ${PWD}
+ @rm -rf report vendor .phpunit.cache
diff --git a/README.md b/README.md
index 5d95d54..2f090cc 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,233 @@
-# docker-container
-Manage Docker containers programmatically, simplifying the creation, running, and interaction with containers.
+# Docker container
+
+[](LICENSE)
+
+* [Overview](#overview)
+* [Installation](#installation)
+* [How to use](#how-to-use)
+ * [Creating a container](#creating-a-container)
+ * [Running a container](#running-a-container)
+ * [Running a container if it doesn't exist](#running-a-container-if-it-doesnt-exist)
+ * [Setting network](#setting-network)
+ * [Setting port mappings](#setting-port-mappings)
+ * [Setting volumes mappings](#setting-volumes-mappings)
+ * [Setting environment variables](#setting-environment-variables)
+ * [Disabling auto-remove](#disabling-auto-remove)
+ * [Copying files to a container](#copying-files-to-a-container)
+ * [Waiting for a condition](#waiting-for-a-condition)
+* [Usage examples](#usage-examples)
+* [License](#license)
+* [Contributing](#contributing)
+
+
+
+## Overview
+
+The `DockerContainer` library provides an interface and implementations to manage Docker containers programmatically.
+It simplifies the creation, execution, and interaction with containers, such as adding network configurations, mapping
+ports, setting environment variables, and executing commands inside containers.
+Designed specifically to support **unit tests** and **integration tests**, the library enables developers to simulate
+and manage containerized environments with minimal effort, ensuring a seamless testing workflow.
+
+
+
+## Installation
+
+```bash
+composer require tiny-blocks/docker-container
+```
+
+
+
+## How to use
+
+### Creating a container
+
+Creates a container from a specified image and optionally a name.
+The `from` method can be used to initialize a new container instance with an image and an optional name for
+identification.
+
+```php
+$container = GenericDockerContainer::from(image: 'php:8.3-fpm', name: 'my-container');
+```
+
+### Running a container
+
+The `run` method starts a container.
+Optionally, it allows you to execute commands within the container after it has started and define a condition to wait
+for using a `ContainerWaitAfterStarted` instance.
+
+**Example with no commands or conditions:**
+
+```php
+$container->run();
+```
+
+**Example with commands only:**
+
+```php
+$container->run(commands: ['ls', '-la']);
+```
+
+**Example with commands and a wait condition:**
+
+```php
+$container->run(commands: ['ls', '-la'], waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 5));
+```
+
+### Running a container if it doesn't exist
+
+The `runIfNotExists` method starts a container only if it doesn't already exist.
+Optionally, it allows you to execute commands within the container after it has started and define a condition to wait
+for using a `ContainerWaitAfterStarted` instance.
+
+```php
+$container->runIfNotExists();
+```
+
+**Example with commands only:**
+
+```php
+$container->runIfNotExists(commands: ['ls', '-la']);
+```
+
+**Example with commands and a wait condition:**
+
+```php
+$container->runIfNotExists(commands: ['ls', '-la'], waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 5));
+```
+
+### Setting network
+
+The `withNetwork` method connects the container to a specified Docker network by name, allowing you to define the
+network configuration the container will use.
+
+```php
+$container->withNetwork(name: 'my-network');
+```
+
+### Setting port mappings
+
+Maps ports between the host and the container.
+The `withPortMapping` method maps a port from the host to a port inside the container.
+
+```php
+$container->withPortMapping(portOnHost: 9000, portOnContainer: 9000);
+```
+
+### Setting volumes mappings
+
+Maps a volume from the host to the container.
+The `withVolumeMapping` method allows you to link a directory from the host to the container.
+
+```php
+$container->withVolumeMapping(pathOnHost: '/path/on/host', pathOnContainer: '/path/in/container');
+```
+
+### Setting environment variables
+
+Sets environment variables inside the container.
+The `withEnvironmentVariable` method allows you to configure environment variables within the container.
+
+```php
+$container->withEnvironmentVariable(key: 'XPTO', value: '123');
+```
+
+### Disabling auto-remove
+
+Prevents the container from being automatically removed when stopped.
+By default, Docker removes containers after they stop.
+The `withoutAutoRemove` method disables this feature, keeping the container around even after it finishes its
+execution.
+
+```php
+$container->withoutAutoRemove();
+```
+
+### Copying files to a container
+
+Copies files or directories from the host machine to the container.
+The `copyToContainer` method allows you to transfer files from the host system into the container’s file system.
+
+```php
+$container->copyToContainer(pathOnHost: '/path/to/files', pathOnContainer: '/path/in/container');
+```
+
+### Waiting for a condition
+
+The `withWaitBeforeRun` method allows the container to pause its execution until a specified condition is met before
+starting.
+
+```php
+$container->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: MySQLReady::from(container: $container)));
+```
+
+
+
+## Usage examples
+
+### MySQL and Generic Containers
+
+The MySQL container is configured and started:
+
+```php
+$mySQLContainer = MySQLDockerContainer::from(image: 'mysql:8.1', name: 'test-database')
+ ->withNetwork(name: 'tiny-blocks')
+ ->withTimezone(timezone: 'America/Sao_Paulo')
+ ->withUsername(user: 'xpto')
+ ->withPassword(password: '123')
+ ->withDatabase(database: 'test_adm')
+ ->withPortMapping(portOnHost: 3306, portOnContainer: 3306)
+ ->withRootPassword(rootPassword: 'root')
+ ->withVolumeMapping(pathOnHost: '/var/lib/mysql', pathOnContainer: '/var/lib/mysql')
+ ->withoutAutoRemove()
+ ->runIfNotExists();
+```
+
+With the MySQL container started, it is possible to retrieve data, such as the address and JDBC connection URL:
+
+```php
+$jdbcUrl = $mySQLContainer->getJdbcUrl(options: 'useUnicode=yes&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&useSSL=false');
+$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 and executes migrations after the MySQL container is **ready**:
+
+```php
+$flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.0.0')
+ ->withNetwork(name: 'tiny-blocks')
+ ->copyToContainer(pathOnHost: '/migrations', pathOnContainer: '/flyway/sql')
+ ->withVolumeMapping(pathOnHost: '/migrations', pathOnContainer: '/flyway/sql')
+ ->withWaitBeforeRun(
+ wait: ContainerWaitForDependency::untilReady(
+ condition: MySQLReady::from(
+ container: $mySQLContainer
+ )
+ )
+ )
+ ->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']);
+```
+
+
+
+## License
+
+Docker container is licensed under [MIT](LICENSE).
+
+
+
+## Contributing
+
+Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to
+contribute to the project.
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..9383c30
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,75 @@
+{
+ "name": "tiny-blocks/docker-container",
+ "type": "library",
+ "license": "MIT",
+ "homepage": "https://github.com/tiny-blocks/docker-container",
+ "description": "Manage Docker containers programmatically, simplifying the creation, running, and interaction with containers.",
+ "prefer-stable": true,
+ "minimum-stability": "stable",
+ "keywords": [
+ "psr",
+ "docker",
+ "tiny-blocks",
+ "docker-container"
+ ],
+ "authors": [
+ {
+ "name": "Gustavo Freze de Araujo Santos",
+ "homepage": "https://github.com/gustavofreze"
+ }
+ ],
+ "support": {
+ "issues": "https://github.com/tiny-blocks/docker-container/issues",
+ "source": "https://github.com/tiny-blocks/docker-container"
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "infection/extension-installer": true
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "TinyBlocks\\DockerContainer\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Test\\": "tests/",
+ "TinyBlocks\\DockerContainer\\": "tests/Unit/"
+ }
+ },
+ "require": {
+ "php": "^8.3",
+ "symfony/process": "^7.1",
+ "tiny-blocks/ksuid": "^1",
+ "tiny-blocks/collection": "^1"
+ },
+ "require-dev": {
+ "phpmd/phpmd": "^2.15",
+ "phpunit/phpunit": "^11",
+ "phpstan/phpstan": "^1",
+ "dg/bypass-finals": "^1.8",
+ "squizlabs/php_codesniffer": "^3.11",
+ "ext-pdo": "*"
+ },
+ "scripts": {
+ "phpcs": "phpcs --standard=PSR12 --extensions=php ./src",
+ "phpmd": "phpmd ./src text phpmd.xml --suffixes php --ignore-violations-on-exit",
+ "phpstan": "phpstan analyse -c phpstan.neon.dist --quiet --no-progress",
+ "test": "phpunit --log-junit=report/coverage/junit.xml --coverage-xml=report/coverage/coverage-xml --coverage-html=report/coverage/coverage-html tests",
+ "unit-test": "phpunit --no-coverage -c phpunit.xml --testsuite unit",
+ "test-no-coverage": "phpunit --no-coverage",
+ "review": [
+ "@phpcs",
+ "@phpmd",
+ "@phpstan"
+ ],
+ "tests": [
+ "@test"
+ ],
+ "tests-no-coverage": [
+ "@test-no-coverage"
+ ]
+ }
+}
diff --git a/phpmd.xml b/phpmd.xml
new file mode 100644
index 0000000..bb59312
--- /dev/null
+++ b/phpmd.xml
@@ -0,0 +1,59 @@
+
+
+ PHPMD Custom rules
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..ac3d3bd
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,14 @@
+parameters:
+ paths:
+ - src
+ level: 9
+ tmpDir: report/phpstan
+ ignoreErrors:
+ - '#Parameter ...#'
+ - '#Cannot access#'
+ - '#Cannot cast mixed to#'
+ - '#Cannot access property#'
+ - '#Unsafe usage of new static#'
+ - '#Access to an undefined property#'
+ - '#type specified in iterable type#'
+ reportUnmatchedIgnoredErrors: false
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..d1590d1
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+ src
+
+
+
+
+
+ tests/Unit
+
+
+ tests/Integration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Contracts/Address.php b/src/Contracts/Address.php
new file mode 100644
index 0000000..6dc68ee
--- /dev/null
+++ b/src/Contracts/Address.php
@@ -0,0 +1,41 @@
+items = CommandOptions::createFromEmpty();
+ $this->volumes = CommandOptions::createFromEmpty();
+ $this->environmentVariables = CommandOptions::createFromEmpty();
+
+ $this->containerHandler = new ContainerHandler(client: new DockerClient());
+ }
+
+ public static function from(string $image, ?string $name = null): static
+ {
+ $container = Container::create(name: $name, image: $image);
+
+ return new static(container: $container);
+ }
+
+ public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted
+ {
+ $this->waitBeforeStarted?->waitBefore();
+
+ $dockerRun = DockerRun::from(
+ commands: $commands,
+ container: $this->container,
+ port: $this->port,
+ network: $this->network,
+ volumes: $this->volumes,
+ detached: SimpleCommandOption::DETACH,
+ autoRemove: $this->autoRemove ? SimpleCommandOption::REMOVE : null,
+ environmentVariables: $this->environmentVariables
+ );
+
+ $container = $this->containerHandler->run(command: $dockerRun);
+
+ $this->items->each(
+ actions: function (VolumeOption $volume) use ($container) {
+ $item = ItemToCopyOption::from(id: $container->id, volume: $volume);
+ $dockerCopy = DockerCopy::from(item: $item);
+ $this->containerHandler->execute(command: $dockerCopy);
+ }
+ );
+
+ $containerStarted = new Started(container: $container, containerHandler: $this->containerHandler);
+ $waitAfterStarted?->waitAfter(containerStarted: $containerStarted);
+
+ return $containerStarted;
+ }
+
+ public function runIfNotExists(
+ array $commands = [],
+ ?ContainerWaitAfterStarted $waitAfterStarted = null
+ ): ContainerStarted {
+ $dockerList = DockerList::from(container: $this->container);
+ $container = $this->containerHandler->findBy(command: $dockerList);
+
+ if ($container->hasId()) {
+ return new Started(container: $container, containerHandler: $this->containerHandler);
+ }
+
+ return $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted);
+ }
+
+ public function copyToContainer(string $pathOnHost, string $pathOnContainer): static
+ {
+ $volume = VolumeOption::from(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer);
+ $this->items->add(elements: $volume);
+
+ return $this;
+ }
+
+ public function withNetwork(string $name): static
+ {
+ $this->network = NetworkOption::from(name: $name);
+
+ return $this;
+ }
+
+ public function withPortMapping(int $portOnHost, int $portOnContainer): static
+ {
+ $this->port = PortOption::from(portOnHost: $portOnHost, portOnContainer: $portOnContainer);
+
+ return $this;
+ }
+
+ public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static
+ {
+ $this->waitBeforeStarted = $wait;
+
+ return $this;
+ }
+
+ public function withoutAutoRemove(): static
+ {
+ $this->autoRemove = false;
+
+ return $this;
+ }
+
+ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): static
+ {
+ $volume = VolumeOption::from(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer);
+ $this->volumes->add(elements: $volume);
+
+ return $this;
+ }
+
+ public function withEnvironmentVariable(string $key, string $value): static
+ {
+ $environmentVariable = EnvironmentVariableOption::from(key: $key, value: $value);
+ $this->environmentVariables->add(elements: $environmentVariable);
+
+ return $this;
+ }
+}
diff --git a/src/Internal/Client/Client.php b/src/Internal/Client/Client.php
new file mode 100644
index 0000000..1a84380
--- /dev/null
+++ b/src/Internal/Client/Client.php
@@ -0,0 +1,24 @@
+toCommandLine());
+
+ try {
+ if (is_a($command, CommandWithTimeout::class)) {
+ $process->setTimeout($command->getTimeoutInWholeSeconds());
+ }
+
+ $process->run();
+
+ return Execution::from(process: $process);
+ } catch (Throwable $exception) {
+ throw new DockerCommandExecutionFailed(process: $process, exception: $exception);
+ }
+ }
+}
diff --git a/src/Internal/Client/Execution.php b/src/Internal/Client/Execution.php
new file mode 100644
index 0000000..e7d61a8
--- /dev/null
+++ b/src/Internal/Client/Execution.php
@@ -0,0 +1,30 @@
+getOutput(), successful: $process->isSuccessful());
+ }
+
+ public function getOutput(): string
+ {
+ return $this->output;
+ }
+
+ public function isSuccessful(): bool
+ {
+ return $this->successful;
+ }
+}
diff --git a/src/Internal/Commands/Command.php b/src/Internal/Commands/Command.php
new file mode 100644
index 0000000..d26bf53
--- /dev/null
+++ b/src/Internal/Commands/Command.php
@@ -0,0 +1,20 @@
+buildFrom(template: 'docker cp %s', values: [$this->commandOptions->toArguments()]);
+ }
+}
diff --git a/src/Internal/Commands/DockerExecute.php b/src/Internal/Commands/DockerExecute.php
new file mode 100644
index 0000000..86abdd3
--- /dev/null
+++ b/src/Internal/Commands/DockerExecute.php
@@ -0,0 +1,34 @@
+buildFrom(
+ template: 'docker exec %s %s',
+ values: [$this->name->value, $this->commandOptions->toArguments()]
+ );
+ }
+}
diff --git a/src/Internal/Commands/DockerInspect.php b/src/Internal/Commands/DockerInspect.php
new file mode 100644
index 0000000..d2c5607
--- /dev/null
+++ b/src/Internal/Commands/DockerInspect.php
@@ -0,0 +1,26 @@
+buildFrom(template: 'docker inspect %s', values: [$this->id->value]);
+ }
+}
diff --git a/src/Internal/Commands/DockerList.php b/src/Internal/Commands/DockerList.php
new file mode 100644
index 0000000..993c5df
--- /dev/null
+++ b/src/Internal/Commands/DockerList.php
@@ -0,0 +1,42 @@
+buildFrom(
+ template: 'docker ps %s %s name=%s',
+ values: [
+ $this->commandOptions->toArguments(),
+ SimpleCommandOption::FILTER->toArguments(),
+ $this->container->name->value
+ ]
+ );
+ }
+}
diff --git a/src/Internal/Commands/DockerRun.php b/src/Internal/Commands/DockerRun.php
new file mode 100644
index 0000000..f2f7a01
--- /dev/null
+++ b/src/Internal/Commands/DockerRun.php
@@ -0,0 +1,46 @@
+container->name->value;
+
+ return $this->buildFrom(
+ template: 'docker run --user root --name %s --hostname %s %s %s %s',
+ values: [
+ $name,
+ $name,
+ $this->commandOptions->toArguments(),
+ $this->container->image->name,
+ $this->commands->joinToString(separator: ' ')
+ ]
+ );
+ }
+}
diff --git a/src/Internal/Commands/DockerStop.php b/src/Internal/Commands/DockerStop.php
new file mode 100644
index 0000000..fcd270b
--- /dev/null
+++ b/src/Internal/Commands/DockerStop.php
@@ -0,0 +1,31 @@
+buildFrom(template: 'docker stop %s', values: [$this->id->value]);
+ }
+
+ public function getTimeoutInWholeSeconds(): int
+ {
+ return $this->timeoutInWholeSeconds;
+ }
+}
diff --git a/src/Internal/Commands/LineBuilder.php b/src/Internal/Commands/LineBuilder.php
new file mode 100644
index 0000000..a3ab75d
--- /dev/null
+++ b/src/Internal/Commands/LineBuilder.php
@@ -0,0 +1,13 @@
+filter()
+ ->each(actions: static function (CommandOption $commandOption) use ($collection) {
+ $collection->add(elements: $commandOption->toArguments());
+ });
+
+ return $this->buildFrom(template: '%s', values: [$collection->joinToString(separator: ' ')]);
+ }
+}
diff --git a/src/Internal/Commands/Options/EnvironmentVariableOption.php b/src/Internal/Commands/Options/EnvironmentVariableOption.php
new file mode 100644
index 0000000..dfa5a4e
--- /dev/null
+++ b/src/Internal/Commands/Options/EnvironmentVariableOption.php
@@ -0,0 +1,26 @@
+buildFrom(template: '--env %s=%s', values: [$this->key, escapeshellarg($this->value)]);
+ }
+}
diff --git a/src/Internal/Commands/Options/GenericCommandOption.php b/src/Internal/Commands/Options/GenericCommandOption.php
new file mode 100644
index 0000000..3244378
--- /dev/null
+++ b/src/Internal/Commands/Options/GenericCommandOption.php
@@ -0,0 +1,29 @@
+buildFrom(template: '%s', values: [$this->commandOptions->joinToString(separator: ' ')]);
+ }
+}
diff --git a/src/Internal/Commands/Options/ItemToCopyOption.php b/src/Internal/Commands/Options/ItemToCopyOption.php
new file mode 100644
index 0000000..f940c2d
--- /dev/null
+++ b/src/Internal/Commands/Options/ItemToCopyOption.php
@@ -0,0 +1,30 @@
+buildFrom(
+ template: '%s %s:%s',
+ values: [$this->volume->pathOnHost, $this->id->value, $this->volume->pathOnContainer]
+ );
+ }
+}
diff --git a/src/Internal/Commands/Options/NetworkOption.php b/src/Internal/Commands/Options/NetworkOption.php
new file mode 100644
index 0000000..4495e21
--- /dev/null
+++ b/src/Internal/Commands/Options/NetworkOption.php
@@ -0,0 +1,26 @@
+buildFrom(template: '--network=%s', values: [$this->name]);
+ }
+}
diff --git a/src/Internal/Commands/Options/PortOption.php b/src/Internal/Commands/Options/PortOption.php
new file mode 100644
index 0000000..fe04f5f
--- /dev/null
+++ b/src/Internal/Commands/Options/PortOption.php
@@ -0,0 +1,26 @@
+buildFrom(template: '--publish %d:%d', values: [$this->portOnHost, $this->portOnContainer]);
+ }
+}
diff --git a/src/Internal/Commands/Options/SimpleCommandOption.php b/src/Internal/Commands/Options/SimpleCommandOption.php
new file mode 100644
index 0000000..0368830
--- /dev/null
+++ b/src/Internal/Commands/Options/SimpleCommandOption.php
@@ -0,0 +1,23 @@
+buildFrom(template: '--%s', values: [$this->value]);
+ }
+}
diff --git a/src/Internal/Commands/Options/VolumeOption.php b/src/Internal/Commands/Options/VolumeOption.php
new file mode 100644
index 0000000..d6075e9
--- /dev/null
+++ b/src/Internal/Commands/Options/VolumeOption.php
@@ -0,0 +1,26 @@
+buildFrom(template: '--volume %s:%s', values: [$this->pathOnHost, $this->pathOnContainer]);
+ }
+}
diff --git a/src/Internal/ContainerHandler.php b/src/Internal/ContainerHandler.php
new file mode 100644
index 0000000..bc56dae
--- /dev/null
+++ b/src/Internal/ContainerHandler.php
@@ -0,0 +1,53 @@
+containerFactory = new ContainerFactory(client: $client);
+ }
+
+ public function run(DockerRun $command): Container
+ {
+ $executionCompleted = $this->client->execute(command: $command);
+ $id = ContainerId::from(value: $executionCompleted->getOutput());
+
+ return $this->containerFactory->buildFrom(id: $id, container: $command->container);
+ }
+
+ public function findBy(DockerList $command): Container
+ {
+ $container = $command->container;
+ $executionCompleted = $this->client->execute(command: $command);
+
+ $output = $executionCompleted->getOutput();
+
+ if (empty($output)) {
+ return Container::create(name: $container->name->value, image: $container->image->name);
+ }
+
+ $id = ContainerId::from(value: $output);
+
+ return $this->containerFactory->buildFrom(id: $id, container: $container);
+ }
+
+ public function execute(Command $command): ExecutionCompleted
+ {
+ return $this->client->execute(command: $command);
+ }
+}
diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php b/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php
new file mode 100644
index 0000000..9ad6ca8
--- /dev/null
+++ b/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php
@@ -0,0 +1,40 @@
+container,
+ containerHandler: $containerStarted->containerHandler
+ );
+ }
+
+ public function getJdbcUrl(?string $options = null): string
+ {
+ $address = $this->getAddress();
+ $port = $address->getPorts()->firstExposedPort() ?? self::DEFAULT_MYSQL_PORT;
+ $hostname = $address->getHostname();
+ $database = $this->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE');
+
+ $baseUrl = sprintf('jdbc:mysql://%s:%d/%s', $hostname, $port, $database);
+
+ return $options ? sprintf('%s?%s', $baseUrl, ltrim($options, '?')) : $baseUrl;
+ }
+}
diff --git a/src/Internal/Containers/Factories/AddressFactory.php b/src/Internal/Containers/Factories/AddressFactory.php
new file mode 100644
index 0000000..3798e55
--- /dev/null
+++ b/src/Internal/Containers/Factories/AddressFactory.php
@@ -0,0 +1,27 @@
+ $networks[key($networks)]['IPAddress'],
+ 'ports' => [
+ 'exposedPorts' => array_map(fn($port) => (int)explode('/', $port)[0], array_keys($ports))
+ ],
+ 'hostname' => $configuration['Hostname']
+ ];
+
+ return Address::from(data: $address);
+ }
+}
diff --git a/src/Internal/Containers/Factories/ContainerFactory.php b/src/Internal/Containers/Factories/ContainerFactory.php
new file mode 100644
index 0000000..c27fb1e
--- /dev/null
+++ b/src/Internal/Containers/Factories/ContainerFactory.php
@@ -0,0 +1,46 @@
+addressFactory = new AddressFactory();
+ $this->variablesFactory = new EnvironmentVariablesFactory();
+ }
+
+ public function buildFrom(ContainerId $id, Container $container): Container
+ {
+ $dockerInspect = DockerInspect::from(id: $id);
+ $executionCompleted = $this->client->execute(command: $dockerInspect);
+
+ $payload = (array)json_decode($executionCompleted->getOutput(), true);
+
+ if (empty(array_filter($payload))) {
+ throw new DockerContainerNotFound(name: $container->name);
+ }
+
+ $data = $payload[0];
+
+ return Container::from(
+ id: $id,
+ name: $container->name,
+ image: $container->image,
+ address: $this->addressFactory->buildFrom(data: $data),
+ environmentVariables: $this->variablesFactory->buildFrom(data: $data)
+ );
+ }
+}
diff --git a/src/Internal/Containers/Factories/EnvironmentVariablesFactory.php b/src/Internal/Containers/Factories/EnvironmentVariablesFactory.php
new file mode 100644
index 0000000..a4670f9
--- /dev/null
+++ b/src/Internal/Containers/Factories/EnvironmentVariablesFactory.php
@@ -0,0 +1,26 @@
+ip->value;
+ }
+
+ public function getPorts(): ContainerPorts
+ {
+ return $this->ports;
+ }
+
+ public function getHostname(): string
+ {
+ return $this->hostname->value;
+ }
+}
diff --git a/src/Internal/Containers/Models/Address/Hostname.php b/src/Internal/Containers/Models/Address/Hostname.php
new file mode 100644
index 0000000..6834ebc
--- /dev/null
+++ b/src/Internal/Containers/Models/Address/Hostname.php
@@ -0,0 +1,26 @@
+filter()
+ ->toArray(preserveKeys: PreserveKeys::DISCARD);
+
+ return new Ports(exposedPorts: $exposedPorts);
+ }
+
+ public static function createFromEmpty(): Ports
+ {
+ return new Ports(exposedPorts: []);
+ }
+
+ public function exposedPorts(): array
+ {
+ return $this->exposedPorts;
+ }
+
+ public function firstExposedPort(): ?int
+ {
+ return $this->exposedPorts()[0] ?? null;
+ }
+}
diff --git a/src/Internal/Containers/Models/Container.php b/src/Internal/Containers/Models/Container.php
new file mode 100644
index 0000000..723064d
--- /dev/null
+++ b/src/Internal/Containers/Models/Container.php
@@ -0,0 +1,56 @@
+id !== null;
+ }
+}
diff --git a/src/Internal/Containers/Models/ContainerId.php b/src/Internal/Containers/Models/ContainerId.php
new file mode 100644
index 0000000..ac44919
--- /dev/null
+++ b/src/Internal/Containers/Models/ContainerId.php
@@ -0,0 +1,31 @@
+ is too short. Minimum length is <%d> characters.';
+ throw new InvalidArgumentException(message: sprintf($template, $value, self::CONTAINER_ID_LENGTH));
+ }
+
+ return new ContainerId(value: substr($value, self::CONTAINER_ID_OFFSET, self::CONTAINER_ID_LENGTH));
+ }
+}
diff --git a/src/Internal/Containers/Models/Environment/EnvironmentVariables.php b/src/Internal/Containers/Models/Environment/EnvironmentVariables.php
new file mode 100644
index 0000000..be0c761
--- /dev/null
+++ b/src/Internal/Containers/Models/Environment/EnvironmentVariables.php
@@ -0,0 +1,16 @@
+toArray()[$key];
+ }
+}
diff --git a/src/Internal/Containers/Models/Image.php b/src/Internal/Containers/Models/Image.php
new file mode 100644
index 0000000..b89de02
--- /dev/null
+++ b/src/Internal/Containers/Models/Image.php
@@ -0,0 +1,22 @@
+getValue() : $value;
+
+ return new Name(value: $value);
+ }
+}
diff --git a/src/Internal/Containers/Started.php b/src/Internal/Containers/Started.php
new file mode 100644
index 0000000..ba5eb3a
--- /dev/null
+++ b/src/Internal/Containers/Started.php
@@ -0,0 +1,55 @@
+container->id->value;
+ }
+
+ public function getName(): string
+ {
+ return $this->container->name->value;
+ }
+
+ public function getAddress(): Address
+ {
+ return $this->container->address;
+ }
+
+ public function getEnvironmentVariables(): EnvironmentVariables
+ {
+ return $this->container->environmentVariables;
+ }
+
+ public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted
+ {
+ $command = DockerStop::from(id: $this->container->id, timeoutInWholeSeconds: $timeoutInWholeSeconds);
+
+ return $this->containerHandler->execute(command: $command);
+ }
+
+ public function executeAfterStarted(array $commands): ExecutionCompleted
+ {
+ $command = DockerExecute::from(name: $this->container->name, commandOptions: $commands);
+
+ return $this->containerHandler->execute(command: $command);
+ }
+}
diff --git a/src/Internal/Exceptions/DockerCommandExecutionFailed.php b/src/Internal/Exceptions/DockerCommandExecutionFailed.php
new file mode 100644
index 0000000..dd17ccb
--- /dev/null
+++ b/src/Internal/Exceptions/DockerCommandExecutionFailed.php
@@ -0,0 +1,20 @@
+isStarted() ? $process->getErrorOutput() : $exception->getMessage();
+ $template = 'Failed to execute command <%s> in Docker container. Reason: %s';
+
+ parent::__construct(message: sprintf($template, $process->getCommandLine(), $reason));
+ }
+}
diff --git a/src/Internal/Exceptions/DockerContainerNotFound.php b/src/Internal/Exceptions/DockerContainerNotFound.php
new file mode 100644
index 0000000..f45165b
--- /dev/null
+++ b/src/Internal/Exceptions/DockerContainerNotFound.php
@@ -0,0 +1,18 @@
+ was not found.';
+
+ parent::__construct(message: sprintf($template, $name->value));
+ }
+}
diff --git a/src/MySQLContainer.php b/src/MySQLContainer.php
new file mode 100644
index 0000000..4359e3b
--- /dev/null
+++ b/src/MySQLContainer.php
@@ -0,0 +1,60 @@
+getEnvironmentVariables();
+
+ $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE');
+ $rootPassword = $environmentVariables->getValueBy(key: 'MYSQL_ROOT_PASSWORD');
+
+ if (!empty($this->grantedHosts)) {
+ $condition = MySQLReady::from(container: $containerStarted);
+ $waitForDependency = ContainerWaitForDependency::untilReady(condition: $condition);
+ $waitForDependency->waitBefore();
+
+ $command = MySQLCommands::createDatabase(database: $database, rootPassword: $rootPassword);
+ $containerStarted->executeAfterStarted(commands: [$command]);
+
+ foreach ($this->grantedHosts as $host) {
+ $command = MySQLCommands::grantPrivilegesToRoot(host: $host, rootPassword: $rootPassword);
+ $containerStarted->executeAfterStarted(commands: [$command]);
+ }
+ }
+
+ return MySQLStarted::from(containerStarted: $containerStarted);
+ }
+
+ public function runIfNotExists(
+ array $commands = [],
+ ?ContainerWaitAfterStarted $waitAfterStarted = null
+ ): MySQLContainerStarted {
+ $containerStarted = parent::runIfNotExists(commands: $commands);
+
+ return MySQLStarted::from(containerStarted: $containerStarted);
+ }
+
+ public function withTimezone(string $timezone): static
+ {
+ $this->withEnvironmentVariable(key: 'TZ', value: $timezone);
+
+ return $this;
+ }
+
+ public function withUsername(string $user): static
+ {
+ $this->withEnvironmentVariable(key: 'MYSQL_USER', value: $user);
+
+ return $this;
+ }
+
+ public function withPassword(string $password): static
+ {
+ $this->withEnvironmentVariable(key: 'MYSQL_PASSWORD', value: $password);
+
+ return $this;
+ }
+
+ public function withDatabase(string $database): static
+ {
+ $this->withEnvironmentVariable(key: 'MYSQL_DATABASE', value: $database);
+
+ return $this;
+ }
+
+ public function withRootPassword(string $rootPassword): static
+ {
+ $this->withEnvironmentVariable(key: 'MYSQL_ROOT_PASSWORD', value: $rootPassword);
+
+ return $this;
+ }
+
+ public function withGrantedHosts(array $hosts = ['%', '172.%']): static
+ {
+ $this->grantedHosts = $hosts;
+
+ return $this;
+ }
+}
diff --git a/src/Waits/Conditions/ContainerReady.php b/src/Waits/Conditions/ContainerReady.php
new file mode 100644
index 0000000..07e3420
--- /dev/null
+++ b/src/Waits/Conditions/ContainerReady.php
@@ -0,0 +1,21 @@
+container
+ ->getEnvironmentVariables()
+ ->getValueBy(key: 'MYSQL_ROOT_PASSWORD');
+
+ return $this->container
+ ->executeAfterStarted(commands: ['mysqladmin', 'ping', '-h', '127.0.0.1', "-p$rootPassword"])
+ ->isSuccessful();
+ }
+}
diff --git a/src/Waits/ContainerWait.php b/src/Waits/ContainerWait.php
new file mode 100644
index 0000000..d9942a9
--- /dev/null
+++ b/src/Waits/ContainerWait.php
@@ -0,0 +1,20 @@
+condition->isReady()) {
+ sleep(self::WAIT_TIME_IN_WHOLE_SECONDS);
+ }
+ }
+}
diff --git a/src/Waits/ContainerWaitForTime.php b/src/Waits/ContainerWaitForTime.php
new file mode 100644
index 0000000..5a5d8f4
--- /dev/null
+++ b/src/Waits/ContainerWaitForTime.php
@@ -0,0 +1,29 @@
+seconds);
+ }
+
+ public function waitAfter(ContainerStarted $containerStarted): void
+ {
+ sleep($this->seconds);
+ }
+}
diff --git a/tests/Integration/Database/Migrations/V0000__Create_xpto_table.sql b/tests/Integration/Database/Migrations/V0000__Create_xpto_table.sql
new file mode 100644
index 0000000..238b77e
--- /dev/null
+++ b/tests/Integration/Database/Migrations/V0000__Create_xpto_table.sql
@@ -0,0 +1,5 @@
+CREATE TABLE `xpto`
+(
+ `id` INT PRIMARY KEY NOT NULL COMMENT 'Unique identifier.',
+ `created_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT 'Date when the record was inserted.'
+);
diff --git a/tests/Integration/Database/Migrations/V0001__Insert_xpto_table.sql b/tests/Integration/Database/Migrations/V0001__Insert_xpto_table.sql
new file mode 100644
index 0000000..84cb186
--- /dev/null
+++ b/tests/Integration/Database/Migrations/V0001__Insert_xpto_table.sql
@@ -0,0 +1,11 @@
+INSERT INTO xpto (id, created_at)
+VALUES (1, NOW()),
+ (2, NOW()),
+ (3, NOW()),
+ (4, NOW()),
+ (5, NOW()),
+ (6, NOW()),
+ (7, NOW()),
+ (8, NOW()),
+ (9, NOW()),
+ (10, NOW());
diff --git a/tests/Integration/DockerContainerTest.php b/tests/Integration/DockerContainerTest.php
new file mode 100644
index 0000000..752ff27
--- /dev/null
+++ b/tests/Integration/DockerContainerTest.php
@@ -0,0 +1,117 @@
+withNetwork(name: 'tiny-blocks')
+ ->withTimezone(timezone: 'America/Sao_Paulo')
+ ->withUsername(user: self::ROOT)
+ ->withPassword(password: self::ROOT)
+ ->withDatabase(database: self::DATABASE)
+ ->withPortMapping(portOnHost: 3306, portOnContainer: 3306)
+ ->withRootPassword(rootPassword: self::ROOT)
+ ->withGrantedHosts()
+ ->withoutAutoRemove()
+ ->withVolumeMapping(pathOnHost: '/var/lib/mysql', pathOnContainer: '/var/lib/mysql')
+ ->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('test-database', $mySQLContainer->getName());
+ self::assertSame(3306, $port);
+ self::assertSame(self::DATABASE, $database);
+
+ /** @Given a Flyway container is configured to perform database migrations */
+ $jdbcUrl = $mySQLContainer->getJdbcUrl(
+ options: 'useUnicode=yes&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&useSSL=false'
+ );
+
+ $flywayContainer = GenericDockerContainer::from(image: 'flyway/flyway:11.0.0')
+ ->withNetwork(name: 'tiny-blocks')
+ ->copyToContainer(pathOnHost: '/migrations', pathOnContainer: '/flyway/sql')
+ ->withVolumeMapping(pathOnHost: '/migrations', pathOnContainer: '/flyway/sql')
+ ->withWaitBeforeRun(
+ wait: ContainerWaitForDependency::untilReady(
+ condition: MySQLReady::from(
+ container: $mySQLContainer
+ )
+ )
+ )
+ ->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: 5)
+ );
+
+ self::assertNotEmpty($flywayContainer->getName());
+
+ /** @Then the Flyway container should execute the migrations successfully */
+ $actual = MySQLRepository::connectFrom(container: $mySQLContainer)->allRecordsFrom(table: 'xpto');
+
+ self::assertCount(10, $actual);
+ }
+
+ public function testRunCalledTwiceForSameContainerDoesNotStartTwice(): void
+ {
+ /** @Given a container is configured */
+ $container = GenericDockerContainer::from(image: 'php:fpm-alpine', name: 'test-container')
+ ->withNetwork(name: 'tiny-blocks')
+ ->withWaitBeforeRun(wait: ContainerWaitForTime::forSeconds(seconds: 1))
+ ->withEnvironmentVariable(key: 'TEST', value: '123');
+
+ /** @When the container is started for the first time */
+ $firstRun = $container->runIfNotExists();
+
+ /** @Then the container should be successfully started */
+ self::assertSame('123', $firstRun->getEnvironmentVariables()->getValueBy(key: 'TEST'));
+
+ /** @And when the same container is started again */
+ $secondRun = GenericDockerContainer::from(image: 'php:fpm-alpine', name: 'test-container')
+ ->runIfNotExists();
+
+ /** @Then the container should not be restarted */
+ self::assertSame($firstRun->getId(), $secondRun->getId());
+ self::assertSame($firstRun->getName(), $secondRun->getName());
+ self::assertEquals($firstRun->getAddress(), $secondRun->getAddress());
+ self::assertEquals($firstRun->getEnvironmentVariables(), $secondRun->getEnvironmentVariables());
+
+ /** @And when the container is stopped */
+ $actual = $firstRun->stop();
+
+ /** @Then the stop operation should be successful */
+ self::assertTrue($actual->isSuccessful());
+ }
+}
diff --git a/tests/Integration/MySQLRepository.php b/tests/Integration/MySQLRepository.php
new file mode 100644
index 0000000..60dc3bc
--- /dev/null
+++ b/tests/Integration/MySQLRepository.php
@@ -0,0 +1,43 @@
+getAddress();
+ $environmentVariables = $container->getEnvironmentVariables();
+
+ $dsn = sprintf(
+ 'mysql:host=%s;port=%d;dbname=%s',
+ $address->getHostname(),
+ $address->getPorts()->firstExposedPort(),
+ $environmentVariables->getValueBy(key: 'MYSQL_DATABASE')
+ );
+
+ $connection = new PDO(
+ $dsn,
+ $environmentVariables->getValueBy(key: 'MYSQL_USER'),
+ $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD')
+ );
+
+ return new MySQLRepository(connection: $connection);
+ }
+
+ public function allRecordsFrom(string $table): array
+ {
+ return $this->connection
+ ->query(sprintf('SELECT * FROM %s', $table))
+ ->fetchAll(PDO::FETCH_ASSOC);
+ }
+}
diff --git a/tests/Unit/ClientMock.php b/tests/Unit/ClientMock.php
new file mode 100644
index 0000000..fdecfd6
--- /dev/null
+++ b/tests/Unit/ClientMock.php
@@ -0,0 +1,62 @@
+dockerRunResponses[] = $data;
+ }
+
+ public function withDockerListResponse(string $data): void
+ {
+ $this->dockerListResponses[] = $data;
+ }
+
+ public function withDockerInspectResponse(array $data): void
+ {
+ $this->dockerInspectResponses[] = $data;
+ }
+
+ public function execute(Command $command): ExecutionCompleted
+ {
+ $output = match (get_class($command)) {
+ DockerRun::class => array_shift($this->dockerRunResponses),
+ DockerList::class => array_shift($this->dockerListResponses),
+ DockerInspect::class => json_encode([array_shift($this->dockerInspectResponses)]),
+ default => ''
+ };
+
+ return new readonly class($output) implements ExecutionCompleted {
+ public function __construct(private string $output)
+ {
+ }
+
+ public function getOutput(): string
+ {
+ return $this->output;
+ }
+
+ public function isSuccessful(): bool
+ {
+ return !empty($this->output);
+ }
+ };
+ }
+}
diff --git a/tests/Unit/CommandMock.php b/tests/Unit/CommandMock.php
new file mode 100644
index 0000000..1690f39
--- /dev/null
+++ b/tests/Unit/CommandMock.php
@@ -0,0 +1,22 @@
+buildFrom(template: 'echo %s', values: $this->command);
+ }
+}
diff --git a/tests/Unit/CommandWithTimeoutMock.php b/tests/Unit/CommandWithTimeoutMock.php
new file mode 100644
index 0000000..4c22cd2
--- /dev/null
+++ b/tests/Unit/CommandWithTimeoutMock.php
@@ -0,0 +1,27 @@
+buildFrom(template: 'echo %s', values: $this->command);
+ }
+
+ public function getTimeoutInWholeSeconds(): int
+ {
+ return $this->timeoutInWholeSeconds;
+ }
+}
diff --git a/tests/Unit/Internal/Client/DockerClientTest.php b/tests/Unit/Internal/Client/DockerClientTest.php
new file mode 100644
index 0000000..613f553
--- /dev/null
+++ b/tests/Unit/Internal/Client/DockerClientTest.php
@@ -0,0 +1,48 @@
+client = new DockerClient();
+ }
+
+ public function testDockerCommandExecution(): void
+ {
+ /** @Given a command that will succeed */
+ $command = new CommandMock(command: [' Hello, World! ']);
+
+ /** @When the command is executed */
+ $actual = $this->client->execute(command: $command);
+
+ /** @Then the output should be the expected one */
+ self::assertTrue($actual->isSuccessful());
+ self::assertEquals("Hello, World!\n", $actual->getOutput());
+ }
+
+ public function testExceptionWhenDockerCommandExecutionFailed(): void
+ {
+ /** @Given a command that will fail due to invalid timeout */
+ $command = new CommandWithTimeoutMock(command: ['Hello, World!'], timeoutInWholeSeconds: -10);
+
+ /** @Then an exception indicating that the Docker command execution failed should be thrown */
+ $this->expectException(DockerCommandExecutionFailed::class);
+ $this->expectExceptionMessage(
+ 'Failed to execute command in Docker container. Reason: The timeout value must be a valid positive integer or float number.'
+ );
+
+ /** @When the command is executed */
+ $this->client->execute(command: $command);
+ }
+}
diff --git a/tests/Unit/Internal/Commands/DockerCopyTest.php b/tests/Unit/Internal/Commands/DockerCopyTest.php
new file mode 100644
index 0000000..795acbc
--- /dev/null
+++ b/tests/Unit/Internal/Commands/DockerCopyTest.php
@@ -0,0 +1,33 @@
+toCommandLine();
+
+ /** @Then the command line should be as expected */
+ self::assertSame('docker cp /path/to/source abc123abc123:/path/to/destination', $actual);
+ }
+}
diff --git a/tests/Unit/Internal/Commands/DockerExecuteTest.php b/tests/Unit/Internal/Commands/DockerExecuteTest.php
new file mode 100644
index 0000000..c1a505e
--- /dev/null
+++ b/tests/Unit/Internal/Commands/DockerExecuteTest.php
@@ -0,0 +1,26 @@
+toCommandLine();
+
+ /** @Then the command line should be as expected */
+ self::assertSame('docker exec container-name ls -la', $actual);
+ }
+}
diff --git a/tests/Unit/Internal/Commands/DockerInspectTest.php b/tests/Unit/Internal/Commands/DockerInspectTest.php
new file mode 100644
index 0000000..465d604
--- /dev/null
+++ b/tests/Unit/Internal/Commands/DockerInspectTest.php
@@ -0,0 +1,23 @@
+toCommandLine();
+
+ /** @Then the command line should be as expected */
+ self::assertSame('docker inspect abc123abc123', $actual);
+ }
+}
diff --git a/tests/Unit/Internal/Commands/DockerListTest.php b/tests/Unit/Internal/Commands/DockerListTest.php
new file mode 100644
index 0000000..546035b
--- /dev/null
+++ b/tests/Unit/Internal/Commands/DockerListTest.php
@@ -0,0 +1,23 @@
+toCommandLine();
+
+ /** @Then the command line should be as expected */
+ self::assertSame('docker ps --all --quiet --filter name=container-name', $actual);
+ }
+}
diff --git a/tests/Unit/Internal/Commands/DockerRunTest.php b/tests/Unit/Internal/Commands/DockerRunTest.php
new file mode 100644
index 0000000..769cbc8
--- /dev/null
+++ b/tests/Unit/Internal/Commands/DockerRunTest.php
@@ -0,0 +1,48 @@
+toCommandLine();
+
+ /** @Then the command line should be as expected */
+ self::assertSame(
+ "docker run --user root --name alpine --hostname alpine --publish 8080:80 --network=host --volume /path/to/source:/path/to/destination --detach --rm --env key1='value1' alpine:latest",
+ $actual
+ );
+ }
+}
diff --git a/tests/Unit/Internal/Commands/DockerStopTest.php b/tests/Unit/Internal/Commands/DockerStopTest.php
new file mode 100644
index 0000000..87e5282
--- /dev/null
+++ b/tests/Unit/Internal/Commands/DockerStopTest.php
@@ -0,0 +1,24 @@
+toCommandLine();
+
+ /** @And the timeout should be correct */
+ self::assertSame('docker stop 1234567890ab', $actual);
+ self::assertSame(10, $command->getTimeoutInWholeSeconds());
+ }
+}
diff --git a/tests/Unit/Internal/ContainerHandlerTest.php b/tests/Unit/Internal/ContainerHandlerTest.php
new file mode 100644
index 0000000..a498712
--- /dev/null
+++ b/tests/Unit/Internal/ContainerHandlerTest.php
@@ -0,0 +1,185 @@
+client = new ClientMock();
+ $this->handler = new ContainerHandler(client: $this->client);
+ }
+
+ public function testShouldRunContainerSuccessfully(): void
+ {
+ /** @Given a DockerRun command */
+ $command = DockerRun::from(
+ commands: [],
+ container: Container::create(name: 'alpine', image: 'alpine:latest'),
+ network: NetworkOption::from(name: 'bridge'),
+ detached: SimpleCommandOption::DETACH,
+ autoRemove: SimpleCommandOption::REMOVE,
+ environmentVariables: CommandOptions::createFromOptions(
+ commandOption: EnvironmentVariableOption::from(key: 'PASSWORD', value: 'root')
+ )
+ );
+
+ /** @And the DockerRun command was executed and returned the container ID */
+ $this->client->withDockerRunResponse(data: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8');
+
+ /** @And the DockerInspect command was executed and returned the container's details */
+ $this->client->withDockerInspectResponse(data: [
+ 'Id' => '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8',
+ 'Name' => '/alpine',
+ 'Config' => [
+ 'Hostname' => 'alpine',
+ 'ExposedPorts' => [],
+ 'Env' => [
+ 'PASSWORD=root'
+ ]
+ ],
+ 'NetworkSettings' => [
+ 'Networks' => [
+ 'bridge' => [
+ 'IPAddress' => '172.22.0.2'
+ ]
+ ]
+ ]
+ ]);
+
+ /** @When running the container */
+ $container = $this->handler->run(command: $command);
+
+ /** @Then the container should be created with the correct details */
+ self::assertSame('root', $container->environmentVariables->getValueBy(key: 'PASSWORD'));
+ self::assertSame('alpine', $container->name->value);
+ self::assertSame('alpine', $container->address->getHostname());
+ self::assertSame('172.22.0.2', $container->address->getIp());
+ self::assertSame('6acae5967be0', $container->id->value);
+ self::assertSame('alpine:latest', $container->image->name);
+ }
+
+ public function testShouldFindContainerSuccessfully(): void
+ {
+ /** @Given a DockerList command */
+ $command = DockerList::from(container: Container::create(name: 'alpine', image: 'alpine:latest'));
+
+ /** @And the DockerList command was executed and returned the container ID */
+ $this->client->withDockerListResponse(data: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8');
+
+ /** @And the DockerInspect command was executed and returned the container details */
+ $this->client->withDockerInspectResponse(data: [
+ 'Id' => '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8',
+ 'Name' => '/alpine',
+ 'Config' => [
+ 'Hostname' => 'alpine',
+ 'ExposedPorts' => [],
+ 'Env' => [
+ 'PASSWORD=root'
+ ]
+ ],
+ 'NetworkSettings' => [
+ 'Networks' => [
+ 'bridge' => [
+ 'IPAddress' => '172.22.0.2'
+ ]
+ ]
+ ]
+ ]);
+
+ /** @When finding the container */
+ $container = $this->handler->findBy(command: $command);
+
+ /** @Then the container should be returned with the correct details */
+ self::assertSame('root', $container->environmentVariables->getValueBy(key: 'PASSWORD'));
+ self::assertSame('alpine', $container->name->value);
+ self::assertSame('alpine', $container->address->getHostname());
+ self::assertSame('172.22.0.2', $container->address->getIp());
+ self::assertSame('6acae5967be0', $container->id->value);
+ self::assertSame('alpine:latest', $container->image->name);
+ }
+
+ public function testShouldReturnEmptyContainerWhenNotFound(): void
+ {
+ /** @Given a DockerList command */
+ $command = DockerList::from(container: Container::create(name: 'alpine', image: 'alpine:latest'));
+
+ /** @And the DockerList command was executed and returned the container ID */
+ $this->client->withDockerListResponse(data: '');
+
+ /** @When finding the container */
+ $container = $this->handler->findBy(command: $command);
+
+ /** @Then the container should be returned with the correct details */
+ self::assertNull($container->id);
+ self::assertSame('alpine', $container->name->value);
+ self::assertSame('localhost', $container->address->getHostname());
+ self::assertSame('127.0.0.1', $container->address->getIp());
+ self::assertSame('alpine:latest', $container->image->name);
+ }
+
+ public function testShouldExecuteCommandSuccessfully(): void
+ {
+ /** @Given a DockerList command */
+ $command = DockerList::from(container: Container::create(name: 'alpine', image: 'alpine:latest'));
+
+ /** @And the DockerList command was executed and returned the container ID */
+ $this->client->withDockerListResponse(data: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8');
+
+ /** @When executing the DockerList command */
+ $executionCompleted = $this->handler->execute(command: $command);
+
+ /** @Then the execution should be successful and return the correct output */
+ self::assertTrue($executionCompleted->isSuccessful());
+ self::assertSame(
+ '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8',
+ $executionCompleted->getOutput()
+ );
+ }
+
+ public function testExceptionWhenDockerContainerNotFound(): void
+ {
+ /** @Given a DockerRun command */
+ $command = DockerRun::from(
+ commands: [],
+ container: Container::create(name: 'alpine', image: 'alpine:latest'),
+ network: NetworkOption::from(name: 'bridge'),
+ detached: SimpleCommandOption::DETACH,
+ autoRemove: SimpleCommandOption::REMOVE,
+ environmentVariables: CommandOptions::createFromOptions(
+ commandOption: EnvironmentVariableOption::from(key: 'PASSWORD', value: 'root')
+ )
+ );
+
+ /** @And the DockerRun command was executed and returned the container ID */
+ $this->client->withDockerRunResponse(data: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8');
+
+ /** @And the DockerInspect command was executed but returned an empty response */
+ $this->client->withDockerInspectResponse(data: []);
+
+ /** @Then an exception indicating that the Docker container was not found should be thrown */
+ $this->expectException(DockerContainerNotFound::class);
+ $this->expectExceptionMessage('Docker container with name was not found.');
+
+ /** @When running the container */
+ $this->handler->run(command: $command);
+ }
+}
diff --git a/tests/Unit/Internal/Containers/Models/ContainerIdTest.php b/tests/Unit/Internal/Containers/Models/ContainerIdTest.php
new file mode 100644
index 0000000..3414a03
--- /dev/null
+++ b/tests/Unit/Internal/Containers/Models/ContainerIdTest.php
@@ -0,0 +1,62 @@
+expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Container ID cannot be empty.');
+
+ /** @When the container ID is created with the empty value */
+ ContainerId::from(value: $value);
+ }
+
+ public function testExceptionWhenIdIsTooShort(): void
+ {
+ /** @Given a value with less than 12 characters */
+ $value = 'abc123';
+
+ /** @Then an InvalidArgumentException should be thrown */
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Container ID is too short. Minimum length is <12> characters.');
+
+ /** @When the container ID is created with the short value */
+ ContainerId::from(value: $value);
+ }
+
+ public function testContainerIdIsAcceptedWhenExactly12Characters(): void
+ {
+ /** @Given a value with exactly 12 characters */
+ $value = 'abc123abc123';
+
+ /** @When the container ID is created */
+ $containerId = ContainerId::from(value: $value);
+
+ /** @Then the container ID should be the same as the input value */
+ $this->assertSame('abc123abc123', $containerId->value);
+ }
+
+ public function testContainerIdIsTruncatedIfLongerThan12Characters(): void
+ {
+ /** @Given a value with more than 12 characters */
+ $value = 'abc123abc123abc123';
+
+ /** @When the container ID is created */
+ $containerId = ContainerId::from(value: $value);
+
+ /** @Then the container ID should be truncated to 12 characters */
+ $this->assertSame('abc123abc123', $containerId->value);
+ }
+}
diff --git a/tests/Unit/Internal/Containers/Models/ImageTest.php b/tests/Unit/Internal/Containers/Models/ImageTest.php
new file mode 100644
index 0000000..c9142d5
--- /dev/null
+++ b/tests/Unit/Internal/Containers/Models/ImageTest.php
@@ -0,0 +1,25 @@
+expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Image name cannot be empty.');
+
+ /** @When the image name is created with the empty value */
+ Image::from(image: $value);
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..db4e5e1
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,7 @@
+