From 172ed6514a6d239f4f50b79c042780f0cf0202a9 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sun, 1 Dec 2024 19:15:58 -0300 Subject: [PATCH] Feature/initial (#1) * feat: Implements Docker container operations and tests. --- .gitattributes | 14 ++ .github/workflows/auto-assign.yml | 22 ++ .github/workflows/ci.yml | 62 +++++ .gitignore | 6 + Makefile | 31 +++ README.md | 235 +++++++++++++++++- composer.json | 75 ++++++ phpmd.xml | 59 +++++ phpstan.neon.dist | 14 ++ phpunit.xml | 39 +++ src/Contracts/Address.php | 41 +++ src/Contracts/ContainerStarted.php | 62 +++++ src/Contracts/EnvironmentVariables.php | 19 ++ src/Contracts/ExecutionCompleted.php | 25 ++ src/Contracts/MySQL/MySQLContainerStarted.php | 25 ++ src/Contracts/Ports.php | 26 ++ src/DockerContainer.php | 124 +++++++++ src/GenericDockerContainer.php | 155 ++++++++++++ src/Internal/Client/Client.php | 24 ++ src/Internal/Client/DockerClient.php | 32 +++ src/Internal/Client/Execution.php | 30 +++ src/Internal/Commands/Command.php | 20 ++ src/Internal/Commands/CommandWithTimeout.php | 18 ++ src/Internal/Commands/DockerCopy.php | 29 +++ src/Internal/Commands/DockerExecute.php | 34 +++ src/Internal/Commands/DockerInspect.php | 26 ++ src/Internal/Commands/DockerList.php | 42 ++++ src/Internal/Commands/DockerRun.php | 46 ++++ src/Internal/Commands/DockerStop.php | 31 +++ src/Internal/Commands/LineBuilder.php | 13 + .../Commands/Options/CommandOption.php | 18 ++ .../Commands/Options/CommandOptions.php | 31 +++ .../Options/EnvironmentVariableOption.php | 26 ++ .../Commands/Options/GenericCommandOption.php | 29 +++ .../Commands/Options/ItemToCopyOption.php | 30 +++ .../Commands/Options/NetworkOption.php | 26 ++ src/Internal/Commands/Options/PortOption.php | 26 ++ .../Commands/Options/SimpleCommandOption.php | 23 ++ .../Commands/Options/VolumeOption.php | 26 ++ src/Internal/ContainerHandler.php | 53 ++++ .../Drivers/MySQL/MySQLCommands.php | 40 +++ .../Containers/Drivers/MySQL/MySQLStarted.php | 34 +++ .../Containers/Factories/AddressFactory.php | 27 ++ .../Containers/Factories/ContainerFactory.php | 46 ++++ .../Factories/EnvironmentVariablesFactory.php | 26 ++ .../Containers/Models/Address/Address.php | 44 ++++ .../Containers/Models/Address/Hostname.php | 26 ++ src/Internal/Containers/Models/Address/IP.php | 26 ++ .../Containers/Models/Address/Ports.php | 40 +++ src/Internal/Containers/Models/Container.php | 56 +++++ .../Containers/Models/ContainerId.php | 31 +++ .../Environment/EnvironmentVariables.php | 16 ++ src/Internal/Containers/Models/Image.php | 22 ++ src/Internal/Containers/Models/Name.php | 21 ++ src/Internal/Containers/Started.php | 55 ++++ .../DockerCommandExecutionFailed.php | 20 ++ .../Exceptions/DockerContainerNotFound.php | 18 ++ src/MySQLContainer.php | 60 +++++ src/MySQLDockerContainer.php | 95 +++++++ src/Waits/Conditions/ContainerReady.php | 21 ++ src/Waits/Conditions/MySQL/MySQLReady.php | 31 +++ src/Waits/ContainerWait.php | 20 ++ src/Waits/ContainerWaitAfterStarted.php | 24 ++ src/Waits/ContainerWaitBeforeStarted.php | 21 ++ src/Waits/ContainerWaitForDependency.php | 26 ++ src/Waits/ContainerWaitForTime.php | 29 +++ .../Migrations/V0000__Create_xpto_table.sql | 5 + .../Migrations/V0001__Insert_xpto_table.sql | 11 + tests/Integration/DockerContainerTest.php | 117 +++++++++ tests/Integration/MySQLRepository.php | 43 ++++ tests/Unit/ClientMock.php | 62 +++++ tests/Unit/CommandMock.php | 22 ++ tests/Unit/CommandWithTimeoutMock.php | 27 ++ .../Unit/Internal/Client/DockerClientTest.php | 48 ++++ .../Unit/Internal/Commands/DockerCopyTest.php | 33 +++ .../Internal/Commands/DockerExecuteTest.php | 26 ++ .../Internal/Commands/DockerInspectTest.php | 23 ++ .../Unit/Internal/Commands/DockerListTest.php | 23 ++ .../Unit/Internal/Commands/DockerRunTest.php | 48 ++++ .../Unit/Internal/Commands/DockerStopTest.php | 24 ++ tests/Unit/Internal/ContainerHandlerTest.php | 185 ++++++++++++++ .../Containers/Models/ContainerIdTest.php | 62 +++++ .../Internal/Containers/Models/ImageTest.php | 25 ++ tests/bootstrap.php | 7 + 84 files changed, 3331 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/auto-assign.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 composer.json create mode 100644 phpmd.xml create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml create mode 100644 src/Contracts/Address.php create mode 100644 src/Contracts/ContainerStarted.php create mode 100644 src/Contracts/EnvironmentVariables.php create mode 100644 src/Contracts/ExecutionCompleted.php create mode 100644 src/Contracts/MySQL/MySQLContainerStarted.php create mode 100644 src/Contracts/Ports.php create mode 100644 src/DockerContainer.php create mode 100644 src/GenericDockerContainer.php create mode 100644 src/Internal/Client/Client.php create mode 100644 src/Internal/Client/DockerClient.php create mode 100644 src/Internal/Client/Execution.php create mode 100644 src/Internal/Commands/Command.php create mode 100644 src/Internal/Commands/CommandWithTimeout.php create mode 100644 src/Internal/Commands/DockerCopy.php create mode 100644 src/Internal/Commands/DockerExecute.php create mode 100644 src/Internal/Commands/DockerInspect.php create mode 100644 src/Internal/Commands/DockerList.php create mode 100644 src/Internal/Commands/DockerRun.php create mode 100644 src/Internal/Commands/DockerStop.php create mode 100644 src/Internal/Commands/LineBuilder.php create mode 100644 src/Internal/Commands/Options/CommandOption.php create mode 100644 src/Internal/Commands/Options/CommandOptions.php create mode 100644 src/Internal/Commands/Options/EnvironmentVariableOption.php create mode 100644 src/Internal/Commands/Options/GenericCommandOption.php create mode 100644 src/Internal/Commands/Options/ItemToCopyOption.php create mode 100644 src/Internal/Commands/Options/NetworkOption.php create mode 100644 src/Internal/Commands/Options/PortOption.php create mode 100644 src/Internal/Commands/Options/SimpleCommandOption.php create mode 100644 src/Internal/Commands/Options/VolumeOption.php create mode 100644 src/Internal/ContainerHandler.php create mode 100644 src/Internal/Containers/Drivers/MySQL/MySQLCommands.php create mode 100644 src/Internal/Containers/Drivers/MySQL/MySQLStarted.php create mode 100644 src/Internal/Containers/Factories/AddressFactory.php create mode 100644 src/Internal/Containers/Factories/ContainerFactory.php create mode 100644 src/Internal/Containers/Factories/EnvironmentVariablesFactory.php create mode 100644 src/Internal/Containers/Models/Address/Address.php create mode 100644 src/Internal/Containers/Models/Address/Hostname.php create mode 100644 src/Internal/Containers/Models/Address/IP.php create mode 100644 src/Internal/Containers/Models/Address/Ports.php create mode 100644 src/Internal/Containers/Models/Container.php create mode 100644 src/Internal/Containers/Models/ContainerId.php create mode 100644 src/Internal/Containers/Models/Environment/EnvironmentVariables.php create mode 100644 src/Internal/Containers/Models/Image.php create mode 100644 src/Internal/Containers/Models/Name.php create mode 100644 src/Internal/Containers/Started.php create mode 100644 src/Internal/Exceptions/DockerCommandExecutionFailed.php create mode 100644 src/Internal/Exceptions/DockerContainerNotFound.php create mode 100644 src/MySQLContainer.php create mode 100644 src/MySQLDockerContainer.php create mode 100644 src/Waits/Conditions/ContainerReady.php create mode 100644 src/Waits/Conditions/MySQL/MySQLReady.php create mode 100644 src/Waits/ContainerWait.php create mode 100644 src/Waits/ContainerWaitAfterStarted.php create mode 100644 src/Waits/ContainerWaitBeforeStarted.php create mode 100644 src/Waits/ContainerWaitForDependency.php create mode 100644 src/Waits/ContainerWaitForTime.php create mode 100644 tests/Integration/Database/Migrations/V0000__Create_xpto_table.sql create mode 100644 tests/Integration/Database/Migrations/V0001__Insert_xpto_table.sql create mode 100644 tests/Integration/DockerContainerTest.php create mode 100644 tests/Integration/MySQLRepository.php create mode 100644 tests/Unit/ClientMock.php create mode 100644 tests/Unit/CommandMock.php create mode 100644 tests/Unit/CommandWithTimeoutMock.php create mode 100644 tests/Unit/Internal/Client/DockerClientTest.php create mode 100644 tests/Unit/Internal/Commands/DockerCopyTest.php create mode 100644 tests/Unit/Internal/Commands/DockerExecuteTest.php create mode 100644 tests/Unit/Internal/Commands/DockerInspectTest.php create mode 100644 tests/Unit/Internal/Commands/DockerListTest.php create mode 100644 tests/Unit/Internal/Commands/DockerRunTest.php create mode 100644 tests/Unit/Internal/Commands/DockerStopTest.php create mode 100644 tests/Unit/Internal/ContainerHandlerTest.php create mode 100644 tests/Unit/Internal/Containers/Models/ContainerIdTest.php create mode 100644 tests/Unit/Internal/Containers/Models/ImageTest.php create mode 100644 tests/bootstrap.php 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](https://img.shields.io/badge/license-MIT-green)](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 @@ +