From e28030073e8f7aa3ddd997da199ef77d8b375f9d Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 27 Jun 2026 11:04:35 -0300 Subject: [PATCH 1/5] feat: Add conditional container run with reuse signaling. Introduce runWhen(gate, then), which starts a container only when a predicate holds and hands the started container to a callback, wasReused() to distinguish a reused container from a freshly started one, and EnvironmentFlag::enabled() as a ready-made predicate. The same change set brings the library into conformance with the tiny-blocks ecosystem rules across folder layout, code style, modeling, tooling, CI, and documentation. --- .editorconfig | 1 + .gitattributes | 16 +- .github/ISSUE_TEMPLATE/bug_report.md | 29 + .github/ISSUE_TEMPLATE/feature_request.md | 17 + .github/PULL_REQUEST_TEMPLATE.md | 16 + .github/workflows/auto-assign.yml | 17 +- .github/workflows/ci.yml | 56 +- .github/workflows/codeql.yml | 7 +- .gitignore | 34 +- Makefile | 41 +- README.md | 175 +- SECURITY.md | 12 + composer.json | 45 +- infection.json.dist | 6 +- phpcs.xml | 7 + phpstan.neon.dist | 123 +- phpunit.xml | 23 +- src/{Contracts => }/Address.php | 2 +- src/{Contracts => }/ContainerStarted.php | 51 +- src/DockerContainer.php | 72 +- src/EnvironmentFlag.php | 28 + src/{Contracts => }/EnvironmentVariables.php | 2 +- src/{Contracts => }/ExecutionCompleted.php | 4 +- src/FlywayContainer.php | 53 +- src/FlywayDockerContainer.php | 59 +- src/GenericDockerContainer.php | 93 +- src/Internal/Client/Client.php | 2 +- src/Internal/Client/DockerClient.php | 4 +- src/Internal/Client/Execution.php | 2 +- .../CommandHandler/CommandHandler.php | 4 +- .../ContainerCommandHandler.php | 48 +- src/Internal/Commands/Command.php | 2 +- src/Internal/Commands/DockerCopy.php | 2 +- src/Internal/Commands/DockerExecute.php | 2 +- src/Internal/Commands/DockerInspect.php | 2 +- src/Internal/Commands/DockerList.php | 6 +- src/Internal/Commands/DockerNetworkPrune.php | 5 +- src/Internal/Commands/DockerReaper.php | 25 +- src/Internal/Commands/DockerRemove.php | 2 +- src/Internal/Commands/DockerRun.php | 36 +- src/Internal/Commands/DockerStop.php | 2 +- src/Internal/Containers/Address/Address.php | 4 +- src/Internal/Containers/Address/Hostname.php | 2 +- src/Internal/Containers/Address/IP.php | 2 +- src/Internal/Containers/Address/Ports.php | 11 +- src/Internal/Containers/ContainerId.php | 36 + src/Internal/Containers/ContainerLookup.php | 3 +- src/Internal/Containers/ContainerReaper.php | 7 +- .../Definitions/ContainerDefinition.php | 30 +- .../Definitions/CopyInstruction.php | 6 +- .../Definitions/EnvironmentVariable.php | 4 +- .../Containers/Definitions/PortMapping.php | 4 +- .../Containers/Definitions/VolumeMapping.php | 4 +- .../Drivers/MySQL/MySQLCommands.php | 13 +- .../Containers/Drivers/MySQL/MySQLStarted.php | 63 +- .../Environment/EnvironmentVariables.php | 2 +- src/Internal/Containers/HostEnvironment.php | 6 +- .../Containers/{Models => }/Image.php | 8 +- .../Containers/Models/ContainerId.php | 35 - src/Internal/Containers/{Models => }/Name.php | 4 +- .../Containers/RegisteredShutdownHook.php | 13 + src/Internal/Containers/Reused.php | 41 +- src/Internal/Containers/ShutdownHook.php | 15 +- src/Internal/Containers/Started.php | 49 +- src/Internal/Exceptions/ContainerIdEmpty.php | 15 + .../Exceptions/ContainerIdTooShort.php | 17 + .../DockerCommandExecutionFailed.php | 14 +- .../Exceptions/DockerContainerNotFound.php | 2 +- src/Internal/Exceptions/ImageNameEmpty.php | 15 + .../MySQL/MySQLContainerStarted.php | 4 +- src/MySQLContainer.php | 34 +- src/MySQLDockerContainer.php | 149 +- src/{Contracts => }/Ports.php | 22 +- src/Waits/Conditions/ContainerReady.php | 2 +- src/Waits/Conditions/MySQL/MySQLReady.php | 6 +- src/Waits/ContainerWaitAfterStarted.php | 2 +- src/Waits/ContainerWaitForTime.php | 2 +- tests/Integration/DockerContainerTest.php | 24 +- tests/Integration/MySQLRepository.php | 2 +- .../InspectResponseFixture.php | 22 +- tests/Unit/{Mocks => }/ClientMock.php | 102 +- tests/Unit/{Mocks => }/CommandMock.php | 2 +- .../{Mocks => }/CommandWithTimeoutMock.php | 2 +- tests/Unit/EnvironmentFlagTest.php | 68 + .../{Mocks => }/ExecutionCompletedMock.php | 4 +- tests/Unit/FlywayDockerContainerTest.php | 435 ++-- tests/Unit/GenericDockerContainerTest.php | 2079 +++++++++++------ tests/Unit/HostEnvironmentTest.php | 27 + .../Unit/Internal/Client/DockerClientTest.php | 32 +- .../Internal/Commands/DockerCommandsTest.php | 192 -- .../Containers/Address/AddressTest.php | 60 - .../Internal/Containers/Address/PortsTest.php | 66 - .../Containers/ContainerReaperTest.php | 74 - .../Overrides/file_exists_outside_docker.php | 1 - .../register_shutdown_function_spy.php | 8 +- .../Internal/Containers/ShutdownHookTest.php | 37 - tests/Unit/MySQLCommandsTest.php | 27 + tests/Unit/MySQLDockerContainerTest.php | 1013 ++++---- tests/Unit/RegisteredShutdownHookTest.php | 31 + tests/Unit/RunningMySQLContainer.php | 52 + tests/Unit/{Mocks => }/ShutdownHookMock.php | 5 +- .../TestableFlywayDockerContainer.php | 8 +- .../TestableGenericDockerContainer.php | 11 +- .../TestableMySQLDockerContainer.php | 10 +- .../Waits/ContainerWaitForDependencyTest.php | 100 +- tests/Unit/Waits/ContainerWaitForTimeTest.php | 26 +- 106 files changed, 3554 insertions(+), 2745 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 SECURITY.md create mode 100644 phpcs.xml rename src/{Contracts => }/Address.php (96%) rename src/{Contracts => }/ContainerStarted.php (88%) create mode 100644 src/EnvironmentFlag.php rename src/{Contracts => }/EnvironmentVariables.php (90%) rename src/{Contracts => }/ExecutionCompleted.php (81%) create mode 100644 src/Internal/Containers/ContainerId.php rename src/Internal/Containers/{Models => }/Image.php (54%) delete mode 100644 src/Internal/Containers/Models/ContainerId.php rename src/Internal/Containers/{Models => }/Name.php (78%) create mode 100644 src/Internal/Containers/RegisteredShutdownHook.php create mode 100644 src/Internal/Exceptions/ContainerIdEmpty.php create mode 100644 src/Internal/Exceptions/ContainerIdTooShort.php create mode 100644 src/Internal/Exceptions/ImageNameEmpty.php rename src/{Contracts => }/MySQL/MySQLContainerStarted.php (88%) rename src/{Contracts => }/Ports.php (96%) rename tests/{Unit/Mocks => Models}/InspectResponseFixture.php (92%) rename tests/Unit/{Mocks => }/ClientMock.php (79%) rename tests/Unit/{Mocks => }/CommandMock.php (92%) rename tests/Unit/{Mocks => }/CommandWithTimeoutMock.php (94%) create mode 100644 tests/Unit/EnvironmentFlagTest.php rename tests/Unit/{Mocks => }/ExecutionCompletedMock.php (81%) create mode 100644 tests/Unit/HostEnvironmentTest.php delete mode 100644 tests/Unit/Internal/Commands/DockerCommandsTest.php delete mode 100644 tests/Unit/Internal/Containers/Address/AddressTest.php delete mode 100644 tests/Unit/Internal/Containers/Address/PortsTest.php delete mode 100644 tests/Unit/Internal/Containers/ContainerReaperTest.php delete mode 100644 tests/Unit/Internal/Containers/ShutdownHookTest.php create mode 100644 tests/Unit/MySQLCommandsTest.php create mode 100644 tests/Unit/RegisteredShutdownHookTest.php create mode 100644 tests/Unit/RunningMySQLContainer.php rename tests/Unit/{Mocks => }/ShutdownHookMock.php (81%) rename tests/Unit/{Mocks => }/TestableFlywayDockerContainer.php (58%) rename tests/Unit/{Mocks => }/TestableGenericDockerContainer.php (76%) rename tests/Unit/{Mocks => }/TestableMySQLDockerContainer.php (87%) diff --git a/.editorconfig b/.editorconfig index 73e3c9a..be5640e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,6 +5,7 @@ charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space +max_line_length = 120 insert_final_newline = true trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes index 744a43b..275cece 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,21 +2,21 @@ *.php text diff=php -# Dev-only — excluded from the Packagist tarball +# Keep Claude tooling scripts out of GitHub's language statistics +/.claude/**/*.py linguist-vendored + +# Dev-only, excluded from the Packagist tarball /.github export-ignore /tests export-ignore /.claude export-ignore +/CLAUDE.md export-ignore /.editorconfig export-ignore /.gitattributes export-ignore /.gitignore export-ignore +/phpcs.xml export-ignore /phpunit.xml export-ignore -/phpunit.xml.dist export-ignore -/phpstan.neon export-ignore /phpstan.neon.dist export-ignore -/phpcs.xml export-ignore -/phpcs.xml.dist export-ignore -/infection.json export-ignore /infection.json.dist export-ignore /Makefile export-ignore -/CONTRIBUTING.md export-ignore -/CHANGES.md export-ignore +/reports export-ignore +/.phpunit.cache export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8ddd1db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report a bug to help improve the library +labels: bug +--- + +## Description + +A clear and concise description of the bug. + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +What should happen. + +## Actual behavior + +What actually happens. + +## Environment + +- PHP version: +- Library version: +- OS: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b344d9e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest a feature for the library +labels: enhancement +--- + +## Problem + +What problem does this feature solve? + +## Proposed solution + +How should the feature work? + +## Alternatives considered + +Other approaches considered. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7a2c836 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md). + +## Summary + +What this pull request does. + +## Related issue + +Closes #... + +## Checklist + +- [ ] Tests added or updated. +- [ ] Documentation updated when applicable. +- [ ] `composer review` passes. +- [ ] `composer tests` passes. diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index d0ba49e..a7e4110 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -8,12 +8,19 @@ on: types: - opened +concurrency: + group: auto-assign-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + issues: write + pull-requests: write + jobs: - run: + auto-assign: + name: Assign issues and pull requests runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write + timeout-minutes: 5 steps: - name: Assign issues and pull requests uses: gustavofreze/auto-assign@2.1.0 @@ -22,4 +29,4 @@ jobs: github_token: '${{ secrets.GITHUB_TOKEN }}' allow_self_assign: 'true' allow_no_assignees: 'true' - assignment_options: 'ISSUE,PULL_REQUEST' \ No newline at end of file + assignment_options: 'ISSUE,PULL_REQUEST' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de58e7f..af3b92d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,26 +3,44 @@ name: CI on: pull_request: +concurrency: + group: ci-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read -env: - PHP_VERSION: '8.5' - jobs: + resolve-php-version: + name: Resolve PHP version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + php-version: ${{ steps.config.outputs.php-version }} + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Resolve PHP version from composer.json + id: config + run: | + version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1) + echo "php-version=$version" >> "$GITHUB_OUTPUT" + build: name: Build + needs: resolve-php-version runs-on: ubuntu-latest - + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Validate composer.json run: composer validate --no-interaction @@ -40,18 +58,18 @@ jobs: auto-review: name: Auto review + needs: [resolve-php-version, build] runs-on: ubuntu-latest - needs: build - + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Download vendor artifact from build uses: actions/download-artifact@v8 @@ -64,18 +82,12 @@ jobs: tests: name: Tests + needs: [resolve-php-version, auto-review] runs-on: ubuntu-latest - needs: build - + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 - - - name: Configure PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ env.PHP_VERSION }} - tools: composer:2 + uses: actions/checkout@v7 - name: Download vendor artifact from build uses: actions/download-artifact@v8 @@ -99,4 +111,4 @@ jobs: -v ${PWD}/tests/Integration/Database/Migrations:/test-adm-migrations \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /app \ - gustavofreze/php:${{ env.PHP_VERSION }}-alpine bash -c "composer tests" + gustavofreze/php:${{ needs.resolve-php-version.outputs.php-version }}-alpine bash -c "composer tests" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4c6d7f7..6cd0acd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,6 +8,10 @@ on: schedule: - cron: "0 0 * * *" +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + permissions: actions: read contents: read @@ -17,6 +21,7 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -24,7 +29,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.gitignore b/.gitignore index bd5baa3..aa218fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,32 @@ -# Agent/IDE -.claude/ -.idea/ -.vscode/ -.cursor/ - -# Composer +# PHP dependencies /vendor/ composer.lock -# PHPUnit / coverage +# Local config overrides (committed baselines are the .dist files) +/phpstan.neon +/infection.json + +# Tooling cache .phpunit.cache/ .phpunit.result.cache -report/ -coverage/ + +# Coverage and reports build/ +reports/ +coverage/ +infection.log + +# Editors and agents +.idea/ +.cursor/ +.vscode/ +/.claude/settings.local.json # OS -.DS_Store Thumbs.db +.DS_Store +Desktop.ini + +# Python bytecode cache from .claude hooks +__pycache__/ +*.pyc diff --git a/Makefile b/Makefile index 1848900..71ec8f8 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,9 @@ ifeq ($(ARCH),arm64) PLATFORM := --platform=linux/amd64 endif -DOCKER_RUN = docker run ${PLATFORM} -u root --rm -it --network=tiny-blocks --name test-lib \ +TTY := $(shell [ -t 0 ] && echo -it) + +DOCKER_RUN = docker run ${PLATFORM} -u root --rm ${TTY} --network=tiny-blocks --name test-lib \ -v ${PWD}:/app \ -v ${PWD}/tests/Integration/Database/Migrations:/test-adm-migrations \ -v /var/run/docker.sock:/var/run/docker.sock \ @@ -20,21 +22,20 @@ YELLOW := \033[0;33m .PHONY: configure configure: configure-test-environment ## Configure development environment - @${DOCKER_RUN} composer update --optimize-autoloader - @${DOCKER_RUN} composer normalize + @${DOCKER_RUN} composer configure + +.PHONY: configure-and-update +configure-and-update: configure-test-environment ## Configure development environment and update dependencies + @${DOCKER_RUN} composer configure-and-update -.PHONY: test -test: configure-test-environment ## Run all tests with coverage +.PHONY: tests +tests: configure-test-environment ## Run unit and mutation tests with coverage @${DOCKER_RUN} composer tests .PHONY: test-file test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) @${DOCKER_RUN} composer test-file ${FILE} -.PHONY: test-no-coverage -test-no-coverage: configure-test-environment ## Run all tests without coverage - @${DOCKER_RUN} composer tests-no-coverage - .PHONY: configure-test-environment configure-test-environment: @if ! docker network inspect tiny-blocks > /dev/null 2>&1; then \ @@ -42,12 +43,12 @@ configure-test-environment: fi .PHONY: review -review: ## Run static code analysis +review: ## Run lint and static analysis @${DOCKER_RUN} composer review .PHONY: show-reports -show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser - @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html +show-reports: ## Open coverage and mutation reports in the browser + @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html .PHONY: show-outdated show-outdated: ## Show outdated direct dependencies @@ -56,28 +57,28 @@ show-outdated: ## Show outdated direct dependencies .PHONY: clean clean: ## Remove dependencies and generated artifacts @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf report vendor .phpunit.cache *.lock + @rm -rf reports vendor .phpunit.cache *.lock .PHONY: help -help: ## Display this help message +help: ## Display this help message @echo "Usage: make [target]" @echo "" @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" - @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" - @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" @grep -E '^(show-reports|show-outdated):.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' diff --git a/README.md b/README.md index b08abbe..5bee7f1 100644 --- a/README.md +++ b/README.md @@ -5,32 +5,36 @@ * [Overview](#overview) * [Installation](#installation) * [How to use](#how-to-use) - * [Creating a container](#creating-a-container) - * [Running a container](#running-a-container) - * [Running if not exists](#running-if-not-exists) - * [Pulling images in parallel](#pulling-images-in-parallel) - * [Setting network](#setting-network) - * [Setting port mappings](#setting-port-mappings) - * [Setting volume mappings](#setting-volume-mappings) - * [Setting environment variables](#setting-environment-variables) - * [Disabling auto-remove](#disabling-auto-remove) - * [Copying files to a container](#copying-files-to-a-container) - * [Stopping a container](#stopping-a-container) - * [Stopping on shutdown](#stopping-on-shutdown) - * [Executing commands after startup](#executing-commands-after-startup) - * [Wait strategies](#wait-strategies) -* [MySQL container](#mysql-container) - * [Configuring MySQL options](#configuring-mysql-options) - * [Setting readiness timeout](#setting-readiness-timeout) - * [Retrieving connection data](#retrieving-connection-data) - * [Environment-aware connection](#environment-aware-connection) -* [Flyway container](#flyway-container) - * [Setting the database source](#setting-the-database-source) - * [Configuring migrations](#configuring-migrations) - * [Configuring Flyway options](#configuring-flyway-options) - * [Running Flyway commands](#running-flyway-commands) -* [Usage examples](#usage-examples) - * [MySQL with Flyway migrations](#mysql-with-flyway-migrations) + + [Creating a container](#creating-a-container) + + [Running a container](#running-a-container) + + [Running if not exists](#running-if-not-exists) + + [Running conditionally](#running-conditionally) + + [Checking if the container was reused](#checking-if-the-container-was-reused) + + [Pulling images in parallel](#pulling-images-in-parallel) + + [Setting network](#setting-network) + + [Setting port mappings](#setting-port-mappings) + + [Setting volume mappings](#setting-volume-mappings) + + [Setting environment variables](#setting-environment-variables) + + [Disabling auto-remove](#disabling-auto-remove) + + [Copying files to a container](#copying-files-to-a-container) + + [Stopping a container](#stopping-a-container) + + [Stopping on shutdown](#stopping-on-shutdown) + + [Executing commands after startup](#executing-commands-after-startup) + + [Wait strategies](#wait-strategies) + - [Waiting for a fixed time](#waiting-for-a-fixed-time) + - [Waiting for a dependency](#waiting-for-a-dependency) + + [MySQL container](#mysql-container) + - [Configuring MySQL options](#configuring-mysql-options) + - [Setting readiness timeout](#setting-readiness-timeout) + - [Retrieving connection data](#retrieving-connection-data) + - [Environment-aware connection](#environment-aware-connection) + + [Flyway container](#flyway-container) + - [Setting the database source](#setting-the-database-source) + - [Configuring migrations](#configuring-migrations) + - [Configuring Flyway options](#configuring-flyway-options) + - [Running Flyway commands](#running-flyway-commands) + + [Usage examples](#usage-examples) + - [MySQL with Flyway migrations](#mysql-with-flyway-migrations) * [License](#license) * [Contributing](#contributing) @@ -53,6 +57,10 @@ composer require tiny-blocks/docker-container Creates a container from a specified image and an optional name. ```php +run(commands: ['ls', '-la']); With commands and a wait strategy: ```php +run(commands: ['ls', '-la'], waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 5)); @@ -89,6 +101,45 @@ Starts a container only if a container with the same name is not already running $container->runIfNotExists(); ``` +### Running conditionally + +Runs the container only when a predicate holds, reusing an existing one if present, then hands the started container +to a callback. Useful to gate side effects such as running migrations behind a flag, without scattering `if` +statements through the bootstrap. When the predicate does not hold, nothing runs. + +```php +runWhen( + gate: EnvironmentFlag::enabled(name: 'RUN_MIGRATIONS'), + then: static function (ContainerStarted $started): void { + # Runs only when RUN_MIGRATIONS=1, with $started ready to use. + } +); +``` + +The gate is any predicate, so a custom condition works as well: + +```php +$container->runWhen(gate: static fn(): bool => $featureToggle->isOn(), then: $migrate); +``` + +### Checking if the container was reused + +Tells whether the started container came from an already-running instance. This distinguishes a fresh start from a +reuse, so first-boot work such as seeding or migrations runs only once. + +```php +$started = $container->runIfNotExists(); + +$alreadyRunning = $started->wasReused(); +``` + ### Pulling images in parallel Calling `pullImage()` starts downloading the image in the background via a non-blocking process. When `run()` or @@ -98,6 +149,10 @@ To pull multiple images in parallel, call `pullImage()` on all containers **befo them. This way the downloads happen concurrently: ```php +runIfNotExists(); # Flyway pull already finished while MySQL was starting. -$flyway->withSource(container: $mySQLStarted, username: 'root', password: 'root') +$flyway->withSource(password: 'root', username: 'root', container: $mySQLStarted) ->cleanAndMigrate(); ``` @@ -233,6 +288,10 @@ $result->isSuccessful(); Pauses execution for a specified number of seconds before or after starting a container. ```php +withWaitBeforeRun(wait: ContainerWaitForTime::forSeconds(seconds: 3)); @@ -244,6 +303,10 @@ Blocks until a readiness condition is satisfied, with a configurable timeout. Th depends on another being fully ready. ```php +run(); ``` -## MySQL container +### MySQL container `MySQLDockerContainer` provides a specialized container for MySQL databases. It extends the generic container with MySQL-specific configuration and automatic readiness detection. -### Configuring MySQL options +#### Configuring MySQL options | Method | Parameter | Description | |--------------------|-----------------|-----------------------------------------------------------------| @@ -280,6 +343,10 @@ MySQL-specific configuration and automatic readiness detection. | `withGrantedHosts` | `$hosts` | Sets hosts granted root privileges (default: `['%', '172.%']`). | ```php +run(); ``` -### Setting readiness timeout +#### Setting readiness timeout Configures how long the MySQL container waits for the database to become ready before throwing a `ContainerWaitTimeout` exception. The default timeout is 30 seconds. ```php +run(); ``` -### Retrieving connection data +#### Retrieving connection data After the MySQL container starts, connection details are available through the `MySQLContainerStarted` instance. ```php +getAddress(); $ip = $address->getIp(); $hostname = $address->getHostname(); @@ -331,13 +406,17 @@ $jdbcUrl = $mySQLContainer->getJdbcUrl(); Use `firstExposedPort()` when connecting from another container in the same network. Use `firstHostPort()` when connecting from the host machine (e.g., tests running outside Docker). -### Environment-aware connection +#### Environment-aware connection The `Address` and `Ports` contracts provide environment-aware methods that automatically resolve the correct host and port for connecting to a container. These methods detect whether the caller is running inside Docker or on the host machine: ```php +withNetwork(name: 'my-network') ->withMigrations(pathOnHost: '/path/to/migrations') - ->withSource(container: $mySQLStarted, username: 'root', password: 'root'); + ->withSource(password: 'root', username: 'root', container: $mySQLStarted); ``` The schema and table can be overridden after calling `withSource()`: ```php +withSource(container: $mySQLStarted, username: 'root', password: 'root') + ->withSource(password: 'root', username: 'root', container: $mySQLStarted) ->withSchema(schema: 'custom_schema') ->withTable(table: 'custom_history'); ``` -### Configuring migrations +#### Configuring migrations Sets the host directory containing Flyway migration SQL files. The files are copied into the container at `/flyway/migrations`. @@ -393,7 +480,7 @@ Sets the host directory containing Flyway migration SQL files. The files are cop $flywayContainer->withMigrations(pathOnHost: '/path/to/migrations'); ``` -### Configuring Flyway options +#### Configuring Flyway options | Method | Parameter | Description | |-------------------------------|-------------|------------------------------------------------------------------| @@ -403,7 +490,7 @@ $flywayContainer->withMigrations(pathOnHost: '/path/to/migrations'); | `withConnectRetries` | `$retries` | Sets the number of database connection retries. | | `withValidateMigrationNaming` | `$enabled` | Enables or disables migration naming validation. | -### Running Flyway commands +#### Running Flyway commands | Method | Flyway command | Description | |---------------------|-----------------|----------------------------------------------| @@ -417,18 +504,22 @@ $flywayContainer->migrate(); $flywayContainer->cleanAndMigrate(); ``` -## Usage examples +### Usage examples - When running the containers from the library on a host (your local machine), map the volume `/var/run/docker.sock:/var/run/docker.sock` so the container has access to the Docker daemon on the host machine. - In some cases, it may be necessary to add the `docker-cli` dependency to your PHP image to interact with Docker from within the container. -### MySQL with Flyway migrations +#### MySQL with Flyway migrations Configure both containers and start image pulls in parallel before running either one: ```php +runIfNotExists(); $mySQLStarted->stopOnShutdown(); $flywayContainer - ->withSource(container: $mySQLStarted, username: 'root', password: 'root') + ->withSource(password: 'root', username: 'root', container: $mySQLStarted) ->cleanAndMigrate(); ``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..17c2086 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported versions + +Only the latest release receives security updates. + +## Reporting a vulnerability + +Report security vulnerabilities privately via +[GitHub Security Advisories](https://github.com/tiny-blocks/docker-container/security/advisories/new). + +Please do not disclose the vulnerability publicly until it has been addressed. diff --git a/composer.json b/composer.json index 9a65af5..7879114 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,13 @@ "description": "Manages Docker containers programmatically for PHP, aimed at integration tests and disposable infrastructure.", "license": "MIT", "type": "library", + "keywords": [ + "docker", + "container", + "tiny-blocks", + "integration-tests", + "disposable-infrastructure" + ], "authors": [ { "name": "Gustavo Freze de Araujo Santos", @@ -16,14 +23,14 @@ }, "require": { "php": "^8.5", - "symfony/process": "^8.0", - "tiny-blocks/collection": "^2.3", - "tiny-blocks/ksuid": "^1.5" + "symfony/process": "^8.1", + "tiny-blocks/collection": "^2.5", + "tiny-blocks/ksuid": "^2.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.51", - "infection/infection": "^0.32", - "phpstan/phpstan": "^2.1", + "ergebnis/composer-normalize": "^2.52", + "infection/infection": "^0.33", + "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^13.1", "squizlabs/php_codesniffer": "^4.0" }, @@ -48,22 +55,22 @@ "sort-packages": true }, "scripts": { - "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", - "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", - "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", + "configure": [ + "@composer install --optimize-autoloader", + "@composer normalize" + ], + "configure-and-update": [ + "@composer update --optimize-autoloader", + "@composer normalize" + ], "review": [ - "@phpcs", - "@phpstan" + "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", + "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress" ], - "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", - "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", - "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --testsuite=unit", + "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", "tests": [ - "@test", - "@mutation-test" - ], - "tests-no-coverage": [ - "@test-no-coverage" + "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" ] } } diff --git a/infection.json.dist b/infection.json.dist index 0e2cd7b..80412d9 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,9 +1,9 @@ { "logs": { - "text": "report/infection/logs/infection-text.log", - "summary": "report/infection/logs/infection-summary.log" + "text": "reports/infection/logs/infection-text.log", + "summary": "reports/infection/logs/infection-summary.log" }, - "tmpDir": "report/infection/cache/", + "tmpDir": "reports/infection/cache/", "minMsi": 100, "timeout": 120, "source": { diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..a52372c --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,7 @@ + + + Code style for the tiny-blocks library. + + src + tests + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b67947e..d7dd73a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,15 +1,110 @@ parameters: - paths: - - src - level: 9 - tmpDir: report/phpstan - ignoreErrors: - - '#Parameter ...#' - - '#Cannot access#' - - '#Cannot cast mixed to#' - - '#Cannot access property#' - - '#Named argument actions for#' - - '#Unsafe usage of new static#' - - '#Access to an undefined property#' - - '#type specified in iterable type#' - reportUnmatchedIgnoredErrors: false + level: max + paths: + - src + - tests + reportUnmatchedIgnoredErrors: true + ignoreErrors: + # Late static binding in extension-point container factories that consumers subclass. + - identifier: new.static + paths: + - src/FlywayDockerContainer.php + - src/GenericDockerContainer.php + - src/MySQLDockerContainer.php + + # tiny-blocks/collection generics carry a mixed TValue on Internal command and definition types. + - identifier: missingType.generics + paths: + - src/Internal/Commands/DockerExecute.php + - src/Internal/Commands/DockerRun.php + - src/Internal/Containers/Address/Ports.php + - src/Internal/Containers/Definitions/ContainerDefinition.php + - src/Internal/Containers/Environment/EnvironmentVariables.php + + # Internal collaborators take raw config arrays at the boundary, where PHPDoc is prohibited. + - identifier: missingType.iterableValue + paths: + - src/Internal/Commands/DockerExecute.php + - src/Internal/Commands/DockerRun.php + - src/Internal/Containers/Address/Ports.php + - src/Internal/Containers/ContainerInspection.php + - src/Internal/Containers/Definitions/CopyInstruction.php + - src/Internal/Containers/Definitions/EnvironmentVariable.php + - src/Internal/Containers/Definitions/PortMapping.php + - src/Internal/Containers/Definitions/VolumeMapping.php + - src/Internal/Containers/Drivers/MySQL/MySQLCommands.php + - src/Internal/Containers/Environment/EnvironmentVariables.php + + # Internal command builders return argument lists assembled from collection mixed values. + - identifier: return.type + paths: + - src/Internal/Commands/DockerCopy.php + - src/Internal/Commands/DockerExecute.php + - src/Internal/Commands/DockerRun.php + - src/Internal/Containers/Address/Ports.php + + # Collection each/reduce pass concrete closures against the mixed-typed variadic API. + - identifier: argument.type + paths: + - src/Internal/CommandHandler/ContainerCommandHandler.php + - src/Internal/Commands/DockerRun.php + - src/Internal/Containers/Address/Ports.php + + # docker inspect json_decode and sprintf feed mixed values across the system boundary. + - identifier: argument.type + paths: + - src/Internal/Containers/ContainerInspection.php + - src/Internal/Containers/Drivers/MySQL/MySQLCommands.php + + # gethostname() returns string|false at the system boundary when the host name is unavailable. + - identifier: argument.type + path: src/Internal/Containers/ContainerReaper.php + + # docker inspect json_decode produces mixed offsets read in ContainerInspection. + - identifier: offsetAccess.nonOffsetAccessible + path: src/Internal/Containers/ContainerInspection.php + + # docker inspect json_decode yields a mixed value cast to int at the boundary. + - identifier: cast.int + path: src/Internal/Containers/ContainerInspection.php + + # docker inspect json_decode yields a mixed value iterated at the boundary. + - identifier: foreach.nonIterable + path: src/Internal/Containers/ContainerInspection.php + + # Environment variable values arrive as mixed and are cast to string at the boundary. + - identifier: cast.string + path: src/Internal/Containers/Environment/EnvironmentVariables.php + + # Fixtures and mocks accept raw arrays, where PHPDoc is prohibited inside tests. + - identifier: missingType.iterableValue + paths: + - tests/Integration/MySQLRepository.php + - tests/Models/InspectResponseFixture.php + - tests/Unit/ClientMock.php + + # ClientMock and fixtures hand mixed values to typed sinks and assertions in tests. + - identifier: argument.type + paths: + - tests/Unit/ClientMock.php + - tests/Unit/FlywayDockerContainerTest.php + - tests/Unit/GenericDockerContainerTest.php + - tests/Unit/MySQLDockerContainerTest.php + + # ClientMock casts a mixed recorded value to string when building command output. + - identifier: cast.string + path: tests/Unit/ClientMock.php + + # ClientMock indexes into a mixed recorded payload when replaying responses. + - identifier: offsetAccess.nonArray + path: tests/Unit/ClientMock.php + + # MySQLRepository reads a mixed PDO result as an object at the system boundary. + - identifier: method.nonObject + path: tests/Integration/MySQLRepository.php + + # Mock-verified and no-exception tests assert a literal true to satisfy the assertion count. + - identifier: staticMethod.alreadyNarrowedType + paths: + - tests/Unit/GenericDockerContainerTest.php + - tests/Unit/Waits/ContainerWaitForDependencyTest.php diff --git a/phpunit.xml b/phpunit.xml index fee0f4e..a038bc4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,16 @@ + colors="true" + executionOrder="random" + failOnDeprecation="true" + failOnNotice="true" + failOnPhpunitDeprecation="true" + failOnRisky="true" + failOnWarning="true"> @@ -26,15 +29,15 @@ - - - - + + + + - + diff --git a/src/Contracts/Address.php b/src/Address.php similarity index 96% rename from src/Contracts/Address.php rename to src/Address.php index 92a98b2..be7aa96 100644 --- a/src/Contracts/Address.php +++ b/src/Address.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\DockerContainer\Contracts; +namespace TinyBlocks\DockerContainer; /** * Represents the network address of a running Docker container. diff --git a/src/Contracts/ContainerStarted.php b/src/ContainerStarted.php similarity index 88% rename from src/Contracts/ContainerStarted.php rename to src/ContainerStarted.php index e2bd6a9..8ee5518 100644 --- a/src/Contracts/ContainerStarted.php +++ b/src/ContainerStarted.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\DockerContainer\Contracts; +namespace TinyBlocks\DockerContainer; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; @@ -17,47 +17,47 @@ interface ContainerStarted public const int DEFAULT_TIMEOUT_IN_WHOLE_SECONDS = 300; /** - * Returns the unique identifier of the container. + * Stops the running container gracefully. * - * @return string The container ID. + * @param int $timeoutInWholeSeconds The maximum time in seconds to wait for the container to stop. + * @return ExecutionCompleted The result of the stop command execution. + * @throws DockerCommandExecutionFailed If the stop command fails. */ - public function getId(): string; + public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted; /** - * Returns the name assigned to the container. + * Returns the unique identifier of the container. * - * @return string The container name. + * @return string The container ID. */ - public function getName(): string; + public function getId(): string; /** - * Returns the network address of the container. - * - * @return Address The container's network address. + * Forcefully removes the container and its anonymous volumes, then prunes + * unused networks created by the library. */ - public function getAddress(): Address; + public function remove(): void; /** - * Returns the environment variables configured in the container. + * Returns the name assigned to the container. * - * @return EnvironmentVariables The container's environment variables. + * @return string The container name. */ - public function getEnvironmentVariables(): EnvironmentVariables; + public function getName(): string; /** - * Stops the running container gracefully. + * Tells whether the container was reused from an already-running instance. * - * @param int $timeoutInWholeSeconds The maximum time in seconds to wait for the container to stop. - * @return ExecutionCompleted The result of the stop command execution. - * @throws DockerCommandExecutionFailed If the stop command fails. + * @return bool True when an existing container was reused, false when a new one was started. */ - public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted; + public function wasReused(): bool; /** - * Forcefully removes the container and its anonymous volumes, then prunes - * unused networks created by the library. + * Returns the network address of the container. + * + * @return Address The container's network address. */ - public function remove(): void; + public function getAddress(): Address; /** * Registers the container to be removed when the PHP process exits. @@ -72,4 +72,11 @@ public function stopOnShutdown(): void; * @throws DockerCommandExecutionFailed If the command execution fails. */ public function executeAfterStarted(array $commands): ExecutionCompleted; + + /** + * Returns the environment variables configured in the container. + * + * @return EnvironmentVariables The container's environment variables. + */ + public function getEnvironmentVariables(): EnvironmentVariables; } diff --git a/src/DockerContainer.php b/src/DockerContainer.php index 21cdb40..66375da 100644 --- a/src/DockerContainer.php +++ b/src/DockerContainer.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; +use Closure; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; use TinyBlocks\DockerContainer\Waits\ContainerWaitAfterStarted; use TinyBlocks\DockerContainer\Waits\ContainerWaitBeforeStarted; @@ -17,7 +17,7 @@ interface DockerContainer /** * Creates a new container instance from the given Docker image. * - * @param string $image The Docker image name (e.g., "mysql:8.1"). + * @param string $image The Docker image name (e.g., "mysql:8.4"). * @param string|null $name An optional name for the container. * @return static A new container instance. */ @@ -35,21 +35,22 @@ public static function from(string $image, ?string $name = null): static; public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted; /** - * Runs the container only if a container with the same name does not already exist. - * The returned instance treats the container as shared: calling stopOnShutdown() or - * remove() on it has no effect, allowing the container to persist across multiple - * PHP processes (e.g., mutation testing). + * Runs the container only when the gate predicate holds, reusing an existing one if present, + * then passes the started container to the callback. Does nothing when the gate does not hold. * + * @param Closure(): bool $gate Decides whether the container should run. + * @param Closure(ContainerStarted): void $then Receives the started container when the gate holds. * @param array $commands Commands to execute on container startup. * @param ContainerWaitAfterStarted|null $waitAfterStarted Optional wait strategy applied after * the container starts. - * @return ContainerStarted The started container instance (existing or newly created). * @throws DockerCommandExecutionFailed If the run command fails. */ - public function runIfNotExists( + public function runWhen( + Closure $gate, + Closure $then, array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null - ): ContainerStarted; + ): void; /** * Starts pulling the container image in the background. When run() or runIfNotExists() @@ -60,15 +61,6 @@ public function runIfNotExists( */ public function pullImage(): static; - /** - * Registers a file or directory to be copied into the container after it starts. - * - * @param string $pathOnHost The absolute path on the host. - * @param string $pathOnContainer The target path inside the container. - * @return static The current container instance for method chaining. - */ - public function copyToContainer(string $pathOnHost, string $pathOnContainer): static; - /** * Sets the Docker network the container should join. The network is created * automatically when the container is started via run() or runIfNotExists(), @@ -79,6 +71,32 @@ public function copyToContainer(string $pathOnHost, string $pathOnContainer): st */ public function withNetwork(string $name): static; + /** + * Runs the container only if a container with the same name does not already exist. + * The returned instance treats the container as shared: calling stopOnShutdown() or + * remove() on it has no effect, allowing the container to persist across multiple + * PHP processes (e.g., mutation testing). + * + * @param array $commands Commands to execute on container startup. + * @param ContainerWaitAfterStarted|null $waitAfterStarted Optional wait strategy applied after + * the container starts. + * @return ContainerStarted The started container instance (existing or newly created). + * @throws DockerCommandExecutionFailed If the run command fails. + */ + public function runIfNotExists( + array $commands = [], + ?ContainerWaitAfterStarted $waitAfterStarted = null + ): ContainerStarted; + + /** + * Registers a file or directory to be copied into the container after it starts. + * + * @param string $pathOnHost The absolute path on the host. + * @param string $pathOnContainer The target path inside the container. + * @return static The current container instance for method chaining. + */ + public function copyToContainer(string $pathOnHost, string $pathOnContainer): static; + /** * Adds a port mapping between the host and the container. * @@ -89,28 +107,28 @@ public function withNetwork(string $name): static; public function withPortMapping(int $portOnHost, int $portOnContainer): static; /** - * Sets a wait strategy to be applied before the container runs. + * Adds a volume mapping between the host and the container. * - * @param ContainerWaitBeforeStarted $wait The wait strategy to apply before starting. + * @param string $pathOnHost The absolute path on the host. + * @param string $pathOnContainer The target path inside the container. * @return static The current container instance for method chaining. */ - public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static; + public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): static; /** - * Disables automatic removal of the container when it stops. + * Sets a wait strategy to be applied before the container runs. * + * @param ContainerWaitBeforeStarted $wait The wait strategy to apply before starting. * @return static The current container instance for method chaining. */ - public function withoutAutoRemove(): static; + public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static; /** - * Adds a volume mapping between the host and the container. + * Disables automatic removal of the container when it stops. * - * @param string $pathOnHost The absolute path on the host. - * @param string $pathOnContainer The target path inside the container. * @return static The current container instance for method chaining. */ - public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): static; + public function withoutAutoRemove(): static; /** * Adds an environment variable to the container. diff --git a/src/EnvironmentFlag.php b/src/EnvironmentFlag.php new file mode 100644 index 0000000..3f57c8e --- /dev/null +++ b/src/EnvironmentFlag.php @@ -0,0 +1,28 @@ + getenv($name) === '1'; + } +} diff --git a/src/Contracts/EnvironmentVariables.php b/src/EnvironmentVariables.php similarity index 90% rename from src/Contracts/EnvironmentVariables.php rename to src/EnvironmentVariables.php index e7a3d95..e45a6d8 100644 --- a/src/Contracts/EnvironmentVariables.php +++ b/src/EnvironmentVariables.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\DockerContainer\Contracts; +namespace TinyBlocks\DockerContainer; /** * Represents the environment variables configured in a Docker container. diff --git a/src/Contracts/ExecutionCompleted.php b/src/ExecutionCompleted.php similarity index 81% rename from src/Contracts/ExecutionCompleted.php rename to src/ExecutionCompleted.php index 59a07f9..b0f9246 100644 --- a/src/Contracts/ExecutionCompleted.php +++ b/src/ExecutionCompleted.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\DockerContainer\Contracts; +namespace TinyBlocks\DockerContainer; /** * Represents the result of a Docker command execution. @@ -17,7 +17,7 @@ interface ExecutionCompleted public function getOutput(): string; /** - * Indicates whether the command execution was successful. + * Tells whether the command execution was successful. * * @return bool True if the execution was successful, false otherwise. */ diff --git a/src/FlywayContainer.php b/src/FlywayContainer.php index 96d253e..3ef1cca 100644 --- a/src/FlywayContainer.php +++ b/src/FlywayContainer.php @@ -4,9 +4,8 @@ namespace TinyBlocks\DockerContainer; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; -use TinyBlocks\DockerContainer\Contracts\MySQL\MySQLContainerStarted; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; +use TinyBlocks\DockerContainer\MySQL\MySQLContainerStarted; /** * Defines the contract for building and running a Flyway Docker container. @@ -23,20 +22,20 @@ interface FlywayContainer public static function from(string $image, ?string $name = null): static; /** - * Runs the Flyway migrate command. + * Runs the Flyway repair command. * * @return ContainerStarted The started container instance. * @throws DockerCommandExecutionFailed If the command fails. */ - public function migrate(): ContainerStarted; + public function repair(): ContainerStarted; /** - * Runs the Flyway repair command. + * Runs the Flyway migrate command. * * @return ContainerStarted The started container instance. * @throws DockerCommandExecutionFailed If the command fails. */ - public function repair(): ContainerStarted; + public function migrate(): ContainerStarted; /** * Runs the Flyway validate command. @@ -70,34 +69,26 @@ public function withTable(string $table): static; */ public function withSchema(string $schema): static; - /** - * Connects the container to a specific Docker network. - * - * @param string $name The name of the Docker network. - * @return static The current container instance for method chaining. - */ - public function withNetwork(string $name): static; - /** * Sets the database source from a started MySQL container. Automatically configures * the JDBC URL, credentials, target schema (from MYSQL_DATABASE), and history table * (defaults to "schema_history"). Use withSchema() or withTable() after this method * to override the defaults. * - * @param MySQLContainerStarted $container The running MySQL container. - * @param string $username The database username. * @param string $password The database password. + * @param string $username The database username. + * @param MySQLContainerStarted $container The running MySQL container. * @return static The current container instance for method chaining. */ - public function withSource(MySQLContainerStarted $container, string $username, string $password): static; + public function withSource(string $password, string $username, MySQLContainerStarted $container): static; /** - * Configures whether Flyway's clean command is disabled. + * Connects the container to a specific Docker network. * - * @param bool $disabled True to disable clean, false to allow it. + * @param string $name The name of the Docker network. * @return static The current container instance for method chaining. */ - public function withCleanDisabled(bool $disabled): static; + public function withNetwork(string $name): static; /** * Sets the migration files from a host directory. @@ -108,20 +99,28 @@ public function withCleanDisabled(bool $disabled): static; public function withMigrations(string $pathOnHost): static; /** - * Sets the number of retries when connecting to the database. + * Runs the Flyway clean command followed by migrate. * - * @param int $retries The number of connection retries. + * @return ContainerStarted The started container instance. + * @throws DockerCommandExecutionFailed If the command fails. + */ + public function cleanAndMigrate(): ContainerStarted; + + /** + * Configures whether Flyway's clean command is disabled. + * + * @param bool $disabled True to disable clean, false to allow it. * @return static The current container instance for method chaining. */ - public function withConnectRetries(int $retries): static; + public function withCleanDisabled(bool $disabled): static; /** - * Runs the Flyway clean command followed by migrate. + * Sets the number of retries when connecting to the database. * - * @return ContainerStarted The started container instance. - * @throws DockerCommandExecutionFailed If the command fails. + * @param int $retries The number of connection retries. + * @return static The current container instance for method chaining. */ - public function cleanAndMigrate(): ContainerStarted; + public function withConnectRetries(int $retries): static; /** * Configures whether Flyway should validate migration naming conventions. diff --git a/src/FlywayDockerContainer.php b/src/FlywayDockerContainer.php index d0b3bd0..2f483f7 100644 --- a/src/FlywayDockerContainer.php +++ b/src/FlywayDockerContainer.php @@ -4,8 +4,7 @@ namespace TinyBlocks\DockerContainer; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; -use TinyBlocks\DockerContainer\Contracts\MySQL\MySQLContainerStarted; +use TinyBlocks\DockerContainer\MySQL\MySQLContainerStarted; use TinyBlocks\DockerContainer\Waits\Conditions\MySQL\MySQLReady; use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency; use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime; @@ -21,14 +20,14 @@ public static function from(string $image, ?string $name = null): static return new static(container: GenericDockerContainer::from(image: $image, name: $name)); } - public function migrate(): ContainerStarted + public function repair(): ContainerStarted { - return $this->container->run(commands: ['migrate']); + return $this->container->run(commands: ['repair']); } - public function repair(): ContainerStarted + public function migrate(): ContainerStarted { - return $this->container->run(commands: ['repair']); + return $this->container->run(commands: ['migrate']); } public function validate(): ContainerStarted @@ -57,23 +56,33 @@ public function withSchema(string $schema): static return $this; } - public function withNetwork(string $name): static + public function withSource(string $password, string $username, MySQLContainerStarted $container): static { - $this->container->withNetwork(name: $name); + $schema = $container->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE'); + + $this->container->withEnvironmentVariable(key: 'FLYWAY_URL', value: $container->getJdbcUrl()); + $this->container->withEnvironmentVariable(key: 'FLYWAY_USER', value: $username); + $this->container->withEnvironmentVariable(key: 'FLYWAY_TABLE', value: 'schema_history'); + $this->container->withEnvironmentVariable(key: 'FLYWAY_SCHEMAS', value: $schema); + $this->container->withEnvironmentVariable(key: 'FLYWAY_PASSWORD', value: $password); + $this->container->withWaitBeforeRun( + wait: ContainerWaitForDependency::untilReady(condition: MySQLReady::from(container: $container)) + ); return $this; } - public function withCleanDisabled(bool $disabled): static + public function withNetwork(string $name): static { - $this->container->withEnvironmentVariable(key: 'FLYWAY_CLEAN_DISABLED', value: $disabled ? 'true' : 'false'); + $this->container->withNetwork(name: $name); return $this; } - public function withConnectRetries(int $retries): static + public function withMigrations(string $pathOnHost): static { - $this->container->withEnvironmentVariable(key: 'FLYWAY_CONNECT_RETRIES', value: (string)$retries); + $this->container->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: '/flyway/migrations'); + $this->container->withEnvironmentVariable(key: 'FLYWAY_LOCATIONS', value: 'filesystem:/flyway/migrations'); return $this; } @@ -86,35 +95,25 @@ public function cleanAndMigrate(): ContainerStarted ); } - public function withMigrations(string $pathOnHost): static + public function withCleanDisabled(bool $disabled): static { - $this->container->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: '/flyway/migrations'); - $this->container->withEnvironmentVariable(key: 'FLYWAY_LOCATIONS', value: 'filesystem:/flyway/migrations'); + $this->container->withEnvironmentVariable(key: 'FLYWAY_CLEAN_DISABLED', value: $disabled ? 'true' : 'false'); return $this; } - public function withValidateMigrationNaming(bool $enabled): static + public function withConnectRetries(int $retries): static { - $this->container->withEnvironmentVariable( - key: 'FLYWAY_VALIDATE_MIGRATION_NAMING', - value: $enabled ? 'true' : 'false' - ); + $this->container->withEnvironmentVariable(key: 'FLYWAY_CONNECT_RETRIES', value: (string)$retries); return $this; } - public function withSource(MySQLContainerStarted $container, string $username, string $password): static + public function withValidateMigrationNaming(bool $enabled): static { - $schema = $container->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE'); - - $this->container->withEnvironmentVariable(key: 'FLYWAY_URL', value: $container->getJdbcUrl()); - $this->container->withEnvironmentVariable(key: 'FLYWAY_USER', value: $username); - $this->container->withEnvironmentVariable(key: 'FLYWAY_TABLE', value: 'schema_history'); - $this->container->withEnvironmentVariable(key: 'FLYWAY_SCHEMAS', value: $schema); - $this->container->withEnvironmentVariable(key: 'FLYWAY_PASSWORD', value: $password); - $this->container->withWaitBeforeRun( - wait: ContainerWaitForDependency::untilReady(condition: MySQLReady::from(container: $container)) + $this->container->withEnvironmentVariable( + key: 'FLYWAY_VALIDATE_MIGRATION_NAMING', + value: $enabled ? 'true' : 'false' ); return $this; diff --git a/src/GenericDockerContainer.php b/src/GenericDockerContainer.php index 044232e..fc48676 100644 --- a/src/GenericDockerContainer.php +++ b/src/GenericDockerContainer.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; +use Closure; use TinyBlocks\DockerContainer\Internal\Client\DockerClient; use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler; use TinyBlocks\DockerContainer\Internal\CommandHandler\ContainerCommandHandler; @@ -12,23 +12,20 @@ use TinyBlocks\DockerContainer\Internal\Commands\DockerRun; use TinyBlocks\DockerContainer\Internal\Containers\ContainerReaper; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; +use TinyBlocks\DockerContainer\Internal\Containers\RegisteredShutdownHook; use TinyBlocks\DockerContainer\Internal\Containers\Reused; -use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; use TinyBlocks\DockerContainer\Waits\ContainerWaitAfterStarted; use TinyBlocks\DockerContainer\Waits\ContainerWaitBeforeStarted; class GenericDockerContainer implements DockerContainer { - protected ContainerDefinition $definition; - private ?ContainerWaitBeforeStarted $waitBeforeStarted = null; protected function __construct( private readonly ContainerReaper $reaper, - ContainerDefinition $definition, + protected ContainerDefinition $definition, private readonly CommandHandler $commandHandler ) { - $this->definition = $definition; } public static function from(string $image, ?string $name = null): static @@ -36,44 +33,65 @@ public static function from(string $image, ?string $name = null): static $client = new DockerClient(); $definition = ContainerDefinition::create(image: $image, name: $name); $reaper = new ContainerReaper(client: $client); - $commandHandler = new ContainerCommandHandler(client: $client, shutdownHook: new ShutdownHook()); + $commandHandler = new ContainerCommandHandler(client: $client, shutdownHook: new RegisteredShutdownHook()); return new static(reaper: $reaper, definition: $definition, commandHandler: $commandHandler); } - public function withNetwork(string $name): static + public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted { - $this->definition = $this->definition->withNetwork(name: $name); + $this->waitBeforeStarted?->waitBefore(); - return $this; + $dockerRun = DockerRun::from(definition: $this->definition, commands: $commands); + $containerStarted = $this->commandHandler->run(dockerRun: $dockerRun); + + $waitAfterStarted?->waitAfter(containerStarted: $containerStarted); + + return $containerStarted; } - public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static - { - $this->waitBeforeStarted = $wait; + public function runWhen( + Closure $gate, + Closure $then, + array $commands = [], + ?ContainerWaitAfterStarted $waitAfterStarted = null + ): void { + if (!$gate()) { + return; + } - return $this; + $then($this->runIfNotExists(commands: $commands, waitAfterStarted: $waitAfterStarted)); } - public function withoutAutoRemove(): static + public function pullImage(): static { - $this->definition = $this->definition->withoutAutoRemove(); + $this->commandHandler->execute(command: DockerPull::from(image: $this->definition->image->name)); return $this; } - public function withEnvironmentVariable(string $key, string $value): static + public function withNetwork(string $name): static { - $this->definition = $this->definition->withEnvironmentVariable(key: $key, value: $value); + $this->definition = $this->definition->withNetwork(name: $name); return $this; } - public function pullImage(): static - { - $this->commandHandler->execute(command: DockerPull::from(image: $this->definition->image->name)); + public function runIfNotExists( + array $commands = [], + ?ContainerWaitAfterStarted $waitAfterStarted = null + ): ContainerStarted { + $existing = $this->commandHandler->findBy(definition: $this->definition); - return $this; + if (!is_null($existing)) { + return new Reused(reaper: $this->reaper, wasReused: true, containerStarted: $existing); + } + + return new Reused( + reaper: $this->reaper, + wasReused: false, + containerStarted: $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted) + ); } public function copyToContainer(string $pathOnHost, string $pathOnContainer): static @@ -106,31 +124,24 @@ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): return $this; } - public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted + public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static { - $this->waitBeforeStarted?->waitBefore(); + $this->waitBeforeStarted = $wait; - $dockerRun = DockerRun::from(definition: $this->definition, commands: $commands); - $containerStarted = $this->commandHandler->run(dockerRun: $dockerRun); + return $this; + } - $waitAfterStarted?->waitAfter(containerStarted: $containerStarted); + public function withoutAutoRemove(): static + { + $this->definition = $this->definition->withoutAutoRemove(); - return $containerStarted; + return $this; } - public function runIfNotExists( - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): ContainerStarted { - $existing = $this->commandHandler->findBy(definition: $this->definition); - - if (!is_null($existing)) { - return new Reused(reaper: $this->reaper, containerStarted: $existing); - } + public function withEnvironmentVariable(string $key, string $value): static + { + $this->definition = $this->definition->withEnvironmentVariable(key: $key, value: $value); - return new Reused( - reaper: $this->reaper, - containerStarted: $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted) - ); + return $this; } } diff --git a/src/Internal/Client/Client.php b/src/Internal/Client/Client.php index e9d8fe2..b2979fe 100644 --- a/src/Internal/Client/Client.php +++ b/src/Internal/Client/Client.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Client; -use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\ExecutionCompleted; use TinyBlocks\DockerContainer\Internal\Commands\Command; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; diff --git a/src/Internal/Client/DockerClient.php b/src/Internal/Client/DockerClient.php index fb97669..a9ea304 100644 --- a/src/Internal/Client/DockerClient.php +++ b/src/Internal/Client/DockerClient.php @@ -6,7 +6,7 @@ use Symfony\Component\Process\Process; use Throwable; -use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\ExecutionCompleted; use TinyBlocks\DockerContainer\Internal\Commands\Command; use TinyBlocks\DockerContainer\Internal\Commands\CommandWithTimeout; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; @@ -15,7 +15,7 @@ { public function execute(Command $command): ExecutionCompleted { - $process = new Process($command->toArguments()); + $process = new Process(command: $command->toArguments()); try { if ($command instanceof CommandWithTimeout) { diff --git a/src/Internal/Client/Execution.php b/src/Internal/Client/Execution.php index d62b025..15b3f12 100644 --- a/src/Internal/Client/Execution.php +++ b/src/Internal/Client/Execution.php @@ -5,7 +5,7 @@ namespace TinyBlocks\DockerContainer\Internal\Client; use Symfony\Component\Process\Process; -use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\ExecutionCompleted; final readonly class Execution implements ExecutionCompleted { diff --git a/src/Internal/CommandHandler/CommandHandler.php b/src/Internal/CommandHandler/CommandHandler.php index cf0f763..54368ba 100644 --- a/src/Internal/CommandHandler/CommandHandler.php +++ b/src/Internal/CommandHandler/CommandHandler.php @@ -4,8 +4,8 @@ namespace TinyBlocks\DockerContainer\Internal\CommandHandler; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; -use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\ContainerStarted; +use TinyBlocks\DockerContainer\ExecutionCompleted; use TinyBlocks\DockerContainer\Internal\Commands\Command; use TinyBlocks\DockerContainer\Internal\Commands\DockerRun; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; diff --git a/src/Internal/CommandHandler/ContainerCommandHandler.php b/src/Internal/CommandHandler/ContainerCommandHandler.php index 1da37c9..28faf3f 100644 --- a/src/Internal/CommandHandler/ContainerCommandHandler.php +++ b/src/Internal/CommandHandler/ContainerCommandHandler.php @@ -4,8 +4,8 @@ namespace TinyBlocks\DockerContainer\Internal\CommandHandler; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; -use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\ContainerStarted; +use TinyBlocks\DockerContainer\ExecutionCompleted; use TinyBlocks\DockerContainer\Internal\Client\Client; use TinyBlocks\DockerContainer\Internal\Commands\Command; use TinyBlocks\DockerContainer\Internal\Commands\DockerCopy; @@ -13,11 +13,11 @@ use TinyBlocks\DockerContainer\Internal\Commands\DockerNetworkConnect; use TinyBlocks\DockerContainer\Internal\Commands\DockerNetworkCreate; use TinyBlocks\DockerContainer\Internal\Commands\DockerRun; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerId; use TinyBlocks\DockerContainer\Internal\Containers\ContainerLookup; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\CopyInstruction; use TinyBlocks\DockerContainer\Internal\Containers\HostEnvironment; -use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId; use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; @@ -30,27 +30,6 @@ public function __construct(private Client $client, ShutdownHook $shutdownHook) $this->lookup = new ContainerLookup(client: $client, shutdownHook: $shutdownHook); } - public function execute(Command $command): ExecutionCompleted - { - return $this->client->execute(command: $command); - } - - public function findBy(ContainerDefinition $definition): ?ContainerStarted - { - $dockerList = DockerList::from(name: $definition->name); - $executionCompleted = $this->client->execute(command: $dockerList); - - $output = trim($executionCompleted->getOutput()); - - if (empty($output)) { - return null; - } - - $id = ContainerId::from(value: $output); - - return $this->lookup->byId(id: $id, definition: $definition, commandHandler: $this); - } - public function run(DockerRun $dockerRun): ContainerStarted { if (!is_null($dockerRun->definition->network)) { @@ -84,4 +63,25 @@ public function run(DockerRun $dockerRun): ContainerStarted return $started; } + + public function findBy(ContainerDefinition $definition): ?ContainerStarted + { + $dockerList = DockerList::from(name: $definition->name); + $executionCompleted = $this->client->execute(command: $dockerList); + + $output = trim($executionCompleted->getOutput()); + + if (empty($output)) { + return null; + } + + $id = ContainerId::from(value: $output); + + return $this->lookup->byId(id: $id, definition: $definition, commandHandler: $this); + } + + public function execute(Command $command): ExecutionCompleted + { + return $this->client->execute(command: $command); + } } diff --git a/src/Internal/Commands/Command.php b/src/Internal/Commands/Command.php index b9dec39..2117217 100644 --- a/src/Internal/Commands/Command.php +++ b/src/Internal/Commands/Command.php @@ -11,7 +11,7 @@ interface Command { /** - * Converts the command to its argument-list representation. + * Returns the Command as an ordered argument list. * * The first element is the executable, remaining elements are its arguments. No shell * interpretation happens between elements, so values are passed through verbatim. diff --git a/src/Internal/Commands/DockerCopy.php b/src/Internal/Commands/DockerCopy.php index c06fb71..04fa7e8 100644 --- a/src/Internal/Commands/DockerCopy.php +++ b/src/Internal/Commands/DockerCopy.php @@ -4,8 +4,8 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerId; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\CopyInstruction; -use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId; final readonly class DockerCopy implements Command { diff --git a/src/Internal/Commands/DockerExecute.php b/src/Internal/Commands/DockerExecute.php index 3d7e45b..dd159b1 100644 --- a/src/Internal/Commands/DockerExecute.php +++ b/src/Internal/Commands/DockerExecute.php @@ -5,7 +5,7 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; use TinyBlocks\Collection\Collection; -use TinyBlocks\DockerContainer\Internal\Containers\Models\Name; +use TinyBlocks\DockerContainer\Internal\Containers\Name; final readonly class DockerExecute implements Command { diff --git a/src/Internal/Commands/DockerInspect.php b/src/Internal/Commands/DockerInspect.php index 0adc08b..976c9c5 100644 --- a/src/Internal/Commands/DockerInspect.php +++ b/src/Internal/Commands/DockerInspect.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; -use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerId; final readonly class DockerInspect implements Command { diff --git a/src/Internal/Commands/DockerList.php b/src/Internal/Commands/DockerList.php index d4f30b4..ce258fe 100644 --- a/src/Internal/Commands/DockerList.php +++ b/src/Internal/Commands/DockerList.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; -use TinyBlocks\DockerContainer\Internal\Containers\Models\Name; +use TinyBlocks\DockerContainer\Internal\Containers\Name; final readonly class DockerList implements Command { @@ -19,6 +19,8 @@ public static function from(Name $name): DockerList public function toArguments(): array { - return ['docker', 'ps', '--all', '--quiet', '--filter', sprintf('name=^%s$', $this->name->value)]; + $template = 'name=^%s$'; + + return ['docker', 'ps', '--all', '--quiet', '--filter', sprintf($template, $this->name->value)]; } } diff --git a/src/Internal/Commands/DockerNetworkPrune.php b/src/Internal/Commands/DockerNetworkPrune.php index ee5e09f..b15ec9b 100644 --- a/src/Internal/Commands/DockerNetworkPrune.php +++ b/src/Internal/Commands/DockerNetworkPrune.php @@ -17,6 +17,9 @@ public static function create(): DockerNetworkPrune public function toArguments(): array { - return ['docker', 'network', 'prune', '--force', '--filter', sprintf('label=%s', DockerRun::MANAGED_LABEL)]; + $template = 'label=%s'; + $filter = sprintf($template, DockerRun::MANAGED_LABEL); + + return ['docker', 'network', 'prune', '--force', '--filter', $filter]; } } diff --git a/src/Internal/Commands/DockerReaper.php b/src/Internal/Commands/DockerReaper.php index cd3e66e..48d250c 100644 --- a/src/Internal/Commands/DockerReaper.php +++ b/src/Internal/Commands/DockerReaper.php @@ -22,6 +22,17 @@ public static function from(string $reaperName, string $containerName, string $t ); } + private function buildScript(): string + { + $template = implode(' ', [ + 'while docker inspect %s >/dev/null 2>&1; do sleep 2; done;', + 'docker rm -fv %s 2>/dev/null;', + 'docker network prune -f --filter label=%s 2>/dev/null' + ]); + + return sprintf($template, $this->testRunnerHostname, $this->containerName, DockerRun::MANAGED_LABEL); + } + public function toArguments(): array { return [ @@ -41,18 +52,4 @@ public function toArguments(): array $this->buildScript() ]; } - - private function buildScript(): string - { - return sprintf( - implode(' ', [ - 'while docker inspect %s >/dev/null 2>&1; do sleep 2; done;', - 'docker rm -fv %s 2>/dev/null;', - 'docker network prune -f --filter label=%s 2>/dev/null' - ]), - $this->testRunnerHostname, - $this->containerName, - DockerRun::MANAGED_LABEL - ); - } } diff --git a/src/Internal/Commands/DockerRemove.php b/src/Internal/Commands/DockerRemove.php index a03bfe9..2ac2258 100644 --- a/src/Internal/Commands/DockerRemove.php +++ b/src/Internal/Commands/DockerRemove.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; -use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerId; final readonly class DockerRemove implements Command { diff --git a/src/Internal/Commands/DockerRun.php b/src/Internal/Commands/DockerRun.php index 5576903..354267d 100644 --- a/src/Internal/Commands/DockerRun.php +++ b/src/Internal/Commands/DockerRun.php @@ -40,19 +40,25 @@ public function toArguments(): array self::MANAGED_LABEL ]; - foreach ($this->definition->portMappings as $port) { - /** @var PortMapping $port */ - $arguments = [...$arguments, ...$port->toArguments()]; - } + $portArguments = $this->definition->portMappings->reduce( + accumulator: static fn(array $carry, PortMapping $port): array => [...$carry, ...$port->toArguments()], + initial: [] + ); + $arguments = [...$arguments, ...$portArguments]; if (!is_null($this->definition->network)) { - $arguments[] = sprintf('--network=%s', $this->definition->network); + $template = '--network=%s'; + $arguments[] = sprintf($template, $this->definition->network); } - foreach ($this->definition->volumeMappings as $volume) { - /** @var VolumeMapping $volume */ - $arguments = [...$arguments, ...$volume->toArguments()]; - } + $volumeArguments = $this->definition->volumeMappings->reduce( + accumulator: static fn(array $carry, VolumeMapping $volume): array => [ + ...$carry, + ...$volume->toArguments() + ], + initial: [] + ); + $arguments = [...$arguments, ...$volumeArguments]; $arguments[] = '--detach'; @@ -60,10 +66,14 @@ public function toArguments(): array $arguments[] = '--rm'; } - foreach ($this->definition->environmentVariables as $environment) { - /** @var EnvironmentVariable $environment */ - $arguments = [...$arguments, ...$environment->toArguments()]; - } + $environmentArguments = $this->definition->environmentVariables->reduce( + accumulator: static fn(array $carry, EnvironmentVariable $environment): array => [ + ...$carry, + ...$environment->toArguments() + ], + initial: [] + ); + $arguments = [...$arguments, ...$environmentArguments]; $arguments[] = $this->definition->image->name; diff --git a/src/Internal/Commands/DockerStop.php b/src/Internal/Commands/DockerStop.php index cd4051a..37bddb8 100644 --- a/src/Internal/Commands/DockerStop.php +++ b/src/Internal/Commands/DockerStop.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Commands; -use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerId; final readonly class DockerStop implements CommandWithTimeout { diff --git a/src/Internal/Containers/Address/Address.php b/src/Internal/Containers/Address/Address.php index 68d4917..eb66c54 100644 --- a/src/Internal/Containers/Address/Address.php +++ b/src/Internal/Containers/Address/Address.php @@ -4,9 +4,9 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Address; -use TinyBlocks\DockerContainer\Contracts\Address as ContainerAddress; -use TinyBlocks\DockerContainer\Contracts\Ports as ContainerPorts; +use TinyBlocks\DockerContainer\Address as ContainerAddress; use TinyBlocks\DockerContainer\Internal\Containers\HostEnvironment; +use TinyBlocks\DockerContainer\Ports as ContainerPorts; final readonly class Address implements ContainerAddress { diff --git a/src/Internal/Containers/Address/Hostname.php b/src/Internal/Containers/Address/Hostname.php index 6f2a1cb..6f3539c 100644 --- a/src/Internal/Containers/Address/Hostname.php +++ b/src/Internal/Containers/Address/Hostname.php @@ -14,6 +14,6 @@ private function __construct(public string $value) public static function from(string $value): Hostname { - return new Hostname(value: empty($value) ? self::LOCALHOST : $value); + return new Hostname(value: $value === '' ? self::LOCALHOST : $value); } } diff --git a/src/Internal/Containers/Address/IP.php b/src/Internal/Containers/Address/IP.php index 4730ed6..55d94db 100644 --- a/src/Internal/Containers/Address/IP.php +++ b/src/Internal/Containers/Address/IP.php @@ -14,6 +14,6 @@ private function __construct(public string $value) public static function from(string $value): IP { - return new IP(value: empty($value) ? self::LOCAL_IP : $value); + return new IP(value: $value === '' ? self::LOCAL_IP : $value); } } diff --git a/src/Internal/Containers/Address/Ports.php b/src/Internal/Containers/Address/Ports.php index 368d9d5..863e0b3 100644 --- a/src/Internal/Containers/Address/Ports.php +++ b/src/Internal/Containers/Address/Ports.php @@ -5,8 +5,9 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Address; use TinyBlocks\Collection\Collection; -use TinyBlocks\DockerContainer\Contracts\Ports as ContainerPorts; +use TinyBlocks\Collection\KeyPreservation; use TinyBlocks\DockerContainer\Internal\Containers\HostEnvironment; +use TinyBlocks\DockerContainer\Ports as ContainerPorts; final readonly class Ports implements ContainerPorts { @@ -17,8 +18,12 @@ private function __construct(private array $exposedPorts, private array $hostMap public static function from(Collection $exposedPorts, Collection $hostMappedPorts): Ports { return new Ports( - exposedPorts: array_values(array_filter($exposedPorts->toArray())), - hostMappedPorts: array_values(array_filter($hostMappedPorts->toArray())) + exposedPorts: $exposedPorts + ->filter(predicates: static fn(int $port): bool => $port !== 0) + ->toArray(keyPreservation: KeyPreservation::DISCARD), + hostMappedPorts: $hostMappedPorts + ->filter(predicates: static fn(int $port): bool => $port !== 0) + ->toArray(keyPreservation: KeyPreservation::DISCARD) ); } diff --git a/src/Internal/Containers/ContainerId.php b/src/Internal/Containers/ContainerId.php new file mode 100644 index 0000000..49e5cff --- /dev/null +++ b/src/Internal/Containers/ContainerId.php @@ -0,0 +1,36 @@ +value = substr($trimmed, self::CONTAINER_ID_OFFSET, self::CONTAINER_ID_LENGTH); + } + + public static function from(string $value): ContainerId + { + return new ContainerId(value: $value); + } +} diff --git a/src/Internal/Containers/ContainerLookup.php b/src/Internal/Containers/ContainerLookup.php index efb3622..2547580 100644 --- a/src/Internal/Containers/ContainerLookup.php +++ b/src/Internal/Containers/ContainerLookup.php @@ -4,12 +4,11 @@ namespace TinyBlocks\DockerContainer\Internal\Containers; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; +use TinyBlocks\DockerContainer\ContainerStarted; use TinyBlocks\DockerContainer\Internal\Client\Client; use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler; use TinyBlocks\DockerContainer\Internal\Commands\DockerInspect; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; -use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerContainerNotFound; final readonly class ContainerLookup diff --git a/src/Internal/Containers/ContainerReaper.php b/src/Internal/Containers/ContainerReaper.php index 24beccf..38f2870 100644 --- a/src/Internal/Containers/ContainerReaper.php +++ b/src/Internal/Containers/ContainerReaper.php @@ -7,7 +7,6 @@ use TinyBlocks\DockerContainer\Internal\Client\Client; use TinyBlocks\DockerContainer\Internal\Commands\DockerList; use TinyBlocks\DockerContainer\Internal\Commands\DockerReaper; -use TinyBlocks\DockerContainer\Internal\Containers\Models\Name; final readonly class ContainerReaper { @@ -15,16 +14,16 @@ public function __construct(private Client $client) { } - public function ensureRunningFor(string $containerName): void { if (!file_exists('/.dockerenv')) { return; } - $reaperName = sprintf('tiny-blocks-reaper-%s', $containerName); + $template = 'tiny-blocks-reaper-%s'; + $reaperName = sprintf($template, $containerName); $reaperList = DockerList::from(name: Name::from(value: $reaperName)); - $reaperExists = !empty(trim($this->client->execute(command: $reaperList)->getOutput())); + $reaperExists = trim($this->client->execute(command: $reaperList)->getOutput()) !== ''; if ($reaperExists) { return; diff --git a/src/Internal/Containers/Definitions/ContainerDefinition.php b/src/Internal/Containers/Definitions/ContainerDefinition.php index a5d3fe5..afc996b 100644 --- a/src/Internal/Containers/Definitions/ContainerDefinition.php +++ b/src/Internal/Containers/Definitions/ContainerDefinition.php @@ -5,8 +5,8 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Definitions; use TinyBlocks\Collection\Collection; -use TinyBlocks\DockerContainer\Internal\Containers\Models\Image; -use TinyBlocks\DockerContainer\Internal\Containers\Models\Name; +use TinyBlocks\DockerContainer\Internal\Containers\Image; +use TinyBlocks\DockerContainer\Internal\Containers\Name; final readonly class ContainerDefinition { @@ -82,23 +82,21 @@ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): ); } - public function withCopyInstruction(string $pathOnHost, string $pathOnContainer): ContainerDefinition + public function withoutAutoRemove(): ContainerDefinition { return new ContainerDefinition( name: $this->name, image: $this->image, network: $this->network, - autoRemove: $this->autoRemove, + autoRemove: false, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, - copyInstructions: $this->copyInstructions->add( - CopyInstruction::from(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer) - ), + copyInstructions: $this->copyInstructions, environmentVariables: $this->environmentVariables ); } - public function withEnvironmentVariable(string $key, string $value): ContainerDefinition + public function withCopyInstruction(string $pathOnHost, string $pathOnContainer): ContainerDefinition { return new ContainerDefinition( name: $this->name, @@ -107,24 +105,26 @@ public function withEnvironmentVariable(string $key, string $value): ContainerDe autoRemove: $this->autoRemove, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, - copyInstructions: $this->copyInstructions, - environmentVariables: $this->environmentVariables->add( - EnvironmentVariable::from(key: $key, value: $value) - ) + copyInstructions: $this->copyInstructions->add( + CopyInstruction::from(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer) + ), + environmentVariables: $this->environmentVariables ); } - public function withoutAutoRemove(): ContainerDefinition + public function withEnvironmentVariable(string $key, string $value): ContainerDefinition { return new ContainerDefinition( name: $this->name, image: $this->image, network: $this->network, - autoRemove: false, + autoRemove: $this->autoRemove, portMappings: $this->portMappings, volumeMappings: $this->volumeMappings, copyInstructions: $this->copyInstructions, - environmentVariables: $this->environmentVariables + environmentVariables: $this->environmentVariables->add( + EnvironmentVariable::from(key: $key, value: $value) + ) ); } } diff --git a/src/Internal/Containers/Definitions/CopyInstruction.php b/src/Internal/Containers/Definitions/CopyInstruction.php index dbf0f71..e887500 100644 --- a/src/Internal/Containers/Definitions/CopyInstruction.php +++ b/src/Internal/Containers/Definitions/CopyInstruction.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Definitions; -use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId; +use TinyBlocks\DockerContainer\Internal\Containers\ContainerId; final readonly class CopyInstruction { @@ -19,6 +19,8 @@ public static function from(string $pathOnHost, string $pathOnContainer): CopyIn public function toCopyArguments(ContainerId $id): array { - return [$this->pathOnHost, sprintf('%s:%s', $id->value, $this->pathOnContainer)]; + $template = '%s:%s'; + + return [$this->pathOnHost, sprintf($template, $id->value, $this->pathOnContainer)]; } } diff --git a/src/Internal/Containers/Definitions/EnvironmentVariable.php b/src/Internal/Containers/Definitions/EnvironmentVariable.php index 91e2041..975c192 100644 --- a/src/Internal/Containers/Definitions/EnvironmentVariable.php +++ b/src/Internal/Containers/Definitions/EnvironmentVariable.php @@ -17,6 +17,8 @@ public static function from(string $key, string $value): EnvironmentVariable public function toArguments(): array { - return ['--env', sprintf('%s=%s', $this->key, $this->value)]; + $template = '%s=%s'; + + return ['--env', sprintf($template, $this->key, $this->value)]; } } diff --git a/src/Internal/Containers/Definitions/PortMapping.php b/src/Internal/Containers/Definitions/PortMapping.php index 96d008a..35ac1c0 100644 --- a/src/Internal/Containers/Definitions/PortMapping.php +++ b/src/Internal/Containers/Definitions/PortMapping.php @@ -17,6 +17,8 @@ public static function from(int $portOnHost, int $portOnContainer): PortMapping public function toArguments(): array { - return ['--publish', sprintf('%d:%d', $this->portOnHost, $this->portOnContainer)]; + $template = '%d:%d'; + + return ['--publish', sprintf($template, $this->portOnHost, $this->portOnContainer)]; } } diff --git a/src/Internal/Containers/Definitions/VolumeMapping.php b/src/Internal/Containers/Definitions/VolumeMapping.php index 2f02202..8317bc4 100644 --- a/src/Internal/Containers/Definitions/VolumeMapping.php +++ b/src/Internal/Containers/Definitions/VolumeMapping.php @@ -17,6 +17,8 @@ public static function from(string $pathOnHost, string $pathOnContainer): Volume public function toArguments(): array { - return ['--volume', sprintf('%s:%s', $this->pathOnHost, $this->pathOnContainer)]; + $template = '%s:%s'; + + return ['--volume', sprintf($template, $this->pathOnHost, $this->pathOnContainer)]; } } diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php b/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php index d289307..c496f69 100644 --- a/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php +++ b/src/Internal/Containers/Drivers/MySQL/MySQLCommands.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Drivers\MySQL; -final readonly class MySQLCommands +final class MySQLCommands { private const string GRANT_ALL = "GRANT ALL PRIVILEGES ON *.* TO '%s'@'%s' WITH GRANT OPTION;"; private const string USER_ROOT = 'root'; @@ -13,18 +13,23 @@ private const string CREATE_DATABASE = 'CREATE DATABASE IF NOT EXISTS %s;'; private const string FLUSH_PRIVILEGES = 'FLUSH PRIVILEGES;'; - public static function setupDatabase(string $database, string $rootPassword, array $grantedHosts): string + private function __construct() + { + } + + public static function setupDatabase(string $database, array $grantedHosts, string $rootPassword): string { $statements = []; - if (!empty($database)) { + if ($database !== '') { $statements[] = sprintf(self::CREATE_DATABASE, $database); } foreach ($grantedHosts as $host) { $createUser = sprintf(self::CREATE_USER, self::USER_ROOT, $host, $rootPassword); $grantAll = sprintf(self::GRANT_ALL, self::USER_ROOT, $host); - $statements[] = sprintf('%s %s', $createUser, $grantAll); + $template = '%s %s'; + $statements[] = sprintf($template, $createUser, $grantAll); } if (!empty($statements)) { diff --git a/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php b/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php index b19b902..4c70716 100644 --- a/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php +++ b/src/Internal/Containers/Drivers/MySQL/MySQLStarted.php @@ -4,11 +4,11 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Drivers\MySQL; -use TinyBlocks\DockerContainer\Contracts\Address; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; -use TinyBlocks\DockerContainer\Contracts\EnvironmentVariables; -use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; -use TinyBlocks\DockerContainer\Contracts\MySQL\MySQLContainerStarted; +use TinyBlocks\DockerContainer\Address; +use TinyBlocks\DockerContainer\ContainerStarted; +use TinyBlocks\DockerContainer\EnvironmentVariables; +use TinyBlocks\DockerContainer\ExecutionCompleted; +use TinyBlocks\DockerContainer\MySQL\MySQLContainerStarted; final readonly class MySQLStarted implements MySQLContainerStarted { @@ -23,19 +23,14 @@ public static function from(ContainerStarted $containerStarted): MySQLStarted return new MySQLStarted(containerStarted: $containerStarted); } - public function getId(): string - { - return $this->containerStarted->getId(); - } - - public function getName(): string + public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted { - return $this->containerStarted->getName(); + return $this->containerStarted->stop(timeoutInWholeSeconds: $timeoutInWholeSeconds); } - public function getAddress(): Address + public function getId(): string { - return $this->containerStarted->getAddress(); + return $this->containerStarted->getId(); } public function remove(): void @@ -43,24 +38,19 @@ public function remove(): void $this->containerStarted->remove(); } - public function stopOnShutdown(): void + public function getName(): string { - $this->containerStarted->stopOnShutdown(); + return $this->containerStarted->getName(); } - public function getEnvironmentVariables(): EnvironmentVariables + public function wasReused(): bool { - return $this->containerStarted->getEnvironmentVariables(); + return $this->containerStarted->wasReused(); } - public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted - { - return $this->containerStarted->stop(timeoutInWholeSeconds: $timeoutInWholeSeconds); - } - - public function executeAfterStarted(array $commands): ExecutionCompleted + public function getAddress(): Address { - return $this->containerStarted->executeAfterStarted(commands: $commands); + return $this->containerStarted->getAddress(); } public function getJdbcUrl(array $options = self::DEFAULT_JDBC_OPTIONS): string @@ -70,12 +60,31 @@ public function getJdbcUrl(array $options = self::DEFAULT_JDBC_OPTIONS): string $hostname = $address->getHostname(); $database = $this->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE'); - $baseUrl = sprintf('jdbc:mysql://%s:%d/%s', $hostname, $port, $database); + $template = 'jdbc:mysql://%s:%d/%s'; + + $baseUrl = sprintf($template, $hostname, $port, $database); if (!empty($options)) { - return sprintf('%s?%s', $baseUrl, http_build_query($options)); + $template = '%s?%s'; + + return sprintf($template, $baseUrl, http_build_query($options)); } return $baseUrl; } + + public function stopOnShutdown(): void + { + $this->containerStarted->stopOnShutdown(); + } + + public function executeAfterStarted(array $commands): ExecutionCompleted + { + return $this->containerStarted->executeAfterStarted(commands: $commands); + } + + public function getEnvironmentVariables(): EnvironmentVariables + { + return $this->containerStarted->getEnvironmentVariables(); + } } diff --git a/src/Internal/Containers/Environment/EnvironmentVariables.php b/src/Internal/Containers/Environment/EnvironmentVariables.php index 26f7ce6..c927fbe 100644 --- a/src/Internal/Containers/Environment/EnvironmentVariables.php +++ b/src/Internal/Containers/Environment/EnvironmentVariables.php @@ -5,7 +5,7 @@ namespace TinyBlocks\DockerContainer\Internal\Containers\Environment; use TinyBlocks\Collection\Collection; -use TinyBlocks\DockerContainer\Contracts\EnvironmentVariables as ContainerEnvironmentVariables; +use TinyBlocks\DockerContainer\EnvironmentVariables as ContainerEnvironmentVariables; final readonly class EnvironmentVariables implements ContainerEnvironmentVariables { diff --git a/src/Internal/Containers/HostEnvironment.php b/src/Internal/Containers/HostEnvironment.php index 027e08d..014a388 100644 --- a/src/Internal/Containers/HostEnvironment.php +++ b/src/Internal/Containers/HostEnvironment.php @@ -4,8 +4,12 @@ namespace TinyBlocks\DockerContainer\Internal\Containers; -final readonly class HostEnvironment +final class HostEnvironment { + private function __construct() + { + } + public static function isInsideDocker(): bool { return file_exists('/.dockerenv'); diff --git a/src/Internal/Containers/Models/Image.php b/src/Internal/Containers/Image.php similarity index 54% rename from src/Internal/Containers/Models/Image.php rename to src/Internal/Containers/Image.php index b89de02..0f2b494 100644 --- a/src/Internal/Containers/Models/Image.php +++ b/src/Internal/Containers/Image.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace TinyBlocks\DockerContainer\Internal\Containers\Models; +namespace TinyBlocks\DockerContainer\Internal\Containers; -use InvalidArgumentException; +use TinyBlocks\DockerContainer\Internal\Exceptions\ImageNameEmpty; final readonly class Image { private function __construct(public string $name) { - if (empty($name)) { - throw new InvalidArgumentException(message: 'Image name cannot be empty.'); + if ($name === '') { + throw new ImageNameEmpty(); } } diff --git a/src/Internal/Containers/Models/ContainerId.php b/src/Internal/Containers/Models/ContainerId.php deleted file mode 100644 index 42d8f6b..0000000 --- a/src/Internal/Containers/Models/ContainerId.php +++ /dev/null @@ -1,35 +0,0 @@ - is too short. Minimum length is <%d> characters.'; - throw new InvalidArgumentException( - message: sprintf($template, $trimmed, self::CONTAINER_ID_LENGTH) - ); - } - - return new ContainerId(value: substr($trimmed, self::CONTAINER_ID_OFFSET, self::CONTAINER_ID_LENGTH)); - } -} diff --git a/src/Internal/Containers/Models/Name.php b/src/Internal/Containers/Name.php similarity index 78% rename from src/Internal/Containers/Models/Name.php rename to src/Internal/Containers/Name.php index 42a98b2..f824c6c 100644 --- a/src/Internal/Containers/Models/Name.php +++ b/src/Internal/Containers/Name.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\DockerContainer\Internal\Containers\Models; +namespace TinyBlocks\DockerContainer\Internal\Containers; use TinyBlocks\Ksuid\Ksuid; @@ -14,7 +14,7 @@ private function __construct(public string $value) public static function from(?string $value): Name { - $value = is_null($value) || $value === '' ? Ksuid::random()->getValue() : $value; + $value = is_null($value) || $value === '' ? Ksuid::random()->value() : $value; return new Name(value: $value); } diff --git a/src/Internal/Containers/RegisteredShutdownHook.php b/src/Internal/Containers/RegisteredShutdownHook.php new file mode 100644 index 0000000..efe4f02 --- /dev/null +++ b/src/Internal/Containers/RegisteredShutdownHook.php @@ -0,0 +1,13 @@ +ensureRunningFor(containerName: $containerStarted->getName()); } - public function remove(): void + public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted { + return $this->containerStarted->stop(timeoutInWholeSeconds: $timeoutInWholeSeconds); } - public function stopOnShutdown(): void + public function getId(): string { + return $this->containerStarted->getId(); } - public function getId(): string + public function remove(): void { - return $this->containerStarted->getId(); } public function getName(): string @@ -34,24 +38,27 @@ public function getName(): string return $this->containerStarted->getName(); } - public function getAddress(): Address + public function wasReused(): bool { - return $this->containerStarted->getAddress(); + return $this->wasReused; } - public function getEnvironmentVariables(): EnvironmentVariables + public function getAddress(): Address { - return $this->containerStarted->getEnvironmentVariables(); + return $this->containerStarted->getAddress(); } - public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted + public function stopOnShutdown(): void { - return $this->containerStarted->stop(timeoutInWholeSeconds: $timeoutInWholeSeconds); } - public function executeAfterStarted(array $commands): ExecutionCompleted { return $this->containerStarted->executeAfterStarted(commands: $commands); } + + public function getEnvironmentVariables(): EnvironmentVariables + { + return $this->containerStarted->getEnvironmentVariables(); + } } diff --git a/src/Internal/Containers/ShutdownHook.php b/src/Internal/Containers/ShutdownHook.php index fc08455..d6b0e1e 100644 --- a/src/Internal/Containers/ShutdownHook.php +++ b/src/Internal/Containers/ShutdownHook.php @@ -4,10 +4,15 @@ namespace TinyBlocks\DockerContainer\Internal\Containers; -class ShutdownHook +/** + * Registers a callback to run when the PHP process shuts down. + */ +interface ShutdownHook { - public function register(callable $callback): void - { - register_shutdown_function($callback); - } + /** + * Registers the callback to run on process shutdown. + * + * @param callable $callback The callback invoked during shutdown. + */ + public function register(callable $callback): void; } diff --git a/src/Internal/Containers/Started.php b/src/Internal/Containers/Started.php index 505d3a1..79f33f5 100644 --- a/src/Internal/Containers/Started.php +++ b/src/Internal/Containers/Started.php @@ -4,10 +4,10 @@ namespace TinyBlocks\DockerContainer\Internal\Containers; -use TinyBlocks\DockerContainer\Contracts\Address; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; -use TinyBlocks\DockerContainer\Contracts\EnvironmentVariables; -use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\Address; +use TinyBlocks\DockerContainer\ContainerStarted; +use TinyBlocks\DockerContainer\EnvironmentVariables; +use TinyBlocks\DockerContainer\ExecutionCompleted; use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler; use TinyBlocks\DockerContainer\Internal\Commands\DockerExecute; use TinyBlocks\DockerContainer\Internal\Commands\DockerNetworkPrune; @@ -15,8 +15,6 @@ use TinyBlocks\DockerContainer\Internal\Commands\DockerStop; use TinyBlocks\DockerContainer\Internal\Containers\Address\Address as ContainerAddress; use TinyBlocks\DockerContainer\Internal\Containers\Environment\EnvironmentVariables as ContainerEnvironmentVariables; -use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId; -use TinyBlocks\DockerContainer\Internal\Containers\Models\Name; final readonly class Started implements ContainerStarted { @@ -30,42 +28,42 @@ public function __construct( ) { } - public function getId(): string + public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted { - return $this->id->value; + $command = DockerStop::from(id: $this->id, timeoutInWholeSeconds: $timeoutInWholeSeconds); + + return $this->commandHandler->execute(command: $command); } - public function getName(): string + public function getId(): string { - return $this->name->value; + return $this->id->value; } - public function getAddress(): Address + public function remove(): void { - return $this->address; + $this->commandHandler->execute(command: DockerRemove::from(id: $this->id)); + $this->commandHandler->execute(command: DockerNetworkPrune::create()); } - public function getEnvironmentVariables(): EnvironmentVariables + public function getName(): string { - return $this->environmentVariables; + return $this->name->value; } - public function stopOnShutdown(): void + public function wasReused(): bool { - $this->shutdownHook->register([$this, 'remove']); + return false; } - public function remove(): void + public function getAddress(): Address { - $this->commandHandler->execute(command: DockerRemove::from(id: $this->id)); - $this->commandHandler->execute(command: DockerNetworkPrune::create()); + return $this->address; } - public function stop(int $timeoutInWholeSeconds = self::DEFAULT_TIMEOUT_IN_WHOLE_SECONDS): ExecutionCompleted + public function stopOnShutdown(): void { - $command = DockerStop::from(id: $this->id, timeoutInWholeSeconds: $timeoutInWholeSeconds); - - return $this->commandHandler->execute(command: $command); + $this->shutdownHook->register(callback: [$this, 'remove']); } public function executeAfterStarted(array $commands): ExecutionCompleted @@ -74,4 +72,9 @@ public function executeAfterStarted(array $commands): ExecutionCompleted return $this->commandHandler->execute(command: $command); } + + public function getEnvironmentVariables(): EnvironmentVariables + { + return $this->environmentVariables; + } } diff --git a/src/Internal/Exceptions/ContainerIdEmpty.php b/src/Internal/Exceptions/ContainerIdEmpty.php new file mode 100644 index 0000000..1a5a1a3 --- /dev/null +++ b/src/Internal/Exceptions/ContainerIdEmpty.php @@ -0,0 +1,15 @@ + is too short. Minimum length is <%d> characters.'; + + parent::__construct(message: sprintf($template, $containerId, $minimumLength)); + } +} diff --git a/src/Internal/Exceptions/DockerCommandExecutionFailed.php b/src/Internal/Exceptions/DockerCommandExecutionFailed.php index 33e5d34..d0b72ae 100644 --- a/src/Internal/Exceptions/DockerCommandExecutionFailed.php +++ b/src/Internal/Exceptions/DockerCommandExecutionFailed.php @@ -7,7 +7,7 @@ use RuntimeException; use Symfony\Component\Process\Process; use Throwable; -use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\ExecutionCompleted; use TinyBlocks\DockerContainer\Internal\Commands\Command; final class DockerCommandExecutionFailed extends RuntimeException @@ -19,17 +19,17 @@ public function __construct(string $reason, string $command) parent::__construct(message: sprintf($template, $command, $reason)); } - public static function fromProcess(Process $process, Throwable $exception): DockerCommandExecutionFailed + public static function fromCommand(Command $command, ExecutionCompleted $execution): DockerCommandExecutionFailed { - $reason = $process->isStarted() ? $process->getErrorOutput() : $exception->getMessage(); + $rendered = implode(' ', array_map('escapeshellarg', $command->toArguments())); - return new DockerCommandExecutionFailed(reason: $reason, command: $process->getCommandLine()); + return new DockerCommandExecutionFailed(reason: $execution->getOutput(), command: $rendered); } - public static function fromCommand(Command $command, ExecutionCompleted $execution): DockerCommandExecutionFailed + public static function fromProcess(Process $process, Throwable $exception): DockerCommandExecutionFailed { - $rendered = implode(' ', array_map('escapeshellarg', $command->toArguments())); + $reason = $process->isStarted() ? $process->getErrorOutput() : $exception->getMessage(); - return new DockerCommandExecutionFailed(reason: $execution->getOutput(), command: $rendered); + return new DockerCommandExecutionFailed(reason: $reason, command: $process->getCommandLine()); } } diff --git a/src/Internal/Exceptions/DockerContainerNotFound.php b/src/Internal/Exceptions/DockerContainerNotFound.php index f45165b..a745831 100644 --- a/src/Internal/Exceptions/DockerContainerNotFound.php +++ b/src/Internal/Exceptions/DockerContainerNotFound.php @@ -5,7 +5,7 @@ namespace TinyBlocks\DockerContainer\Internal\Exceptions; use RuntimeException; -use TinyBlocks\DockerContainer\Internal\Containers\Models\Name; +use TinyBlocks\DockerContainer\Internal\Containers\Name; final class DockerContainerNotFound extends RuntimeException { diff --git a/src/Internal/Exceptions/ImageNameEmpty.php b/src/Internal/Exceptions/ImageNameEmpty.php new file mode 100644 index 0000000..76f0cdb --- /dev/null +++ b/src/Internal/Exceptions/ImageNameEmpty.php @@ -0,0 +1,15 @@ +container->pullImage(); + public function run( + array $commands = [], + ?ContainerWaitAfterStarted $waitAfterStarted = null + ): MySQLContainerStarted { + $containerStarted = $this->container->run(commands: $commands); - return $this; + $condition = MySQLReady::from(container: $containerStarted); + ContainerWaitForDependency::untilReady( + condition: $condition, + timeoutInSeconds: $this->readinessTimeoutInSeconds + )->waitBefore(); + + $environmentVariables = $containerStarted->getEnvironmentVariables(); + $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); + $rootPassword = $environmentVariables->getValueBy(key: 'MYSQL_ROOT_PASSWORD'); + + if ($database !== '' || !empty($this->grantedHosts)) { + $containerStarted->executeAfterStarted( + commands: [ + MySQLCommands::setupDatabase( + database: $database, + grantedHosts: $this->grantedHosts, + rootPassword: $rootPassword + ) + ] + ); + } + + return MySQLStarted::from(containerStarted: $containerStarted); } - public function copyToContainer(string $pathOnHost, string $pathOnContainer): static + public function runWhen( + Closure $gate, + Closure $then, + array $commands = [], + ?ContainerWaitAfterStarted $waitAfterStarted = null + ): void { + $this->container->runWhen( + gate: $gate, + then: static function (ContainerStarted $started) use ($then): void { + $then(MySQLStarted::from(containerStarted: $started)); + }, + commands: $commands, + waitAfterStarted: $waitAfterStarted + ); + } + + public function pullImage(): static { - $this->container->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer); + $this->container->pullImage(); return $this; } @@ -51,79 +92,88 @@ public function withNetwork(string $name): static return $this; } - public function withPortMapping(int $portOnHost, int $portOnContainer): static + public function withDatabase(string $database): static { - $this->container->withPortMapping(portOnHost: $portOnHost, portOnContainer: $portOnContainer); + $this->container->withEnvironmentVariable(key: 'MYSQL_DATABASE', value: $database); return $this; } - public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static + public function withPassword(string $password): static { - $this->container->withWaitBeforeRun(wait: $wait); + $this->container->withEnvironmentVariable(key: 'MYSQL_PASSWORD', value: $password); return $this; } - public function withoutAutoRemove(): static + public function withTimezone(string $timezone): static { - $this->container->withoutAutoRemove(); + $this->container->withEnvironmentVariable(key: 'TZ', value: $timezone); return $this; } - public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): static + public function withUsername(string $user): static { - $this->container->withVolumeMapping(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer); + $this->container->withEnvironmentVariable(key: 'MYSQL_USER', value: $user); return $this; } - public function withEnvironmentVariable(string $key, string $value): static + public function runIfNotExists( + array $commands = [], + ?ContainerWaitAfterStarted $waitAfterStarted = null + ): MySQLContainerStarted { + $containerStarted = $this->container->runIfNotExists(commands: $commands); + + return MySQLStarted::from(containerStarted: $containerStarted); + } + + public function copyToContainer(string $pathOnHost, string $pathOnContainer): static { - $this->container->withEnvironmentVariable(key: $key, value: $value); + $this->container->copyToContainer(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer); return $this; } - public function withTimezone(string $timezone): static + public function withPortMapping(int $portOnHost, int $portOnContainer): static { - $this->container->withEnvironmentVariable(key: 'TZ', value: $timezone); + $this->container->withPortMapping(portOnHost: $portOnHost, portOnContainer: $portOnContainer); return $this; } - public function withUsername(string $user): static + public function withGrantedHosts(array $hosts = ['%', '172.%']): static { - $this->container->withEnvironmentVariable(key: 'MYSQL_USER', value: $user); + $this->grantedHosts = $hosts; return $this; } - public function withPassword(string $password): static + public function withRootPassword(string $rootPassword): static { - $this->container->withEnvironmentVariable(key: 'MYSQL_PASSWORD', value: $password); + $this->container->withEnvironmentVariable(key: 'MYSQL_ROOT_PASSWORD', value: $rootPassword); return $this; } - public function withDatabase(string $database): static + public function withVolumeMapping(string $pathOnHost, string $pathOnContainer): static { - $this->container->withEnvironmentVariable(key: 'MYSQL_DATABASE', value: $database); + $this->container->withVolumeMapping(pathOnHost: $pathOnHost, pathOnContainer: $pathOnContainer); return $this; } - public function withRootPassword(string $rootPassword): static + public function withWaitBeforeRun(ContainerWaitBeforeStarted $wait): static { - $this->container->withEnvironmentVariable(key: 'MYSQL_ROOT_PASSWORD', value: $rootPassword); + $this->container->withWaitBeforeRun(wait: $wait); return $this; } - public function withGrantedHosts(array $hosts = ['%', '172.%']): static + public function withoutAutoRemove(): static { - $this->grantedHosts = $hosts; + $this->container->withoutAutoRemove(); return $this; } @@ -135,43 +185,10 @@ public function withReadinessTimeout(int $timeoutInSeconds): static return $this; } - public function runIfNotExists( - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): MySQLContainerStarted { - $containerStarted = $this->container->runIfNotExists(commands: $commands); - - return MySQLStarted::from(containerStarted: $containerStarted); - } - - public function run( - array $commands = [], - ?ContainerWaitAfterStarted $waitAfterStarted = null - ): MySQLContainerStarted { - $containerStarted = $this->container->run(commands: $commands); - - $condition = MySQLReady::from(container: $containerStarted); - ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: $this->readinessTimeoutInSeconds - )->waitBefore(); - - $environmentVariables = $containerStarted->getEnvironmentVariables(); - $database = $environmentVariables->getValueBy(key: 'MYSQL_DATABASE'); - $rootPassword = $environmentVariables->getValueBy(key: 'MYSQL_ROOT_PASSWORD'); - - if (!empty($database) || !empty($this->grantedHosts)) { - $containerStarted->executeAfterStarted( - commands: [ - MySQLCommands::setupDatabase( - database: $database, - rootPassword: $rootPassword, - grantedHosts: $this->grantedHosts - ) - ] - ); - } + public function withEnvironmentVariable(string $key, string $value): static + { + $this->container->withEnvironmentVariable(key: $key, value: $value); - return MySQLStarted::from(containerStarted: $containerStarted); + return $this; } } diff --git a/src/Contracts/Ports.php b/src/Ports.php similarity index 96% rename from src/Contracts/Ports.php rename to src/Ports.php index 3ec8ef3..d600683 100644 --- a/src/Contracts/Ports.php +++ b/src/Ports.php @@ -2,20 +2,13 @@ declare(strict_types=1); -namespace TinyBlocks\DockerContainer\Contracts; +namespace TinyBlocks\DockerContainer; /** * Represents the port mappings of a Docker container. */ interface Ports { - /** - * Returns all container-internal exposed ports. - * - * @return array The list of exposed port numbers. - */ - public function exposedPorts(): array; - /** * Returns all host-mapped ports. These are the ports accessible from the host machine. * @@ -24,11 +17,11 @@ public function exposedPorts(): array; public function hostPorts(): array; /** - * Returns the first container-internal exposed port, or null if no ports are exposed. + * Returns all container-internal exposed ports. * - * @return int|null The first exposed port number, or null if none. + * @return array The list of exposed port numbers. */ - public function firstExposedPort(): ?int; + public function exposedPorts(): array; /** * Returns the first host-mapped port, or null if no ports are mapped. @@ -37,6 +30,13 @@ public function firstExposedPort(): ?int; */ public function firstHostPort(): ?int; + /** + * Returns the first container-internal exposed port, or null if no ports are exposed. + * + * @return int|null The first exposed port number, or null if none. + */ + public function firstExposedPort(): ?int; + /** * Returns the appropriate port for connecting to the container. * diff --git a/src/Waits/Conditions/ContainerReady.php b/src/Waits/Conditions/ContainerReady.php index 9a082f2..9ce2de8 100644 --- a/src/Waits/Conditions/ContainerReady.php +++ b/src/Waits/Conditions/ContainerReady.php @@ -10,7 +10,7 @@ interface ContainerReady { /** - * Checks whether the container dependency is ready to accept connections. + * Tells whether the container dependency is ready to accept connections. * * @return bool True if the dependency is ready, false otherwise. */ diff --git a/src/Waits/Conditions/MySQL/MySQLReady.php b/src/Waits/Conditions/MySQL/MySQLReady.php index 9cfac1b..e69ff53 100644 --- a/src/Waits/Conditions/MySQL/MySQLReady.php +++ b/src/Waits/Conditions/MySQL/MySQLReady.php @@ -5,7 +5,7 @@ namespace TinyBlocks\DockerContainer\Waits\Conditions\MySQL; use Throwable; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; +use TinyBlocks\DockerContainer\ContainerStarted; use TinyBlocks\DockerContainer\Waits\Conditions\ContainerReady; final readonly class MySQLReady implements ContainerReady @@ -26,10 +26,12 @@ public function isReady(): bool ->getEnvironmentVariables() ->getValueBy(key: 'MYSQL_ROOT_PASSWORD'); + $template = 'MYSQL_PWD=%s'; + return $this->container ->executeAfterStarted(commands: [ 'env', - "MYSQL_PWD=$rootPassword", + sprintf($template, $rootPassword), 'mysqladmin', 'ping', '-h', diff --git a/src/Waits/ContainerWaitAfterStarted.php b/src/Waits/ContainerWaitAfterStarted.php index 70ac8f9..b350386 100644 --- a/src/Waits/ContainerWaitAfterStarted.php +++ b/src/Waits/ContainerWaitAfterStarted.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Waits; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; +use TinyBlocks\DockerContainer\ContainerStarted; /** * Defines a wait strategy to be applied after a container has started. diff --git a/src/Waits/ContainerWaitForTime.php b/src/Waits/ContainerWaitForTime.php index d2df507..3c18b19 100644 --- a/src/Waits/ContainerWaitForTime.php +++ b/src/Waits/ContainerWaitForTime.php @@ -4,7 +4,7 @@ namespace TinyBlocks\DockerContainer\Waits; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; +use TinyBlocks\DockerContainer\ContainerStarted; final readonly class ContainerWaitForTime implements ContainerWaitBeforeStarted, ContainerWaitAfterStarted { diff --git a/tests/Integration/DockerContainerTest.php b/tests/Integration/DockerContainerTest.php index 7d98ac3..b99028e 100644 --- a/tests/Integration/DockerContainerTest.php +++ b/tests/Integration/DockerContainerTest.php @@ -47,16 +47,16 @@ public function testMultipleContainersAreRunSuccessfully(): void $environmentVariables = $mySQLStarted->getEnvironmentVariables(); $address = $mySQLStarted->getAddress(); - self::assertSame(expected: 'test-database', actual: $mySQLStarted->getName()); - self::assertSame(expected: 3306, actual: $address->getPorts()->firstExposedPort()); - self::assertSame(expected: self::DATABASE, actual: $environmentVariables->getValueBy(key: 'MYSQL_DATABASE')); + self::assertSame('test-database', $mySQLStarted->getName()); + self::assertSame(3306, $address->getPorts()->firstExposedPort()); + self::assertSame(self::DATABASE, $environmentVariables->getValueBy(key: 'MYSQL_DATABASE')); /** @And when Flyway runs migrations against the started MySQL container */ $flywayStarted = $flywayContainer ->withSource( - container: $mySQLStarted, + password: $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD'), username: $environmentVariables->getValueBy(key: 'MYSQL_USER'), - password: $environmentVariables->getValueBy(key: 'MYSQL_PASSWORD') + container: $mySQLStarted ) ->cleanAndMigrate(); @@ -65,7 +65,7 @@ public function testMultipleContainersAreRunSuccessfully(): void $records = MySQLRepository::connectFrom(container: $mySQLStarted)->allRecordsFrom(table: 'xpto'); - self::assertCount(expectedCount: 10, haystack: $records); + self::assertCount(10, $records); } public function testRunCalledTwiceForSameContainerDoesNotStartTwice(): void @@ -81,19 +81,19 @@ public function testRunCalledTwiceForSameContainerDoesNotStartTwice(): void $firstRun->stopOnShutdown(); /** @Then the container should be successfully started */ - self::assertSame(expected: '123', actual: $firstRun->getEnvironmentVariables()->getValueBy(key: 'TEST')); + 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(expected: $firstRun->getId(), actual: $secondRun->getId()); - self::assertSame(expected: $firstRun->getName(), actual: $secondRun->getName()); - self::assertEquals(expected: $firstRun->getAddress(), actual: $secondRun->getAddress()); + self::assertSame($firstRun->getId(), $secondRun->getId()); + self::assertSame($firstRun->getName(), $secondRun->getName()); + self::assertEquals($firstRun->getAddress(), $secondRun->getAddress()); self::assertEquals( - expected: $firstRun->getEnvironmentVariables(), - actual: $secondRun->getEnvironmentVariables() + $firstRun->getEnvironmentVariables(), + $secondRun->getEnvironmentVariables() ); /** @And when the container is stopped */ diff --git a/tests/Integration/MySQLRepository.php b/tests/Integration/MySQLRepository.php index 60dc3bc..7a35bb1 100644 --- a/tests/Integration/MySQLRepository.php +++ b/tests/Integration/MySQLRepository.php @@ -5,7 +5,7 @@ namespace Test\Integration; use PDO; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; +use TinyBlocks\DockerContainer\ContainerStarted; final readonly class MySQLRepository { diff --git a/tests/Unit/Mocks/InspectResponseFixture.php b/tests/Models/InspectResponseFixture.php similarity index 92% rename from tests/Unit/Mocks/InspectResponseFixture.php rename to tests/Models/InspectResponseFixture.php index 3b4582e..bc8befd 100644 --- a/tests/Unit/Mocks/InspectResponseFixture.php +++ b/tests/Models/InspectResponseFixture.php @@ -2,18 +2,12 @@ declare(strict_types=1); -namespace Test\Unit\Mocks; +namespace Test\Models; -final readonly class InspectResponseFixture +final class InspectResponseFixture { - public static function containerId(): string + private function __construct() { - return '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8'; - } - - public static function shortContainerId(): string - { - return '6acae5967be0'; } public static function build( @@ -43,4 +37,14 @@ public static function build( ] ]; } + + public static function containerId(): string + { + return '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8'; + } + + public static function shortContainerId(): string + { + return '6acae5967be0'; + } } diff --git a/tests/Unit/Mocks/ClientMock.php b/tests/Unit/ClientMock.php similarity index 79% rename from tests/Unit/Mocks/ClientMock.php rename to tests/Unit/ClientMock.php index b2c9d45..7da6d3e 100644 --- a/tests/Unit/Mocks/ClientMock.php +++ b/tests/Unit/ClientMock.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Test\Unit\Mocks; +namespace Test\Unit; use Throwable; -use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\ExecutionCompleted; use TinyBlocks\DockerContainer\Internal\Client\Client; use TinyBlocks\DockerContainer\Internal\Commands\Command; use TinyBlocks\DockerContainer\Internal\Commands\CommandWithTimeout; @@ -33,46 +33,13 @@ final class ClientMock implements Client private array $executedCommandLines = []; - private bool $runIsSuccessful = true; - - public function withDockerRunResponse(string $output, bool $isSuccessful = true): void - { - $this->runResponses[] = $output; - $this->runIsSuccessful = $isSuccessful; - } - - public function withDockerListResponse(string $output): void - { - $this->listResponses[] = $output; - } - - public function withDockerInspectResponse(array $inspectResult): void - { - $this->inspectResponses[] = $inspectResult; - } - - public function withDockerExecuteResponse(string $output, bool $isSuccessful = true): void - { - $this->executeResponses[] = [$output, $isSuccessful]; - } - - public function withDockerExecuteException(Throwable $exception): void - { - $this->executeResponses[] = $exception; - } + private array $executedArguments = []; - public function withDockerStopResponse(string $output, bool $isSuccessful = true): void - { - $this->stopResponses[] = [$output, $isSuccessful]; - } - - public function getExecutedCommandLines(): array - { - return $this->executedCommandLines; - } + private bool $runIsSuccessful = true; public function execute(Command $command): ExecutionCompleted { + $this->executedArguments[] = $command->toArguments(); $this->executedCommandLines[] = implode(' ', $command->toArguments()); if ($command instanceof CommandWithTimeout) { @@ -92,26 +59,67 @@ public function execute(Command $command): ExecutionCompleted } [$output, $isSuccessful] = match (true) { - $command instanceof DockerRun => [ + $command instanceof DockerRun => [ array_shift($this->runResponses) ?? '', $this->runIsSuccessful ], - $command instanceof DockerList => [ + $command instanceof DockerList => [ ($listOutput = array_shift($this->listResponses) ?? ''), !empty($listOutput) ], - $command instanceof DockerInspect => [ + $command instanceof DockerInspect => [ json_encode([($inspectData = array_shift($this->inspectResponses))]), !empty($inspectData) ], - $command instanceof DockerCopy => ['', true], - $command instanceof DockerPull => ['', true], - $command instanceof DockerStop => array_shift($this->stopResponses) ?? ['', true], - $command instanceof DockerNetworkCreate => ['', true], - $command instanceof DockerNetworkConnect => ['', true], - default => ['', false] + $command instanceof DockerStop => array_shift($this->stopResponses) ?? ['', true], + $command instanceof DockerCopy, + $command instanceof DockerPull, + $command instanceof DockerNetworkCreate, + $command instanceof DockerNetworkConnect => ['', true], + default => ['', false] }; return new ExecutionCompletedMock(output: (string)$output, successful: $isSuccessful); } + + public function getExecutedArguments(): array + { + return $this->executedArguments; + } + + public function withDockerRunResponse(string $output, bool $isSuccessful = true): void + { + $this->runResponses[] = $output; + $this->runIsSuccessful = $isSuccessful; + } + + public function withDockerListResponse(string $output): void + { + $this->listResponses[] = $output; + } + + public function withDockerStopResponse(string $output, bool $isSuccessful = true): void + { + $this->stopResponses[] = [$output, $isSuccessful]; + } + + public function getExecutedCommandLines(): array + { + return $this->executedCommandLines; + } + + public function withDockerExecuteResponse(string $output, bool $isSuccessful = true): void + { + $this->executeResponses[] = [$output, $isSuccessful]; + } + + public function withDockerInspectResponse(array $inspectResult): void + { + $this->inspectResponses[] = $inspectResult; + } + + public function withDockerExecuteException(Throwable $exception): void + { + $this->executeResponses[] = $exception; + } } diff --git a/tests/Unit/Mocks/CommandMock.php b/tests/Unit/CommandMock.php similarity index 92% rename from tests/Unit/Mocks/CommandMock.php rename to tests/Unit/CommandMock.php index 9503c17..bdeff4e 100644 --- a/tests/Unit/Mocks/CommandMock.php +++ b/tests/Unit/CommandMock.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\Unit\Mocks; +namespace Test\Unit; use TinyBlocks\DockerContainer\Internal\Commands\Command; diff --git a/tests/Unit/Mocks/CommandWithTimeoutMock.php b/tests/Unit/CommandWithTimeoutMock.php similarity index 94% rename from tests/Unit/Mocks/CommandWithTimeoutMock.php rename to tests/Unit/CommandWithTimeoutMock.php index 3c94f96..2116712 100644 --- a/tests/Unit/Mocks/CommandWithTimeoutMock.php +++ b/tests/Unit/CommandWithTimeoutMock.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\Unit\Mocks; +namespace Test\Unit; use TinyBlocks\DockerContainer\Internal\Commands\CommandWithTimeout; diff --git a/tests/Unit/EnvironmentFlagTest.php b/tests/Unit/EnvironmentFlagTest.php new file mode 100644 index 0000000..5530576 --- /dev/null +++ b/tests/Unit/EnvironmentFlagTest.php @@ -0,0 +1,68 @@ +newInstanceWithoutConstructor(); + + /** @When the private constructor is invoked through reflection */ + $reflection->getMethod('__construct')->invoke($instance); + + /** @Then the environment flag instance is created */ + self::assertInstanceOf(EnvironmentFlag::class, $instance); + } +} diff --git a/tests/Unit/Mocks/ExecutionCompletedMock.php b/tests/Unit/ExecutionCompletedMock.php similarity index 81% rename from tests/Unit/Mocks/ExecutionCompletedMock.php rename to tests/Unit/ExecutionCompletedMock.php index d4dcc6b..267746d 100644 --- a/tests/Unit/Mocks/ExecutionCompletedMock.php +++ b/tests/Unit/ExecutionCompletedMock.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Test\Unit\Mocks; +namespace Test\Unit; -use TinyBlocks\DockerContainer\Contracts\ExecutionCompleted; +use TinyBlocks\DockerContainer\ExecutionCompleted; final readonly class ExecutionCompletedMock implements ExecutionCompleted { diff --git a/tests/Unit/FlywayDockerContainerTest.php b/tests/Unit/FlywayDockerContainerTest.php index 16108ef..409b2b2 100644 --- a/tests/Unit/FlywayDockerContainerTest.php +++ b/tests/Unit/FlywayDockerContainerTest.php @@ -5,11 +5,7 @@ namespace Test\Unit; use PHPUnit\Framework\TestCase; -use Test\Unit\Mocks\ClientMock; -use Test\Unit\Mocks\InspectResponseFixture; -use Test\Unit\Mocks\TestableFlywayDockerContainer; -use Test\Unit\Mocks\TestableMySQLDockerContainer; -use TinyBlocks\DockerContainer\Contracts\MySQL\MySQLContainerStarted; +use Test\Models\InspectResponseFixture; final class FlywayDockerContainerTest extends TestCase { @@ -20,35 +16,40 @@ protected function setUp(): void $this->client = new ClientMock(); } - public function testMigrateRunsFlywayMigrateCommand(): void + public function testPullImageStartsBackgroundPull(): void { - /** @Given a Flyway container */ + /** @Given a Flyway container with image pulling enabled */ $container = TestableFlywayDockerContainer::createWith( + name: 'flyway-pull', image: 'flyway/flyway:12-alpine', - name: 'flyway-alpha', client: $this->client - ); + )->pullImage(); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-alpha') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-pull') ); /** @When migrate is called */ $started = $container->migrate(); - /** @Then the container should have executed the migrate command */ - self::assertSame(expected: 'flyway-alpha', actual: $started->getName()); - self::assertCommandLineContains(needle: 'migrate', commandLines: $this->client->getExecutedCommandLines()); + /** @Then the container should start successfully after the pull completes */ + self::assertSame('flyway-pull', $started->getName()); + + /** @And the docker pull command should have been executed */ + self::assertStringContainsString( + 'docker pull flyway/flyway:12-alpine', + implode(PHP_EOL, $this->client->getExecutedCommandLines()) + ); } public function testRepairRunsFlywayRepairCommand(): void { /** @Given a Flyway container */ $container = TestableFlywayDockerContainer::createWith( - image: 'flyway/flyway:12-alpine', name: 'flyway-beta', + image: 'flyway/flyway:12-alpine', client: $this->client ); @@ -62,39 +63,53 @@ public function testRepairRunsFlywayRepairCommand(): void $started = $container->repair(); /** @Then the container should have executed the repair command */ - self::assertSame(expected: 'flyway-beta', actual: $started->getName()); - self::assertCommandLineContains(needle: 'repair', commandLines: $this->client->getExecutedCommandLines()); + self::assertSame('flyway-beta', $started->getName()); + self::assertStringContainsString('repair', implode(PHP_EOL, $this->client->getExecutedCommandLines())); } - public function testValidateRunsFlywayValidateCommand(): void + public function testWithTableOverridesDefaultTable(): void { - /** @Given a Flyway container */ + /** @Given a running MySQL container */ + $mySQLStarted = RunningMySQLContainer::startWith( + client: $this->client, + database: 'test_db', + hostname: 'custom-table-db' + ); + + /** @And a Flyway container with source and a table override */ $container = TestableFlywayDockerContainer::createWith( + name: 'flyway-override-table', image: 'flyway/flyway:12-alpine', - name: 'flyway-gamma', client: $this->client - ); + ) + ->withSource(password: 'root', username: 'root', container: $mySQLStarted) + ->withTable(table: 'flyway_history'); - /** @And the Docker daemon returns valid responses */ + /** @And the MySQL readiness check succeeds */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @And the Docker daemon returns valid Flyway responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-gamma') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-override-table') ); - /** @When validate is called */ - $started = $container->validate(); + /** @When migrate is called */ + $container->migrate(); - /** @Then the container should have executed the validate command */ - self::assertSame(expected: 'flyway-gamma', actual: $started->getName()); - self::assertCommandLineContains(needle: 'validate', commandLines: $this->client->getExecutedCommandLines()); + /** @Then the overridden table should be present in the command */ + self::assertStringContainsString( + 'FLYWAY_TABLE=flyway_history', + implode(PHP_EOL, $this->client->getExecutedCommandLines()) + ); } public function testCleanAndMigrateRunsBothCommands(): void { /** @Given a Flyway container */ $container = TestableFlywayDockerContainer::createWith( - image: 'flyway/flyway:12-alpine', name: 'flyway-clean-migrate', + image: 'flyway/flyway:12-alpine', client: $this->client ); @@ -110,146 +125,106 @@ public function testCleanAndMigrateRunsBothCommands(): void $elapsed = microtime(true) - $start; /** @Then the container should have executed clean followed by migrate */ - self::assertSame(expected: 'flyway-clean-migrate', actual: $started->getName()); - self::assertCommandLineContains( - needle: 'clean migrate', - commandLines: $this->client->getExecutedCommandLines() - ); + self::assertSame('flyway-clean-migrate', $started->getName()); + self::assertStringContainsString('clean migrate', implode(PHP_EOL, $this->client->getExecutedCommandLines())); /** @And the wait time should be exactly 10 seconds */ - self::assertGreaterThanOrEqual(minimum: 9.5, actual: $elapsed); - self::assertLessThanOrEqual(maximum: 10.5, actual: $elapsed); + self::assertGreaterThanOrEqual(9.5, $elapsed); + self::assertLessThanOrEqual(10.5, $elapsed); } - public function testWithSourceAutoDetectsSchemaFromMySQLContainer(): void + public function testMigrateRunsFlywayMigrateCommand(): void { - /** @Given a running MySQL container with database "products" */ - $mySQLStarted = $this->createRunningMySQLContainer( - hostname: 'schema-db', - database: 'products' - ); - - /** @And a Flyway container configured with the MySQL source */ + /** @Given a Flyway container */ $container = TestableFlywayDockerContainer::createWith( + name: 'flyway-alpha', image: 'flyway/flyway:12-alpine', - name: 'flyway-schema', client: $this->client - )->withSource(container: $mySQLStarted, username: 'root', password: 'root'); - - /** @And the MySQL readiness check succeeds during Flyway startup */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + ); - /** @And the Docker daemon returns valid Flyway responses */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-schema') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-alpha') ); /** @When migrate is called */ - $container->migrate(); + $started = $container->migrate(); - /** @Then FLYWAY_SCHEMAS should be auto-detected from the MySQL database name */ - self::assertCommandLineContains( - needle: 'FLYWAY_SCHEMAS=products', - commandLines: $this->client->getExecutedCommandLines() - ); + /** @Then the container should have executed the migrate command */ + self::assertSame('flyway-alpha', $started->getName()); + self::assertStringContainsString('migrate', implode(PHP_EOL, $this->client->getExecutedCommandLines())); } - public function testWithSourceSetsDefaultSchemaHistoryTable(): void + public function testValidateRunsFlywayValidateCommand(): void { - /** @Given a running MySQL container */ - $mySQLStarted = $this->createRunningMySQLContainer( - hostname: 'table-db', - database: 'test_db' - ); - - /** @And a Flyway container configured with the MySQL source */ + /** @Given a Flyway container */ $container = TestableFlywayDockerContainer::createWith( + name: 'flyway-gamma', image: 'flyway/flyway:12-alpine', - name: 'flyway-table', client: $this->client - )->withSource(container: $mySQLStarted, username: 'root', password: 'root'); - - /** @And the MySQL readiness check succeeds */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + ); - /** @And the Docker daemon returns valid Flyway responses */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-table') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-gamma') ); - /** @When migrate is called */ - $container->migrate(); + /** @When validate is called */ + $started = $container->validate(); - /** @Then FLYWAY_TABLE should default to "schema_history" */ - self::assertCommandLineContains( - needle: 'FLYWAY_TABLE=schema_history', - commandLines: $this->client->getExecutedCommandLines() - ); + /** @Then the container should have executed the validate command */ + self::assertSame('flyway-gamma', $started->getName()); + self::assertStringContainsString('validate', implode(PHP_EOL, $this->client->getExecutedCommandLines())); } - public function testWithSourceConfiguresJdbcUrlAndCredentials(): void + public function testWithNetworkConfiguresDockerNetwork(): void { - /** @Given a running MySQL container */ - $mySQLStarted = $this->createRunningMySQLContainer( - hostname: 'source-db', - database: 'app_database' - ); - - /** @And a Flyway container configured with the MySQL source */ + /** @Given a Flyway container with a network */ $container = TestableFlywayDockerContainer::createWith( + name: 'flyway-network', image: 'flyway/flyway:12-alpine', - name: 'flyway-source', client: $this->client - )->withSource(container: $mySQLStarted, username: 'admin', password: 'secret'); - - /** @And the MySQL readiness check succeeds */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + )->withNetwork(name: 'test-network'); - /** @And the Docker daemon returns valid Flyway responses */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-source') + inspectResult: InspectResponseFixture::build( + hostname: 'flyway-network', + networkName: 'test-network' + ) ); /** @When migrate is called */ $container->migrate(); - /** @Then the docker run command should include the JDBC URL and credentials */ + /** @Then the docker run command should include the network and auto-creation */ $commandLines = $this->client->getExecutedCommandLines(); - self::assertCommandLineContains( - needle: 'FLYWAY_URL=jdbc:mysql://source-db:3306/app_database', - commandLines: $commandLines - ); - self::assertCommandLineContains(needle: 'FLYWAY_USER=admin', commandLines: $commandLines); - self::assertCommandLineContains(needle: 'FLYWAY_PASSWORD=secret', commandLines: $commandLines); - - /** @And a MySQL readiness check should have been executed before Flyway started */ - $mysqladminPingCount = count( - array_filter( - $commandLines, - static fn(string $cmd): bool => str_contains($cmd, 'mysqladmin ping') - ) + self::assertStringContainsString('--network=test-network', implode(PHP_EOL, $commandLines)); + self::assertStringContainsString( + 'docker network create --label tiny-blocks.docker-container=true test-network', + implode(PHP_EOL, $commandLines) ); - self::assertSame(expected: 2, actual: $mysqladminPingCount); } public function testWithSchemaOverridesAutoDetectedSchema(): void { /** @Given a running MySQL container with database "original" */ - $mySQLStarted = $this->createRunningMySQLContainer( - hostname: 'override-db', - database: 'original' + $mySQLStarted = RunningMySQLContainer::startWith( + client: $this->client, + database: 'original', + hostname: 'override-db' ); /** @And a Flyway container with source and a schema override */ $container = TestableFlywayDockerContainer::createWith( - image: 'flyway/flyway:12-alpine', name: 'flyway-override-schema', + image: 'flyway/flyway:12-alpine', client: $this->client ) - ->withSource(container: $mySQLStarted, username: 'root', password: 'root') + ->withSource(password: 'root', username: 'root', container: $mySQLStarted) ->withSchema(schema: 'custom_schema'); /** @And the MySQL readiness check succeeds */ @@ -265,81 +240,80 @@ public function testWithSchemaOverridesAutoDetectedSchema(): void $container->migrate(); /** @Then the overridden schema should be present in the command */ - self::assertCommandLineContains( - needle: 'FLYWAY_SCHEMAS=custom_schema', - commandLines: $this->client->getExecutedCommandLines() + self::assertStringContainsString( + 'FLYWAY_SCHEMAS=custom_schema', + implode(PHP_EOL, $this->client->getExecutedCommandLines()) ); } - public function testWithTableOverridesDefaultTable(): void + public function testWithMigrationsConfiguresCopyAndLocation(): void { - /** @Given a running MySQL container */ - $mySQLStarted = $this->createRunningMySQLContainer( - hostname: 'custom-table-db', - database: 'test_db' - ); - - /** @And a Flyway container with source and a table override */ + /** @Given a Flyway container with migrations configured */ $container = TestableFlywayDockerContainer::createWith( + name: 'flyway-migrations', image: 'flyway/flyway:12-alpine', - name: 'flyway-override-table', client: $this->client - ) - ->withSource(container: $mySQLStarted, username: 'root', password: 'root') - ->withTable(table: 'flyway_history'); - - /** @And the MySQL readiness check succeeds */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + )->withMigrations(pathOnHost: '/host/migrations'); - /** @And the Docker daemon returns valid Flyway responses */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-override-table') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-migrations') ); /** @When migrate is called */ $container->migrate(); - /** @Then the overridden table should be present in the command */ - self::assertCommandLineContains( - needle: 'FLYWAY_TABLE=flyway_history', - commandLines: $this->client->getExecutedCommandLines() + /** @Then the FLYWAY_LOCATIONS should point to the container migrations path */ + $commandLines = $this->client->getExecutedCommandLines(); + self::assertStringContainsString( + 'FLYWAY_LOCATIONS=filesystem:/flyway/migrations', + implode(PHP_EOL, $commandLines) ); + self::assertStringContainsString('docker cp /host/migrations', implode(PHP_EOL, $commandLines)); } - public function testWithMigrationsConfiguresCopyAndLocation(): void + public function testWithSourceSetsDefaultSchemaHistoryTable(): void { - /** @Given a Flyway container with migrations configured */ + /** @Given a running MySQL container */ + $mySQLStarted = RunningMySQLContainer::startWith( + client: $this->client, + database: 'test_db', + hostname: 'table-db' + ); + + /** @And a Flyway container configured with the MySQL source */ $container = TestableFlywayDockerContainer::createWith( + name: 'flyway-table', image: 'flyway/flyway:12-alpine', - name: 'flyway-migrations', client: $this->client - )->withMigrations(pathOnHost: '/host/migrations'); + )->withSource(password: 'root', username: 'root', container: $mySQLStarted); - /** @And the Docker daemon returns valid responses */ + /** @And the MySQL readiness check succeeds */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @And the Docker daemon returns valid Flyway responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-migrations') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-table') ); /** @When migrate is called */ $container->migrate(); - /** @Then the FLYWAY_LOCATIONS should point to the container migrations path */ - $commandLines = $this->client->getExecutedCommandLines(); - self::assertCommandLineContains( - needle: 'FLYWAY_LOCATIONS=filesystem:/flyway/migrations', - commandLines: $commandLines + /** @Then FLYWAY_TABLE should default to "schema_history" */ + self::assertStringContainsString( + 'FLYWAY_TABLE=schema_history', + implode(PHP_EOL, $this->client->getExecutedCommandLines()) ); - self::assertCommandLineContains(needle: 'docker cp /host/migrations', commandLines: $commandLines); } public function testWithCleanDisabledSetsEnvironmentVariable(): void { /** @Given a Flyway container with clean disabled */ $container = TestableFlywayDockerContainer::createWith( - image: 'flyway/flyway:12-alpine', name: 'flyway-clean-disabled', + image: 'flyway/flyway:12-alpine', client: $this->client )->withCleanDisabled(disabled: true); @@ -353,9 +327,9 @@ public function testWithCleanDisabledSetsEnvironmentVariable(): void $container->migrate(); /** @Then FLYWAY_CLEAN_DISABLED should be set to true */ - self::assertCommandLineContains( - needle: 'FLYWAY_CLEAN_DISABLED=true', - commandLines: $this->client->getExecutedCommandLines() + self::assertStringContainsString( + 'FLYWAY_CLEAN_DISABLED=true', + implode(PHP_EOL, $this->client->getExecutedCommandLines()) ); } @@ -363,8 +337,8 @@ public function testWithConnectRetriesSetsEnvironmentVariable(): void { /** @Given a Flyway container with connect retries configured */ $container = TestableFlywayDockerContainer::createWith( - image: 'flyway/flyway:12-alpine', name: 'flyway-retries', + image: 'flyway/flyway:12-alpine', client: $this->client )->withConnectRetries(retries: 10); @@ -378,139 +352,116 @@ public function testWithConnectRetriesSetsEnvironmentVariable(): void $container->migrate(); /** @Then FLYWAY_CONNECT_RETRIES should be set to 10 */ - self::assertCommandLineContains( - needle: 'FLYWAY_CONNECT_RETRIES=10', - commandLines: $this->client->getExecutedCommandLines() + self::assertStringContainsString( + 'FLYWAY_CONNECT_RETRIES=10', + implode(PHP_EOL, $this->client->getExecutedCommandLines()) ); } - public function testWithValidateMigrationNamingSetsEnvironmentVariable(): void + public function testWithSourceConfiguresJdbcUrlAndCredentials(): void { - /** @Given a Flyway container with migration naming validation enabled */ + /** @Given a running MySQL container */ + $mySQLStarted = RunningMySQLContainer::startWith( + client: $this->client, + database: 'app_database', + hostname: 'source-db' + ); + + /** @And a Flyway container configured with the MySQL source */ $container = TestableFlywayDockerContainer::createWith( + name: 'flyway-source', image: 'flyway/flyway:12-alpine', - name: 'flyway-naming', client: $this->client - )->withValidateMigrationNaming(enabled: true); + )->withSource(password: 'secret', username: 'admin', container: $mySQLStarted); - /** @And the Docker daemon returns valid responses */ + /** @And the MySQL readiness check succeeds */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @And the Docker daemon returns valid Flyway responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-naming') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-source') ); /** @When migrate is called */ $container->migrate(); - /** @Then FLYWAY_VALIDATE_MIGRATION_NAMING should be set to true */ - self::assertCommandLineContains( - needle: 'FLYWAY_VALIDATE_MIGRATION_NAMING=true', - commandLines: $this->client->getExecutedCommandLines() + /** @Then the docker run command should include the JDBC URL and credentials */ + $commandLines = $this->client->getExecutedCommandLines(); + self::assertStringContainsString( + 'FLYWAY_URL=jdbc:mysql://source-db:3306/app_database', + implode(PHP_EOL, $commandLines) + ); + self::assertStringContainsString('FLYWAY_USER=admin', implode(PHP_EOL, $commandLines)); + self::assertStringContainsString('FLYWAY_PASSWORD=secret', implode(PHP_EOL, $commandLines)); + + /** @And a MySQL readiness check should have been executed before Flyway started */ + $mysqladminPingCount = count( + array_filter( + $commandLines, + static fn(string $cmd): bool => str_contains($cmd, 'mysqladmin ping') + ) ); + self::assertSame(2, $mysqladminPingCount); } - public function testWithNetworkConfiguresDockerNetwork(): void + public function testWithSourceAutoDetectsSchemaFromMySQLContainer(): void { - /** @Given a Flyway container with a network */ + /** @Given a running MySQL container with database "products" */ + $mySQLStarted = RunningMySQLContainer::startWith( + client: $this->client, + database: 'products', + hostname: 'schema-db' + ); + + /** @And a Flyway container configured with the MySQL source */ $container = TestableFlywayDockerContainer::createWith( + name: 'flyway-schema', image: 'flyway/flyway:12-alpine', - name: 'flyway-network', client: $this->client - )->withNetwork(name: 'test-network'); + )->withSource(password: 'root', username: 'root', container: $mySQLStarted); - /** @And the Docker daemon returns valid responses */ + /** @And the MySQL readiness check succeeds during Flyway startup */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @And the Docker daemon returns valid Flyway responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'flyway-network', - networkName: 'test-network' - ) + inspectResult: InspectResponseFixture::build(hostname: 'flyway-schema') ); /** @When migrate is called */ $container->migrate(); - /** @Then the docker run command should include the network and auto-creation */ - $commandLines = $this->client->getExecutedCommandLines(); - self::assertCommandLineContains(needle: '--network=test-network', commandLines: $commandLines); - self::assertCommandLineContains( - needle: 'docker network create --label tiny-blocks.docker-container=true test-network', - commandLines: $commandLines + /** @Then FLYWAY_SCHEMAS should be auto-detected from the MySQL database name */ + self::assertStringContainsString( + 'FLYWAY_SCHEMAS=products', + implode(PHP_EOL, $this->client->getExecutedCommandLines()) ); } - public function testPullImageStartsBackgroundPull(): void + public function testWithValidateMigrationNamingSetsEnvironmentVariable(): void { - /** @Given a Flyway container with image pulling enabled */ + /** @Given a Flyway container with migration naming validation enabled */ $container = TestableFlywayDockerContainer::createWith( + name: 'flyway-naming', image: 'flyway/flyway:12-alpine', - name: 'flyway-pull', client: $this->client - )->pullImage(); + )->withValidateMigrationNaming(enabled: true); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'flyway-pull') + inspectResult: InspectResponseFixture::build(hostname: 'flyway-naming') ); /** @When migrate is called */ - $started = $container->migrate(); - - /** @Then the container should start successfully after the pull completes */ - self::assertSame(expected: 'flyway-pull', actual: $started->getName()); - - /** @And the docker pull command should have been executed */ - self::assertCommandLineContains( - needle: 'docker pull flyway/flyway:12-alpine', - commandLines: $this->client->getExecutedCommandLines() - ); - } - - protected function createRunningMySQLContainer(string $hostname, string $database): MySQLContainerStarted - { - $container = TestableMySQLDockerContainer::createWith( - image: 'mysql:8.4', - name: $hostname, - client: $this->client - ) - ->withDatabase(database: $database) - ->withRootPassword(rootPassword: 'root'); - - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: $hostname, - environment: [ - sprintf('MYSQL_DATABASE=%s', $database), - 'MYSQL_ROOT_PASSWORD=root' - ], - exposedPorts: ['3306/tcp' => (object)[]] - ) - ); - - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - $this->client->withDockerExecuteResponse(output: ''); - - return $container->run(); - } + $container->migrate(); - protected static function assertCommandLineContains(string $needle, array $commandLines): void - { - foreach ($commandLines as $commandLine) { - if (str_contains((string)$commandLine, $needle)) { - self::assertTrue(true); - return; - } - } - - self::fail( - sprintf( - 'Expected command containing "%s" not found in executed commands:%s%s', - $needle, - PHP_EOL, - implode(PHP_EOL, $commandLines) - ) + /** @Then FLYWAY_VALIDATE_MIGRATION_NAMING should be set to true */ + self::assertStringContainsString( + 'FLYWAY_VALIDATE_MIGRATION_NAMING=true', + implode(PHP_EOL, $this->client->getExecutedCommandLines()) ); } } diff --git a/tests/Unit/GenericDockerContainerTest.php b/tests/Unit/GenericDockerContainerTest.php index 33fc6b5..b9d0e61 100644 --- a/tests/Unit/GenericDockerContainerTest.php +++ b/tests/Unit/GenericDockerContainerTest.php @@ -7,10 +7,8 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; -use Test\Unit\Mocks\ClientMock; -use Test\Unit\Mocks\InspectResponseFixture; -use Test\Unit\Mocks\ShutdownHookMock; -use Test\Unit\Mocks\TestableGenericDockerContainer; +use Test\Models\InspectResponseFixture; +use TinyBlocks\DockerContainer\ContainerStarted; use TinyBlocks\DockerContainer\GenericDockerContainer; use TinyBlocks\DockerContainer\Internal\Exceptions\ContainerWaitTimeout; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; @@ -28,150 +26,109 @@ protected function setUp(): void $this->client = new ClientMock(); } - public function testRunContainerSuccessfully(): void + public function testStopContainer(): void { - /** @Given a container configured with an image and a name */ + /** @Given a running container */ $container = TestableGenericDockerContainer::createWith( + name: 'stop-test', image: 'alpine:latest', - name: 'test-alpine', client: $this->client ); - /** @And the Docker daemon returns a valid container ID and inspect response */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'test-alpine', - environment: ['PATH=/usr/local/bin'] - ) - ); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-test')); + $this->client->withDockerStopResponse(output: ''); - /** @When the container is started */ + /** @And the container is started */ $started = $container->run(); - /** @Then the container should be running with the expected properties */ - self::assertSame(expected: InspectResponseFixture::shortContainerId(), actual: $started->getId()); - self::assertSame(expected: 'test-alpine', actual: $started->getName()); - self::assertSame(expected: 'test-alpine', actual: $started->getAddress()->getHostname()); - self::assertSame(expected: '172.22.0.2', actual: $started->getAddress()->getIp()); + /** @When the container is stopped */ + $stopped = $started->stop(); + + /** @Then the stop should be successful */ + self::assertTrue($stopped->isSuccessful()); } - public function testRunContainerWithFullConfiguration(): void + public function testExecuteAfterStarted(): void { - /** @Given a fully configured container */ + /** @Given a running container */ $container = TestableGenericDockerContainer::createWith( - image: 'nginx:latest', - name: 'web-server', + name: 'exec-test', + image: 'alpine:latest', client: $this->client - ) - ->withNetwork(name: 'my-network') - ->withPortMapping(portOnHost: 8080, portOnContainer: 80) - ->withVolumeMapping(pathOnHost: '/var/www', pathOnContainer: '/usr/share/nginx/html') - ->withEnvironmentVariable(key: 'NGINX_HOST', value: 'localhost') - ->withEnvironmentVariable(key: 'NGINX_PORT', value: '80'); + ); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'web-server', - environment: ['NGINX_HOST=localhost', 'NGINX_PORT=80'], - networkName: 'my-network', - exposedPorts: ['80/tcp' => (object)[]] - ) - ); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-test')); + $this->client->withDockerExecuteResponse(output: 'command output'); - /** @When the container is started */ + /** @And the container is started */ $started = $container->run(); - /** @Then the container should expose the configured environment variables */ - self::assertSame( - expected: 'localhost', - actual: $started->getEnvironmentVariables()->getValueBy( - key: 'NGINX_HOST' - ) - ); - self::assertSame(expected: '80', actual: $started->getEnvironmentVariables()->getValueBy(key: 'NGINX_PORT')); + /** @When commands are executed inside the running container */ + $execution = $started->executeAfterStarted(commands: ['ls', '-la']); - /** @And the address should reflect the exposed port */ - self::assertSame(expected: 80, actual: $started->getAddress()->getPorts()->firstExposedPort()); - self::assertSame(expected: [80], actual: $started->getAddress()->getPorts()->exposedPorts()); + /** @Then the execution should be successful */ + self::assertTrue($execution->isSuccessful()); + self::assertSame('command output', $execution->getOutput()); } - public function testRunContainerWithMultiplePortMappings(): void + public function testExceptionWhenRunFails(): void { - /** @Given a container with multiple port mappings */ + /** @Given a container that will fail to start */ $container = TestableGenericDockerContainer::createWith( - image: 'nginx:latest', - name: 'multi-port', + name: 'fail-test', + image: 'invalid:image', client: $this->client - ) - ->withPortMapping(portOnHost: 8080, portOnContainer: 80) - ->withPortMapping(portOnHost: 8443, portOnContainer: 443); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'multi-port', - exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[]] - ) ); - /** @When the container is started */ - $started = $container->run(); - - /** @Then both ports should be exposed */ - self::assertSame(expected: [80, 443], actual: $started->getAddress()->getPorts()->exposedPorts()); - self::assertSame(expected: 80, actual: $started->getAddress()->getPorts()->firstExposedPort()); - } - - public function testRunContainerWithoutAutoRemove(): void - { - /** @Given a container with auto-remove disabled */ - $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', - name: 'persistent', - client: $this->client - )->withoutAutoRemove(); + /** @And the Docker daemon returns a failure */ + $this->client->withDockerRunResponse(output: 'Cannot connect to the Docker daemon.', isSuccessful: false); - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'persistent')); + /** @Then a DockerCommandExecutionFailed exception should be thrown */ + $this->expectException(DockerCommandExecutionFailed::class); + $this->expectExceptionMessageMatches('/Cannot connect to the Docker daemon/'); /** @When the container is started */ - $started = $container->run(); - - /** @Then the container should be running */ - self::assertSame(expected: 'persistent', actual: $started->getName()); + $container->run(); } - public function testRunContainerWithCopyToContainer(): void + public function testRunContainerSuccessfully(): void { - /** @Given a container with files to copy */ + /** @Given a container configured with an image and a name */ $container = TestableGenericDockerContainer::createWith( + name: 'test-alpine', image: 'alpine:latest', - name: 'copy-test', client: $this->client - )->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config'); + ); - /** @And the Docker daemon returns valid responses */ + /** @And the Docker daemon returns a valid container ID and inspect response */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'copy-test')); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'test-alpine', + environment: ['PATH=/usr/local/bin'] + ) + ); - /** @When the container is started (docker cp is automatically called) */ + /** @When the container is started */ $started = $container->run(); - /** @Then the container should be running */ - self::assertSame(expected: 'copy-test', actual: $started->getName()); + /** @Then the container should be running with the expected properties */ + self::assertSame(InspectResponseFixture::shortContainerId(), $started->getId()); + self::assertSame('test-alpine', $started->getName()); + self::assertSame('test-alpine', $started->getAddress()->getHostname()); + self::assertSame('172.22.0.2', $started->getAddress()->getIp()); } public function testRunContainerWithCommands(): void { /** @Given a container */ $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', name: 'cmd-test', + image: 'alpine:latest', client: $this->client ); @@ -183,258 +140,725 @@ public function testRunContainerWithCommands(): void $started = $container->run(commands: ['echo', 'hello']); /** @Then the container should be running */ - self::assertSame(expected: 'cmd-test', actual: $started->getName()); + self::assertSame('cmd-test', $started->getName()); } - public function testRunContainerWithWaitBeforeRun(): void + public function testRunContainerWithPullImage(): void { - /** @Given a condition that is immediately ready */ - $condition = $this->createMock(ContainerReady::class); - $condition->expects(self::once())->method('isReady')->willReturn(true); - - /** @And a container with a wait-before-run condition */ + /** @Given a container with image pulling enabled */ $container = TestableGenericDockerContainer::createWith( + name: 'pull-test', image: 'alpine:latest', - name: 'wait-test', client: $this->client - )->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); + )->pullImage(); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-test')); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'pull-test')); - /** @When the container is started */ + /** @When the container is started (waiting for the image pull to complete first) */ $started = $container->run(); - /** @Then the container should be running (wait was called) */ - self::assertSame(expected: 'wait-test', actual: $started->getName()); + /** @Then the container should be running */ + self::assertSame('pull-test', $started->getName()); + + /** @And the docker pull command should have been executed */ + $commandLines = $this->client->getExecutedCommandLines(); + self::assertStringContainsString('docker pull alpine:latest', $commandLines[0]); } - public function testRunIfNotExistsCreatesNewContainer(): void + public function testContainerWithHostPortMapping(): void { - /** @Given a container that does not exist */ + /** @Given a container with a host port mapping */ $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', - name: 'new-container', + name: 'host-port', + image: 'mysql:8.4', client: $this->client - )->withEnvironmentVariable(key: 'APP_ENV', value: 'test'); - - /** @And the Docker list returns empty (container does not exist) */ - $this->client->withDockerListResponse(output: ''); + )->withPortMapping(portOnHost: 33060, portOnContainer: 3306); - /** @And the Docker daemon returns valid run and inspect responses */ + /** @And the Docker daemon returns a response with host port bindings */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'new-container', - environment: ['APP_ENV=test'] + hostname: 'host-port', + exposedPorts: ['3306/tcp' => (object)[]], + hostPortBindings: [ + '3306/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '33060']] + ] ) ); - /** @When runIfNotExists is called */ - $started = $container->runIfNotExists(); + /** @When the container is started */ + $started = $container->run(); - /** @Then a new container should be created */ - self::assertSame(expected: 'new-container', actual: $started->getName()); - self::assertSame(expected: 'test', actual: $started->getEnvironmentVariables()->getValueBy(key: 'APP_ENV')); + /** @Then the exposed port should be the container-internal port */ + self::assertSame(3306, $started->getAddress()->getPorts()->firstExposedPort()); + + /** @And the host port should be the host-mapped port */ + self::assertSame(33060, $started->getAddress()->getPorts()->firstHostPort()); + self::assertSame([33060], $started->getAddress()->getPorts()->hostPorts()); } - public function testRunIfNotExistsTreatsWhitespaceOnlyListOutputAsMissingContainer(): void + public function testExceptionWhenImageNameIsEmpty(): void { - /** @Given a container that does not exist according to a whitespace-only docker list response */ - $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', - name: 'whitespace-list', - client: $this->client - ); - - /** @And the Docker list returns only whitespace */ - $this->client->withDockerListResponse(output: " \n\t "); - - /** @And the Docker daemon returns valid run and inspect responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'whitespace-list') - ); - - /** @When runIfNotExists is called */ - $started = $container->runIfNotExists(); + /** @Then an InvalidArgumentException should be thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Image name cannot be empty.'); - /** @Then a new container should be created because the whitespace list output is trimmed */ - self::assertSame(expected: 'whitespace-list', actual: $started->getName()); + /** @When creating a container with an empty image name */ + GenericDockerContainer::from(image: ''); } - public function testRunIfNotExistsReturnsExistingContainer(): void + public function testRemoveOnReusedContainerIsNoOp(): void { - /** @Given a container that already exists */ + /** @Given a container returned by runIfNotExists (a Reused instance) */ $container = TestableGenericDockerContainer::createWith( + name: 'reused-remove', image: 'alpine:latest', - name: 'existing', client: $this->client ); - /** @And the Docker list returns the existing container ID */ + /** @And the Docker list returns an existing container */ $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); /** @And the Docker inspect returns the container details */ $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'existing', - environment: ['EXISTING=true'] - ) + inspectResult: InspectResponseFixture::build(hostname: 'reused-remove') ); - /** @When runIfNotExists is called */ + /** @When runIfNotExists returns a reused container */ $started = $container->runIfNotExists(); - /** @Then the existing container should be returned */ - self::assertSame(expected: 'existing', actual: $started->getName()); - self::assertSame(expected: InspectResponseFixture::shortContainerId(), actual: $started->getId()); - self::assertSame(expected: 'true', actual: $started->getEnvironmentVariables()->getValueBy(key: 'EXISTING')); + /** @And remove is called on the reused container */ + $started->remove(); + + /** @Then the container should still be accessible (remove is a no-op for reused containers) */ + self::assertSame('reused-remove', $started->getName()); } - public function testStopContainer(): void + public function testRunCommandLineIncludesNetwork(): void { - /** @Given a running container */ + /** @Given a container with a network */ $container = TestableGenericDockerContainer::createWith( + name: 'net-cmd', image: 'alpine:latest', - name: 'stop-test', client: $this->client - ); + )->withNetwork(name: 'my-network'); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-test')); - $this->client->withDockerStopResponse(output: ''); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'net-cmd')); - /** @And the container is started */ - $started = $container->run(); + /** @When the container is started */ + $container->run(); - /** @When the container is stopped */ - $stopped = $started->stop(); + /** @Then the first command should be the network creation */ + $networkCommand = $this->client->getExecutedCommandLines()[0]; + self::assertStringContainsString( + 'docker network create --label tiny-blocks.docker-container=true my-network', + $networkCommand + ); - /** @Then the stop should be successful */ - self::assertTrue($stopped->isSuccessful()); + /** @And the docker run command should contain the network argument */ + $runCommand = $this->client->getExecutedCommandLines()[2]; + self::assertStringContainsString('--network=my-network', $runCommand); } - public function testExecuteAfterStarted(): void + public function testRunContainerWithWaitBeforeRun(): void { - /** @Given a running container */ - $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', - name: 'exec-test', + /** @Given a condition that is immediately ready */ + $condition = $this->createMock(ContainerReady::class); + $condition->expects(self::once())->method('isReady')->willReturn(true); + + /** @And a container with a wait-before-run condition */ + $container = TestableGenericDockerContainer::createWith( + name: 'wait-test', + image: 'alpine:latest', + client: $this->client + )->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-test')); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then the container should be running (wait was called) */ + self::assertSame('wait-test', $started->getName()); + } + + public function testRunContainerWithoutAutoRemove(): void + { + /** @Given a container with auto-remove disabled */ + $container = TestableGenericDockerContainer::createWith( + name: 'persistent', + image: 'alpine:latest', + client: $this->client + )->withoutAutoRemove(); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'persistent')); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then the container should be running */ + self::assertSame('persistent', $started->getName()); + } + + public function testStopExecutesDockerStopCommand(): void + { + /** @Given a running container */ + $container = TestableGenericDockerContainer::createWith( + name: 'stop-cmd', + image: 'alpine:latest', client: $this->client ); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-test')); - $this->client->withDockerExecuteResponse(output: 'command output'); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-cmd')); + $this->client->withDockerStopResponse(output: ''); /** @And the container is started */ $started = $container->run(); - /** @When commands are executed inside the running container */ - $execution = $started->executeAfterStarted(commands: ['ls', '-la']); + /** @When the container is stopped */ + $started->stop(); - /** @Then the execution should be successful */ - self::assertTrue($execution->isSuccessful()); - self::assertSame(expected: 'command output', actual: $execution->getOutput()); + /** @Then a docker stop command should have been executed with the container ID */ + $stopCommand = $this->client->getExecutedCommandLines()[2]; + self::assertStringStartsWith('docker stop', $stopCommand); + self::assertStringContainsString(InspectResponseFixture::shortContainerId(), $stopCommand); } - public function testExceptionWhenRunFails(): void + public function testStopOnShutdownRegistersRemove(): void { - /** @Given a container that will fail to start */ + /** @Given a ShutdownHook that tracks registration */ + $shutdownHook = new ShutdownHookMock(); + + /** @And a running container using the tracked hook */ $container = TestableGenericDockerContainer::createWith( - image: 'invalid:image', - name: 'fail-test', + name: 'shutdown-test', + image: 'alpine:latest', + client: $this->client, + shutdownHook: $shutdownHook + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'shutdown-test') + ); + + /** @And the container is started */ + $started = $container->run(); + + /** @When stopOnShutdown is called */ + $started->stopOnShutdown(); + + /** @Then the shutdown hook should have registered the remove callback */ + self::assertSame(1, $shutdownHook->getRegistrationCount()); + } + + public function testRemoveCanBeCalledMultipleTimes(): void + { + /** @Given a running container */ + $container = TestableGenericDockerContainer::createWith( + name: 'already-removed', + image: 'alpine:latest', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'already-removed') + ); + + /** @And the container is started */ + $started = $container->run(); + + /** @When remove is called twice */ + $started->remove(); + $started->remove(); + + /** @Then no exception should be thrown */ + self::assertTrue(true); + } + + public function testRunCommandLineIncludesCommands(): void + { + /** @Given a container */ + $container = TestableGenericDockerContainer::createWith( + name: 'args-cmd', + image: 'alpine:latest', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'args-cmd')); + + /** @When the container is started with commands */ + $container->run(commands: ['-connectRetries=15', 'clean', 'migrate']); + + /** @Then the docker run command should end with the commands */ + $runCommand = $this->client->getExecutedCommandLines()[0]; + self::assertStringContainsString('-connectRetries=15 clean migrate', $runCommand); + } + + public function testStopContainerWithCustomTimeout(): void + { + /** @Given a running container */ + $container = TestableGenericDockerContainer::createWith( + name: 'stop-timeout', + image: 'alpine:latest', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'stop-timeout') + ); + $this->client->withDockerStopResponse(output: ''); + + /** @And the container is started */ + $started = $container->run(); + + /** @When the container is stopped with a custom timeout */ + $stopped = $started->stop(timeoutInWholeSeconds: 10); + + /** @Then the stop should be successful */ + self::assertTrue($stopped->isSuccessful()); + } + + public function testRunContainerWithCopyToContainer(): void + { + /** @Given a container with files to copy */ + $container = TestableGenericDockerContainer::createWith( + name: 'copy-test', + image: 'alpine:latest', + client: $this->client + )->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config'); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'copy-test')); + + /** @When the container is started (docker cp is automatically called) */ + $started = $container->run(); + + /** @Then the container should be running */ + self::assertSame('copy-test', $started->getName()); + } + + public function testRunIfNotExistsWithWaitBeforeRun(): void + { + /** @Given a condition that is immediately ready */ + $condition = $this->createMock(ContainerReady::class); + $condition->expects(self::once())->method('isReady')->willReturn(true); + + /** @And a container with a wait-before-run that does not exist */ + $container = TestableGenericDockerContainer::createWith( + name: 'wait-new', + image: 'alpine:latest', + client: $this->client + )->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); + + /** @And the Docker list returns empty */ + $this->client->withDockerListResponse(output: ''); + + /** @And the Docker daemon returns valid run and inspect responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-new')); + + /** @When runIfNotExists is called */ + $started = $container->runIfNotExists(); + + /** @Then the wait-before-run should have been evaluated and the container created */ + self::assertSame('wait-new', $started->getName()); + } + + public function testRunContainerWithWaitAfterStarted(): void + { + /** @Given a container */ + $container = TestableGenericDockerContainer::createWith( + name: 'wait-after', + image: 'alpine:latest', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-after')); + + /** @When the container is started with a wait-after condition */ + $start = microtime(true); + $started = $container->run(waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 1)); + $elapsed = microtime(true) - $start; + + /** @Then the container should have waited after starting */ + self::assertSame('wait-after', $started->getName()); + self::assertGreaterThanOrEqual(0.9, $elapsed); + } + + public function testExecuteAfterStartedReturnsFailure(): void + { + /** @Given a running container */ + $container = TestableGenericDockerContainer::createWith( + name: 'exec-fail', + image: 'alpine:latest', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-fail')); + $this->client->withDockerExecuteResponse(output: 'command not found', isSuccessful: false); + + /** @And the container is started */ + $started = $container->run(); + + /** @When an invalid command is executed */ + $execution = $started->executeAfterStarted(commands: ['invalid-command']); + + /** @Then the result should indicate failure */ + self::assertFalse($execution->isSuccessful()); + self::assertSame('command not found', $execution->getOutput()); + } + + public function testRunCommandLineIncludesPortMapping(): void + { + /** @Given a container with a port mapping */ + $container = TestableGenericDockerContainer::createWith( + name: 'port-cmd', + image: 'nginx:latest', + client: $this->client + )->withPortMapping(portOnHost: 8080, portOnContainer: 80); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'port-cmd')); + + /** @When the container is started */ + $container->run(); + + /** @Then the executed docker run command should contain the port mapping argument */ + $runCommand = $this->client->getExecutedCommandLines()[0]; + self::assertStringContainsString('--publish 8080:80', $runCommand); + } + + public function testRunContainerWithAutoGeneratedName(): void + { + /** @Given a container without a name */ + $container = TestableGenericDockerContainer::createWith( + name: null, + image: 'alpine:latest', + client: $this->client + ); + + /** @And the Docker daemon returns valid responses (with any hostname from KSUID) */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'auto-generated' + ) + ); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then the container should have an auto-generated name (non-empty) */ + self::assertNotEmpty($started->getName()); + } + + public function testRunContainerWithFullConfiguration(): void + { + /** @Given a fully configured container */ + $container = TestableGenericDockerContainer::createWith( + name: 'web-server', + image: 'nginx:latest', client: $this->client + ) + ->withNetwork(name: 'my-network') + ->withPortMapping(portOnHost: 8080, portOnContainer: 80) + ->withVolumeMapping(pathOnHost: '/var/www', pathOnContainer: '/usr/share/nginx/html') + ->withEnvironmentVariable(key: 'NGINX_HOST', value: 'localhost') + ->withEnvironmentVariable(key: 'NGINX_PORT', value: '80'); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'web-server', + environment: ['NGINX_HOST=localhost', 'NGINX_PORT=80'], + networkName: 'my-network', + exposedPorts: ['80/tcp' => (object)[]] + ) + ); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then the container should expose the configured environment variables */ + self::assertSame( + 'localhost', + $started->getEnvironmentVariables()->getValueBy( + key: 'NGINX_HOST' + ) + ); + self::assertSame('80', $started->getEnvironmentVariables()->getValueBy(key: 'NGINX_PORT')); + + /** @And the address should reflect the exposed port */ + self::assertSame(80, $started->getAddress()->getPorts()->firstExposedPort()); + self::assertSame([80], $started->getAddress()->getPorts()->exposedPorts()); + } + + public function testRunIfNotExistsCreatesNewContainer(): void + { + /** @Given a container that does not exist */ + $container = TestableGenericDockerContainer::createWith( + name: 'new-container', + image: 'alpine:latest', + client: $this->client + )->withEnvironmentVariable(key: 'APP_ENV', value: 'test'); + + /** @And the Docker list returns empty (container does not exist) */ + $this->client->withDockerListResponse(output: ''); + + /** @And the Docker daemon returns valid run and inspect responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'new-container', + environment: ['APP_ENV=test'] + ) + ); + + /** @When runIfNotExists is called */ + $started = $container->runIfNotExists(); + + /** @Then a new container should be created */ + self::assertSame('new-container', $started->getName()); + self::assertSame('test', $started->getEnvironmentVariables()->getValueBy(key: 'APP_ENV')); + } + + public function testExceptionWhenWaitBeforeRunTimesOut(): void + { + /** @Given a condition that never becomes ready */ + $condition = $this->createStub(ContainerReady::class); + $condition->method('isReady')->willReturn(false); + + /** @And a container with a wait-before-run that has a short timeout */ + $container = TestableGenericDockerContainer::createWith( + name: 'timeout-wait', + image: 'alpine:latest', + client: $this->client + )->withWaitBeforeRun( + wait: ContainerWaitForDependency::untilReady( + condition: $condition, + timeoutInSeconds: 1, + pollIntervalInMicroseconds: 50_000 + ) + ); + + /** @Then a ContainerWaitTimeout exception should be thrown */ + $this->expectException(ContainerWaitTimeout::class); + + /** @When the container is started */ + $container->run(); + } + + public function testRunCommandLineIncludesVolumeMapping(): void + { + /** @Given a container with two volume mappings */ + $container = TestableGenericDockerContainer::createWith( + name: 'vol-cmd', + image: 'alpine:latest', + client: $this->client + ) + ->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/app/data') + ->withVolumeMapping(pathOnHost: '/host/logs', pathOnContainer: '/app/logs'); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'vol-cmd')); + + /** @When the container is started */ + $container->run(); + + /** @Then the docker run command should contain the first volume mapping argument */ + $runCommand = $this->client->getExecutedCommandLines()[0]; + self::assertStringContainsString('--volume /host/data:/app/data', $runCommand); + + /** @And the docker run command should contain the second volume mapping argument */ + self::assertStringContainsString('--volume /host/logs:/app/logs', $runCommand); + } + + public function testRunContainerWithMultiplePortMappings(): void + { + /** @Given a container with multiple port mappings */ + $container = TestableGenericDockerContainer::createWith( + name: 'multi-port', + image: 'nginx:latest', + client: $this->client + ) + ->withPortMapping(portOnHost: 8080, portOnContainer: 80) + ->withPortMapping(portOnHost: 8443, portOnContainer: 443); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'multi-port', + exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[]] + ) + ); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then both ports should be exposed */ + self::assertSame([80, 443], $started->getAddress()->getPorts()->exposedPorts()); + self::assertSame(80, $started->getAddress()->getPorts()->firstExposedPort()); + } + + public function testAddressDefaultsWhenNetworkInfoIsEmpty(): void + { + /** @Given a container with empty network info */ + $container = TestableGenericDockerContainer::createWith( + name: 'no-net', + image: 'alpine:latest', + client: $this->client + ); + + /** @And the Docker daemon returns a response with empty address data */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: '', + ipAddress: '' + ) ); - /** @And the Docker daemon returns a failure */ - $this->client->withDockerRunResponse(output: 'Cannot connect to the Docker daemon.', isSuccessful: false); - - /** @Then a DockerCommandExecutionFailed exception should be thrown */ - $this->expectException(DockerCommandExecutionFailed::class); - $this->expectExceptionMessageMatches('/Cannot connect to the Docker daemon/'); - /** @When the container is started */ - $container->run(); + $started = $container->run(); + + /** @Then the address should fall back to defaults */ + self::assertSame('127.0.0.1', $started->getAddress()->getIp()); + self::assertSame('localhost', $started->getAddress()->getHostname()); } - public function testExceptionWhenRunFailsRendersCommandWithShellEscapedArguments(): void + public function testContainerWithMultipleHostPortMappings(): void { - /** @Given a container that will fail to start */ + /** @Given a container with multiple host port mappings */ $container = TestableGenericDockerContainer::createWith( - image: 'invalid:image', - name: 'fail-render-test', + name: 'multi-host-port', + image: 'nginx:latest', client: $this->client - ); - - /** @And the Docker daemon returns a failure */ - $this->client->withDockerRunResponse(output: 'boom', isSuccessful: false); + ) + ->withPortMapping(portOnHost: 8080, portOnContainer: 80) + ->withPortMapping(portOnHost: 8443, portOnContainer: 443); - /** @Then the failure message should render each argument shell-escaped */ - $this->expectException(DockerCommandExecutionFailed::class); - $this->expectExceptionMessageMatches("/'docker' 'run'/"); + /** @And the Docker daemon returns a response with multiple host port bindings */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'multi-host-port', + exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[]], + hostPortBindings: [ + '80/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8080']], + '443/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8443']] + ] + ) + ); /** @When the container is started */ - $container->run(); + $started = $container->run(); + + /** @Then both exposed and host ports should be available */ + self::assertSame([80, 443], $started->getAddress()->getPorts()->exposedPorts()); + self::assertSame([8080, 8443], $started->getAddress()->getPorts()->hostPorts()); + self::assertSame(8080, $started->getAddress()->getPorts()->firstHostPort()); } - public function testExceptionWhenContainerInspectReturnsEmpty(): void + public function testRemoveExecutesDockerRmAndNetworkPrune(): void { - /** @Given a container whose inspect returns empty data */ + /** @Given a running container */ $container = TestableGenericDockerContainer::createWith( + name: 'force-remove', image: 'alpine:latest', - name: 'ghost', client: $this->client ); - /** @And the Docker daemon returns a valid ID but empty inspect */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: []); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'force-remove') + ); - /** @Then a DockerContainerNotFound exception should be thrown */ - $this->expectException(DockerContainerNotFound::class); - $this->expectExceptionMessage('Docker container with name was not found.'); + /** @And the container is started */ + $started = $container->run(); - /** @When the container is started */ - $container->run(); + /** @When remove is called */ + $started->remove(); + + /** @Then a docker rm command should have been executed with the container ID */ + $commandLines = $this->client->getExecutedCommandLines(); + $removeCommand = $commandLines[2]; + + self::assertStringContainsString('docker rm --force --volumes', $removeCommand); + self::assertStringContainsString( + InspectResponseFixture::shortContainerId(), + $removeCommand + ); + + /** @And a docker network prune command should have been executed with the managed label */ + $pruneCommand = $commandLines[3]; + + self::assertStringContainsString( + 'docker network prune --force --filter label=tiny-blocks.docker-container=true', + $pruneCommand + ); } - public function testAddressDefaultsWhenNetworkInfoIsEmpty(): void + public function testRunWhenGateDoesNotHoldThenNothingRuns(): void { - /** @Given a container with empty network info */ + /** @Given a container */ $container = TestableGenericDockerContainer::createWith( + name: 'gate-closed', image: 'alpine:latest', - name: 'no-net', client: $this->client ); - /** @And the Docker daemon returns a response with empty address data */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: '', - ipAddress: '' - ) + /** @And a callback that records whether it was invoked */ + $wasInvoked = false; + + /** @When runWhen is invoked with a gate that does not hold */ + $container->runWhen( + gate: static fn(): bool => false, + then: static function (ContainerStarted $started) use (&$wasInvoked): void { + $wasInvoked = true; + } ); - /** @When the container is started */ - $started = $container->run(); + /** @Then the callback should not have been invoked */ + self::assertFalse($wasInvoked); - /** @Then the address should fall back to defaults */ - self::assertSame(expected: '127.0.0.1', actual: $started->getAddress()->getIp()); - self::assertSame(expected: 'localhost', actual: $started->getAddress()->getHostname()); + /** @And no docker command should have been executed */ + self::assertEmpty($this->client->getExecutedCommandLines()); } public function testContainerWithNoExposedPortsReturnsNull(): void { /** @Given a container with no exposed ports */ $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', name: 'no-ports', + image: 'alpine:latest', client: $this->client ); @@ -454,397 +878,352 @@ public function testContainerWithNoExposedPortsReturnsNull(): void self::assertEmpty($started->getAddress()->getPorts()->hostPorts()); } - public function testContainerWithHostPortMapping(): void + public function testCopyToContainerExecutesDockerCpCommand(): void { - /** @Given a container with a host port mapping */ + /** @Given a container with a copy instruction */ $container = TestableGenericDockerContainer::createWith( - image: 'mysql:8.4', - name: 'host-port', + name: 'cp-cmd', + image: 'alpine:latest', client: $this->client - )->withPortMapping(portOnHost: 33060, portOnContainer: 3306); + )->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql'); - /** @And the Docker daemon returns a response with host port bindings */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'host-port', - exposedPorts: ['3306/tcp' => (object)[]], - hostPortBindings: [ - '3306/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '33060']] - ] - ) - ); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'cp-cmd')); /** @When the container is started */ - $started = $container->run(); - - /** @Then the exposed port should be the container-internal port */ - self::assertSame(expected: 3306, actual: $started->getAddress()->getPorts()->firstExposedPort()); + $container->run(); - /** @And the host port should be the host-mapped port */ - self::assertSame(expected: 33060, actual: $started->getAddress()->getPorts()->firstHostPort()); - self::assertSame(expected: [33060], actual: $started->getAddress()->getPorts()->hostPorts()); + /** @Then the second executed command should be a docker cp with the correct arguments */ + $copyCommand = $this->client->getExecutedCommandLines()[2]; + self::assertStringStartsWith('docker cp', $copyCommand); + self::assertStringContainsString('/host/sql', $copyCommand); + self::assertStringContainsString('/app/sql', $copyCommand); } - public function testContainerWithMultipleHostPortMappings(): void + public function testRunContainerWithMultipleVolumeMappings(): void { - /** @Given a container with multiple host port mappings */ + /** @Given a container with multiple volume mappings */ $container = TestableGenericDockerContainer::createWith( - image: 'nginx:latest', - name: 'multi-host-port', + name: 'multi-vol', + image: 'alpine:latest', client: $this->client ) - ->withPortMapping(portOnHost: 8080, portOnContainer: 80) - ->withPortMapping(portOnHost: 8443, portOnContainer: 443); + ->withVolumeMapping(pathOnHost: '/data', pathOnContainer: '/app/data') + ->withVolumeMapping(pathOnHost: '/config', pathOnContainer: '/app/config'); - /** @And the Docker daemon returns a response with multiple host port bindings */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'multi-host-port', - exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[]], - hostPortBindings: [ - '80/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8080']], - '443/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8443']] - ] - ) - ); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'multi-vol')); /** @When the container is started */ $started = $container->run(); - /** @Then both exposed and host ports should be available */ - self::assertSame(expected: [80, 443], actual: $started->getAddress()->getPorts()->exposedPorts()); - self::assertSame(expected: [8080, 8443], actual: $started->getAddress()->getPorts()->hostPorts()); - self::assertSame(expected: 8080, actual: $started->getAddress()->getPorts()->firstHostPort()); + /** @Then the container should be running */ + self::assertSame('multi-vol', $started->getName()); } - public function testContainerWithMixedValidAndNullHostBindingsRetainsValidPorts(): void + public function testRunIfNotExistsReturnsExistingContainer(): void { - /** @Given a container whose inspect payload mixes valid and null host bindings */ + /** @Given a container that already exists */ $container = TestableGenericDockerContainer::createWith( - image: 'nginx:latest', - name: 'mixed-bindings', + name: 'existing', + image: 'alpine:latest', client: $this->client - ) - ->withPortMapping(portOnHost: 8080, portOnContainer: 80) - ->withPortMapping(portOnHost: 8443, portOnContainer: 443); + ); - /** @And the Docker daemon returns an inspect response with a trailing null binding */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + /** @And the Docker list returns the existing container ID */ + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + + /** @And the Docker inspect returns the container details */ $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'mixed-bindings', - exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[], '5432/tcp' => (object)[]], - hostPortBindings: [ - '80/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8080']], - '443/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8443']], - '5432/tcp' => null - ] + hostname: 'existing', + environment: ['EXISTING=true'] ) ); - /** @When the container is started */ - $started = $container->run(); + /** @When runIfNotExists is called */ + $started = $container->runIfNotExists(); - /** @Then the host-mapped ports should retain all previously collected ports when a null binding follows */ - self::assertSame(expected: [8080, 8443], actual: $started->getAddress()->getPorts()->hostPorts()); + /** @Then the existing container should be returned */ + self::assertSame('existing', $started->getName()); + self::assertSame(InspectResponseFixture::shortContainerId(), $started->getId()); + self::assertSame('true', $started->getEnvironmentVariables()->getValueBy(key: 'EXISTING')); } - public function testContainerWithBindingMissingHostPortIsSkipped(): void + public function testContainerWithExposedPortButNoHostBinding(): void { - /** @Given a container whose inspect payload includes a binding with no HostPort key */ + /** @Given a container with an exposed port but no host binding */ $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', - name: 'no-host-port-key', + name: 'no-host-bind', + image: 'redis:latest', client: $this->client ); - /** @And the Docker daemon returns an inspect response with a partial binding */ + /** @And the Docker daemon returns a response with exposed port but null host bindings */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'no-host-port-key', - exposedPorts: ['80/tcp' => (object)[]], - hostPortBindings: [ - '80/tcp' => [['HostIp' => '0.0.0.0']] - ] + hostname: 'no-host-bind', + exposedPorts: ['6379/tcp' => (object)[]], + hostPortBindings: ['6379/tcp' => null] ) ); /** @When the container is started */ $started = $container->run(); - /** @Then bindings lacking HostPort should be skipped without errors */ + /** @Then the exposed port should be available */ + self::assertSame(6379, $started->getAddress()->getPorts()->firstExposedPort()); + + /** @And the host port should be null since there is no binding */ + self::assertNull($started->getAddress()->getPorts()->firstHostPort()); self::assertEmpty($started->getAddress()->getPorts()->hostPorts()); } - public function testContainerWithZeroHostPortDropsItFromResult(): void + public function testExecuteAfterStartedRunsDockerExecCommand(): void { - /** @Given a container whose inspect payload reports a binding with HostPort equal to zero */ + /** @Given a running container */ $container = TestableGenericDockerContainer::createWith( + name: 'exec-cmd', image: 'alpine:latest', - name: 'zero-host-port', client: $this->client ); - /** @And the Docker daemon returns an inspect response with HostPort zero alongside a valid binding */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'zero-host-port', - exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[]], - hostPortBindings: [ - '80/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '0']], - '443/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8443']] - ] - ) - ); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-cmd')); + $this->client->withDockerExecuteResponse(output: ''); - /** @When the container is started */ + /** @And the container is started */ $started = $container->run(); - /** @Then only strictly positive host ports should be retained */ - self::assertSame(expected: [8443], actual: $started->getAddress()->getPorts()->hostPorts()); + /** @When executing commands inside the container */ + $started->executeAfterStarted(commands: ['ls', '-la', '/tmp']); + + /** @Then a docker exec command should have been executed with the container name and commands */ + $execCommand = $this->client->getExecutedCommandLines()[2]; + self::assertSame('docker exec exec-cmd ls -la /tmp', $execCommand); } - public function testContainerWithExposedPortButNoHostBinding(): void + public function testRunContainerWithMultipleCopyInstructions(): void { - /** @Given a container with an exposed port but no host binding */ + /** @Given a container with multiple copy instructions */ $container = TestableGenericDockerContainer::createWith( - image: 'redis:latest', - name: 'no-host-bind', + name: 'multi-copy', + image: 'alpine:latest', client: $this->client - ); - - /** @And the Docker daemon returns a response with exposed port but null host bindings */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'no-host-bind', - exposedPorts: ['6379/tcp' => (object)[]], - hostPortBindings: ['6379/tcp' => null] - ) - ); + ) + ->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql') + ->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config'); + + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'multi-copy')); /** @When the container is started */ $started = $container->run(); - /** @Then the exposed port should be available */ - self::assertSame(expected: 6379, actual: $started->getAddress()->getPorts()->firstExposedPort()); - - /** @And the host port should be null since there is no binding */ - self::assertNull($started->getAddress()->getPorts()->firstHostPort()); - self::assertEmpty($started->getAddress()->getPorts()->hostPorts()); + /** @Then the container should be running (both docker cp calls were made) */ + self::assertSame('multi-copy', $started->getName()); } - public function testEnvironmentVariableReturnsEmptyStringForMissingKey(): void + public function testExceptionWhenContainerInspectReturnsEmpty(): void { - /** @Given a running container with known environment variables */ + /** @Given a container whose inspect returns empty data */ $container = TestableGenericDockerContainer::createWith( + name: 'ghost', image: 'alpine:latest', - name: 'env-test', client: $this->client ); - /** @And the Docker daemon returns valid responses */ + /** @And the Docker daemon returns a valid ID but empty inspect */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'env-test', - environment: ['KNOWN=value'] - ) - ); - - /** @And the container is started */ - $started = $container->run(); + $this->client->withDockerInspectResponse(inspectResult: []); - /** @When querying for a missing environment variable */ - $missingValue = $started->getEnvironmentVariables()->getValueBy(key: 'MISSING'); + /** @Then a DockerContainerNotFound exception should be thrown */ + $this->expectException(DockerContainerNotFound::class); + $this->expectExceptionMessage('Docker container with name was not found.'); - /** @Then it should return an empty string */ - self::assertSame(expected: '', actual: $missingValue); + /** @When the container is started */ + $container->run(); } - public function testRunContainerWithAutoGeneratedName(): void + public function testRunCommandLineIncludesAutoRemoveByDefault(): void { - /** @Given a container without a name */ + /** @Given a container with default configuration */ $container = TestableGenericDockerContainer::createWith( + name: 'rm-cmd', image: 'alpine:latest', - name: null, client: $this->client ); - /** @And the Docker daemon returns valid responses (with any hostname from KSUID) */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'auto-generated' - ) - ); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'rm-cmd')); /** @When the container is started */ - $started = $container->run(); + $container->run(); - /** @Then the container should have an auto-generated name (non-empty) */ - self::assertNotEmpty($started->getName()); + /** @Then the docker run command should contain --rm */ + $runCommand = $this->client->getExecutedCommandLines()[0]; + self::assertStringContainsString('--rm', $runCommand); } - public function testRunContainerWithWaitAfterStarted(): void + public function testRunCommandLineIncludesEnvironmentVariable(): void { - /** @Given a container */ + /** @Given a container with an environment variable */ $container = TestableGenericDockerContainer::createWith( + name: 'env-cmd', image: 'alpine:latest', - name: 'wait-after', client: $this->client - ); + )->withEnvironmentVariable(key: 'APP_ENV', value: 'production'); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-after')); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'env-cmd')); - /** @When the container is started with a wait-after condition */ - $start = microtime(true); - $started = $container->run(waitAfterStarted: ContainerWaitForTime::forSeconds(seconds: 1)); - $elapsed = microtime(true) - $start; + /** @When the container is started */ + $container->run(); - /** @Then the container should have waited after starting */ - self::assertSame(expected: 'wait-after', actual: $started->getName()); - self::assertGreaterThanOrEqual(minimum: 0.9, actual: $elapsed); + /** @Then the docker run command should contain the environment variable argument */ + $runCommand = $this->client->getExecutedCommandLines()[0]; + self::assertStringContainsString('--env APP_ENV=production', $runCommand); } - public function testStopContainerWithCustomTimeout(): void + public function testContainerWithZeroHostPortDropsItFromResult(): void { - /** @Given a running container */ + /** @Given a container whose inspect payload reports a binding with HostPort equal to zero */ $container = TestableGenericDockerContainer::createWith( + name: 'zero-host-port', image: 'alpine:latest', - name: 'stop-timeout', client: $this->client ); - /** @And the Docker daemon returns valid responses */ + /** @And the Docker daemon returns an inspect response with HostPort zero alongside a valid binding */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'stop-timeout') + inspectResult: InspectResponseFixture::build( + hostname: 'zero-host-port', + exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[]], + hostPortBindings: [ + '80/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '0']], + '443/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8443']] + ] + ) ); - $this->client->withDockerStopResponse(output: ''); - /** @And the container is started */ + /** @When the container is started */ $started = $container->run(); - /** @When the container is stopped with a custom timeout */ - $stopped = $started->stop(timeoutInWholeSeconds: 10); - - /** @Then the stop should be successful */ - self::assertTrue($stopped->isSuccessful()); + /** @Then only strictly positive host ports should be retained */ + self::assertSame([8443], $started->getAddress()->getPorts()->hostPorts()); } - public function testExecuteAfterStartedReturnsFailure(): void + public function testExceptionWhenDockerReturnsEmptyContainerId(): void { - /** @Given a running container */ + /** @Given a container */ $container = TestableGenericDockerContainer::createWith( + name: 'empty-id', image: 'alpine:latest', - name: 'exec-fail', client: $this->client ); - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-fail')); - $this->client->withDockerExecuteResponse(output: 'command not found', isSuccessful: false); - - /** @And the container is started */ - $started = $container->run(); + /** @And the Docker daemon returns an empty container ID */ + $this->client->withDockerRunResponse(output: ' '); - /** @When an invalid command is executed */ - $execution = $started->executeAfterStarted(commands: ['invalid-command']); + /** @Then an InvalidArgumentException should be thrown */ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Container ID cannot be empty.'); - /** @Then the result should indicate failure */ - self::assertFalse($execution->isSuccessful()); - self::assertSame(expected: 'command not found', actual: $execution->getOutput()); + /** @When the container is started */ + $container->run(); } - public function testRunIfNotExistsWithWaitBeforeRun(): void + public function testRunCommandLineIncludesMultiplePortMappings(): void { - /** @Given a condition that is immediately ready */ - $condition = $this->createMock(ContainerReady::class); - $condition->expects(self::once())->method('isReady')->willReturn(true); - - /** @And a container with a wait-before-run that does not exist */ + /** @Given a container with multiple port mappings */ $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', - name: 'wait-new', + name: 'multi-port-cmd', + image: 'nginx:latest', client: $this->client - )->withWaitBeforeRun(wait: ContainerWaitForDependency::untilReady(condition: $condition)); - - /** @And the Docker list returns empty */ - $this->client->withDockerListResponse(output: ''); + ) + ->withPortMapping(portOnHost: 8080, portOnContainer: 80) + ->withPortMapping(portOnHost: 8443, portOnContainer: 443); - /** @And the Docker daemon returns valid run and inspect responses */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'wait-new')); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'multi-port-cmd' + ) + ); - /** @When runIfNotExists is called */ - $started = $container->runIfNotExists(); + /** @When the container is started */ + $container->run(); - /** @Then the wait-before-run should have been evaluated and the container created */ - self::assertSame(expected: 'wait-new', actual: $started->getName()); + /** @Then the docker run command should contain both port mapping arguments */ + $runCommand = $this->client->getExecutedCommandLines()[0]; + self::assertStringContainsString('--publish 8080:80', $runCommand); + self::assertStringContainsString('--publish 8443:443', $runCommand); } - public function testExceptionWhenWaitBeforeRunTimesOut(): void + public function testContainerWithBindingMissingHostPortIsSkipped(): void { - /** @Given a condition that never becomes ready */ - $condition = $this->createStub(ContainerReady::class); - $condition->method('isReady')->willReturn(false); - - /** @And a container with a wait-before-run that has a short timeout */ + /** @Given a container whose inspect payload includes a binding with no HostPort key */ $container = TestableGenericDockerContainer::createWith( + name: 'no-host-port-key', image: 'alpine:latest', - name: 'timeout-wait', client: $this->client - )->withWaitBeforeRun( - wait: ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: 1, - pollIntervalInMicroseconds: 50_000 - ) ); - /** @Then a ContainerWaitTimeout exception should be thrown */ - $this->expectException(ContainerWaitTimeout::class); + /** @And the Docker daemon returns an inspect response with a partial binding */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'no-host-port-key', + exposedPorts: ['80/tcp' => (object)[]], + hostPortBindings: [ + '80/tcp' => [['HostIp' => '0.0.0.0']] + ] + ) + ); /** @When the container is started */ - $container->run(); + $started = $container->run(); + + /** @Then bindings lacking HostPort should be skipped without errors */ + self::assertEmpty($started->getAddress()->getPorts()->hostPorts()); } - public function testRunContainerWithMultipleVolumeMappings(): void + public function testRunCommandLineExcludesAutoRemoveWhenDisabled(): void { - /** @Given a container with multiple volume mappings */ + /** @Given a container with auto-remove disabled */ $container = TestableGenericDockerContainer::createWith( + name: 'no-rm-cmd', image: 'alpine:latest', - name: 'multi-vol', client: $this->client - ) - ->withVolumeMapping(pathOnHost: '/data', pathOnContainer: '/app/data') - ->withVolumeMapping(pathOnHost: '/config', pathOnContainer: '/app/config'); + )->withoutAutoRemove(); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'multi-vol')); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'no-rm-cmd')); /** @When the container is started */ - $started = $container->run(); + $container->run(); - /** @Then the container should be running */ - self::assertSame(expected: 'multi-vol', actual: $started->getName()); + /** @Then the docker run command should NOT contain --rm */ + $runCommand = $this->client->getExecutedCommandLines()[0]; + self::assertStringNotContainsString('--rm', $runCommand); } public function testRunContainerWithMultipleEnvironmentVariables(): void { /** @Given a container with multiple environment variables */ $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', name: 'multi-env', + image: 'alpine:latest', client: $this->client ) ->withEnvironmentVariable(key: 'DB_HOST', value: 'localhost') @@ -865,497 +1244,659 @@ public function testRunContainerWithMultipleEnvironmentVariables(): void /** @Then all environment variables should be accessible */ self::assertSame( - expected: 'localhost', - actual: $started->getEnvironmentVariables()->getValueBy(key: 'DB_HOST') + 'localhost', + $started->getEnvironmentVariables()->getValueBy(key: 'DB_HOST') ); - self::assertSame(expected: '5432', actual: $started->getEnvironmentVariables()->getValueBy(key: 'DB_PORT')); - self::assertSame(expected: 'mydb', actual: $started->getEnvironmentVariables()->getValueBy(key: 'DB_NAME')); - } - - public function testRunContainerWithMultipleCopyInstructions(): void - { - /** @Given a container with multiple copy instructions */ - $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', - name: 'multi-copy', - client: $this->client - ) - ->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql') - ->copyToContainer(pathOnHost: '/host/config', pathOnContainer: '/app/config'); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'multi-copy')); - - /** @When the container is started */ - $started = $container->run(); - - /** @Then the container should be running (both docker cp calls were made) */ - self::assertSame(expected: 'multi-copy', actual: $started->getName()); - } - - public function testExceptionWhenImageNameIsEmpty(): void - { - /** @Then an InvalidArgumentException should be thrown */ - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Image name cannot be empty.'); - - /** @When creating a container with an empty image name */ - GenericDockerContainer::from(image: ''); + self::assertSame('5432', $started->getEnvironmentVariables()->getValueBy(key: 'DB_PORT')); + self::assertSame('mydb', $started->getEnvironmentVariables()->getValueBy(key: 'DB_NAME')); } - public function testExceptionWhenDockerReturnsEmptyContainerId(): void + public function testExceptionWhenDockerReturnsTooShortContainerId(): void { /** @Given a container */ $container = TestableGenericDockerContainer::createWith( + name: 'short-id', image: 'alpine:latest', - name: 'empty-id', client: $this->client ); - /** @And the Docker daemon returns an empty container ID */ - $this->client->withDockerRunResponse(output: ' '); + /** @And the Docker daemon returns a too-short container ID */ + $this->client->withDockerRunResponse(output: 'abc123'); /** @Then an InvalidArgumentException should be thrown */ $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Container ID cannot be empty.'); + $this->expectExceptionMessage('Container ID is too short. Minimum length is <12> characters.'); /** @When the container is started */ $container->run(); } - public function testExceptionWhenDockerReturnsTooShortContainerId(): void + public function testRunContainerThenInspectCommandTargetsContainerId(): void { - /** @Given a container */ + /** @Given a container configured with an image and a name */ $container = TestableGenericDockerContainer::createWith( + name: 'inspect-cmd', image: 'alpine:latest', - name: 'short-id', client: $this->client ); - /** @And the Docker daemon returns a too-short container ID */ - $this->client->withDockerRunResponse(output: 'abc123'); - - /** @Then an InvalidArgumentException should be thrown */ - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Container ID is too short. Minimum length is <12> characters.'); + /** @And the Docker daemon returns a valid container ID and inspect response */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'inspect-cmd')); /** @When the container is started */ $container->run(); + + /** @Then a docker inspect command should have been executed for the container ID */ + $inspectCommand = $this->client->getExecutedCommandLines()[1]; + self::assertSame(sprintf('docker inspect %s', InspectResponseFixture::shortContainerId()), $inspectCommand); } - public function testRunCommandLineIncludesPortMapping(): void + public function testRunContainerWhenStartedFreshThenWasReusedIsFalse(): void { - /** @Given a container with a port mapping */ + /** @Given a container */ $container = TestableGenericDockerContainer::createWith( - image: 'nginx:latest', - name: 'port-cmd', + name: 'fresh-run', + image: 'alpine:latest', client: $this->client - )->withPortMapping(portOnHost: 8080, portOnContainer: 80); + ); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'port-cmd')); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'fresh-run')); /** @When the container is started */ - $container->run(); + $started = $container->run(); - /** @Then the executed docker run command should contain the port mapping argument */ - $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString(needle: '--publish 8080:80', haystack: $runCommand); + /** @Then the started container should report that it was not reused */ + self::assertFalse($started->wasReused()); } - public function testRunCommandLineIncludesMultiplePortMappings(): void + public function testEnvironmentVariableReturnsEmptyStringForMissingKey(): void { - /** @Given a container with multiple port mappings */ + /** @Given a running container with known environment variables */ $container = TestableGenericDockerContainer::createWith( - image: 'nginx:latest', - name: 'multi-port-cmd', + name: 'env-test', + image: 'alpine:latest', client: $this->client - ) - ->withPortMapping(portOnHost: 8080, portOnContainer: 80) - ->withPortMapping(portOnHost: 8443, portOnContainer: 443); + ); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'multi-port-cmd' + hostname: 'env-test', + environment: ['KNOWN=value'] ) ); - /** @When the container is started */ - $container->run(); + /** @And the container is started */ + $started = $container->run(); - /** @Then the docker run command should contain both port mapping arguments */ - $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString(needle: '--publish 8080:80', haystack: $runCommand); - self::assertStringContainsString(needle: '--publish 8443:443', haystack: $runCommand); + /** @When querying for a missing environment variable */ + $missingValue = $started->getEnvironmentVariables()->getValueBy(key: 'MISSING'); + + /** @Then it should return an empty string */ + self::assertSame('', $missingValue); } - public function testRunCommandLineIncludesVolumeMapping(): void + public function testRunIfNotExistsWhenContainerExistsThenStartedWasReused(): void { - /** @Given a container with a volume mapping */ + /** @Given a container that already exists */ $container = TestableGenericDockerContainer::createWith( + name: 'reused-flag', image: 'alpine:latest', - name: 'vol-cmd', client: $this->client - )->withVolumeMapping(pathOnHost: '/host/data', pathOnContainer: '/app/data'); + ); + + /** @And the Docker list returns the existing container ID */ + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + + /** @And the Docker inspect returns the container details */ + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'reused-flag')); + + /** @When runIfNotExists is called */ + $started = $container->runIfNotExists(); + + /** @Then the started container should report that it was reused */ + self::assertTrue($started->wasReused()); + } + + #[RunInSeparateProcess] + public function testGetPortForConnectionWhenInsideDockerReturnsExposedPort(): void + { + $template = '%s/Internal/Containers/Overrides/file_exists_inside_docker.php'; + require_once sprintf($template, __DIR__); + + /** @Given a Docker client */ + $client = new ClientMock(); + + /** @And a container with a host port mapping */ + $container = TestableGenericDockerContainer::createWith( + name: 'conn-port', + image: 'mysql:8.4', + client: $client + )->withPortMapping(portOnHost: 33060, portOnContainer: 3306); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'vol-cmd')); + $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - /** @When the container is started */ - $container->run(); + /** @And the inspect response carries exposed and host-mapped ports */ + $client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'conn-port', + exposedPorts: ['3306/tcp' => (object)[]], + hostPortBindings: ['3306/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '33060']]] + ) + ); - /** @Then the docker run command should contain the volume mapping argument */ - $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString(needle: '--volume /host/data:/app/data', haystack: $runCommand); + /** @And the container is started */ + $started = $container->run(); + + /** @When the connection port is resolved inside Docker */ + $port = $started->getAddress()->getPorts()->getPortForConnection(); + + /** @Then it should be the container-internal exposed port */ + self::assertSame(3306, $port); } - public function testRunCommandLineIncludesEnvironmentVariable(): void + #[RunInSeparateProcess] + public function testRunIfNotExistsWhenOutsideDockerThenSkipsReaperCreation(): void { - /** @Given a container with an environment variable */ + $template = '%s/Internal/Containers/Overrides/file_exists_outside_docker.php'; + require_once sprintf($template, __DIR__); + + /** @Given a Docker client */ + $client = new ClientMock(); + + /** @And a container that already exists, running outside a Docker environment */ $container = TestableGenericDockerContainer::createWith( + name: 'reaper-outside', image: 'alpine:latest', - name: 'env-cmd', - client: $this->client - )->withEnvironmentVariable(key: 'APP_ENV', value: 'production'); + client: $client + ); - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'env-cmd')); + /** @And the Docker list returns an existing container */ + $client->withDockerListResponse(output: InspectResponseFixture::containerId()); - /** @When the container is started */ - $container->run(); + /** @And the Docker inspect returns the container details */ + $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'reaper-outside')); - /** @Then the docker run command should contain the environment variable argument */ - $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString(needle: '--env APP_ENV=production', haystack: $runCommand); + /** @When runIfNotExists is called */ + $container->runIfNotExists(); + + /** @Then no reaper container should have been started */ + self::assertStringNotContainsString( + 'tiny-blocks-reaper', + implode(PHP_EOL, $client->getExecutedCommandLines()) + ); } - public function testRunCommandLineIncludesNetwork(): void + public function testRunWhenGateHoldsThenStartedContainerIsPassedToCallback(): void { - /** @Given a container with a network */ + /** @Given a container */ $container = TestableGenericDockerContainer::createWith( + name: 'gate-open', image: 'alpine:latest', - name: 'net-cmd', client: $this->client - )->withNetwork(name: 'my-network'); + ); - /** @And the Docker daemon returns valid responses */ + /** @And the Docker list is empty so a fresh container is started */ + $this->client->withDockerListResponse(output: ''); + + /** @And the Docker daemon returns valid run and inspect responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'net-cmd')); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'gate-open')); - /** @When the container is started */ - $container->run(); + /** @And a callback that captures the container it receives */ + $received = null; - /** @Then the first command should be the network creation */ - $networkCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString( - needle: 'docker network create --label tiny-blocks.docker-container=true my-network', - haystack: $networkCommand + /** @When runWhen is invoked with a gate that holds */ + $container->runWhen( + gate: static fn(): bool => true, + then: static function (ContainerStarted $started) use (&$received): void { + $received = $started; + } ); - /** @And the docker run command should contain the network argument */ - $runCommand = $this->client->getExecutedCommandLines()[2]; - self::assertStringContainsString(needle: '--network=my-network', haystack: $runCommand); + /** @Then the callback should have received the started container */ + self::assertSame('gate-open', $received?->getName()); + + /** @And a docker run command should have been executed */ + self::assertStringContainsString('docker run', implode(PHP_EOL, $this->client->getExecutedCommandLines())); } - public function testRunCommandLineIncludesAutoRemoveByDefault(): void + #[RunInSeparateProcess] + public function testRunIfNotExistsSkipsReaperCreationWhenReaperAlreadyExists(): void { - /** @Given a container with default configuration */ + $template = '%s/Internal/Containers/Overrides/file_exists_inside_docker.php'; + require_once sprintf($template, __DIR__); + + /** @Given a container that already exists */ + $client = new ClientMock(); $container = TestableGenericDockerContainer::createWith( + name: 'reaper-skip', image: 'alpine:latest', - name: 'rm-cmd', - client: $this->client + client: $client ); - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'rm-cmd')); + /** @And the Docker list returns an existing container */ + $client->withDockerListResponse(output: InspectResponseFixture::containerId()); - /** @When the container is started */ - $container->run(); + /** @And the Docker inspect returns the container details */ + $client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'reaper-skip') + ); - /** @Then the docker run command should contain --rm */ - $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString(needle: '--rm', haystack: $runCommand); + /** @And the reaper container already exists */ + $client->withDockerListResponse(output: 'existing-reaper-id'); + + /** @When runIfNotExists is called */ + $started = $container->runIfNotExists(); + + /** @Then the container should be returned */ + self::assertSame('reaper-skip', $started->getName()); + + /** @And the reused container should have probed for the reaper list to exist */ + $commandLines = $client->getExecutedCommandLines(); + $reaperListed = false; + + foreach ($commandLines as $commandLine) { + $reaperListed = $reaperListed || str_contains($commandLine, 'tiny-blocks-reaper-reaper-skip'); + self::assertStringNotContainsString( + 'docker run --rm -d --name tiny-blocks-reaper', + $commandLine + ); + } + self::assertTrue($reaperListed); } - public function testRunCommandLineExcludesAutoRemoveWhenDisabled(): void + public function testRunIfNotExistsThenListCommandFiltersByExactContainerName(): void { - /** @Given a container with auto-remove disabled */ + /** @Given a container that already exists */ $container = TestableGenericDockerContainer::createWith( + name: 'list-cmd', image: 'alpine:latest', - name: 'no-rm-cmd', client: $this->client - )->withoutAutoRemove(); + ); + + /** @And the Docker list returns the existing container ID */ + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + + /** @And the Docker inspect returns the container details */ + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'list-cmd')); + + /** @When runIfNotExists is called */ + $container->runIfNotExists(); + + /** @Then the first command should list containers filtered by the exact name */ + $listCommand = $this->client->getExecutedCommandLines()[0]; + self::assertSame('docker ps --all --quiet --filter name=^list-cmd$', $listCommand); + } + + #[RunInSeparateProcess] + public function testGetPortForConnectionWhenOutsideDockerReturnsHostMappedPort(): void + { + $template = '%s/Internal/Containers/Overrides/file_exists_outside_docker.php'; + require_once sprintf($template, __DIR__); + + /** @Given a Docker client */ + $client = new ClientMock(); + + /** @And a container with a host port mapping */ + $container = TestableGenericDockerContainer::createWith( + name: 'conn-port', + image: 'mysql:8.4', + client: $client + )->withPortMapping(portOnHost: 33060, portOnContainer: 3306); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'no-rm-cmd')); + $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - /** @When the container is started */ - $container->run(); + /** @And the inspect response carries exposed and host-mapped ports */ + $client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'conn-port', + exposedPorts: ['3306/tcp' => (object)[]], + hostPortBindings: ['3306/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '33060']]] + ) + ); - /** @Then the docker run command should NOT contain --rm */ - $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringNotContainsString(needle: '--rm', haystack: $runCommand); + /** @And the container is started */ + $started = $container->run(); + + /** @When the connection port is resolved outside Docker */ + $port = $started->getAddress()->getPorts()->getPortForConnection(); + + /** @Then it should be the host-mapped port */ + self::assertSame(33060, $port); } - public function testRunCommandLineIncludesCommands(): void + public function testRunContainerWhenExposedPortIsZeroThenItIsDroppedFromResult(): void { /** @Given a container */ $container = TestableGenericDockerContainer::createWith( + name: 'zero-exposed', image: 'alpine:latest', - name: 'args-cmd', client: $this->client ); - /** @And the Docker daemon returns valid responses */ + /** @And the Docker daemon returns an inspect response with a zero exposed port alongside a valid one */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'args-cmd')); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'zero-exposed', + exposedPorts: ['0/tcp' => (object)[], '3306/tcp' => (object)[]] + ) + ); - /** @When the container is started with commands */ - $container->run(commands: ['-connectRetries=15', 'clean', 'migrate']); + /** @When the container is started */ + $started = $container->run(); - /** @Then the docker run command should end with the commands */ - $runCommand = $this->client->getExecutedCommandLines()[0]; - self::assertStringContainsString(needle: '-connectRetries=15 clean migrate', haystack: $runCommand); + /** @Then only the strictly positive exposed port should be retained */ + self::assertSame([3306], $started->getAddress()->getPorts()->exposedPorts()); } - public function testCopyToContainerExecutesDockerCpCommand(): void + #[RunInSeparateProcess] + public function testRunIfNotExistsWhenReaperIsMissingThenStartsReaperContainer(): void { - /** @Given a container with a copy instruction */ + $template = '%s/Internal/Containers/Overrides/file_exists_inside_docker.php'; + require_once sprintf($template, __DIR__); + + /** @Given a Docker client */ + $client = new ClientMock(); + + /** @And a container that already exists */ $container = TestableGenericDockerContainer::createWith( + name: 'reaper-start', image: 'alpine:latest', - name: 'cp-cmd', - client: $this->client - )->copyToContainer(pathOnHost: '/host/sql', pathOnContainer: '/app/sql'); + client: $client + ); - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'cp-cmd')); + /** @And the Docker list returns an existing container */ + $client->withDockerListResponse(output: InspectResponseFixture::containerId()); - /** @When the container is started */ - $container->run(); + /** @And the Docker inspect returns the container details */ + $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'reaper-start')); - /** @Then the second executed command should be a docker cp with the correct arguments */ - $copyCommand = $this->client->getExecutedCommandLines()[2]; - self::assertStringStartsWith(prefix: 'docker cp', string: $copyCommand); - self::assertStringContainsString(needle: '/host/sql', haystack: $copyCommand); - self::assertStringContainsString(needle: '/app/sql', haystack: $copyCommand); + /** @And no reaper container is currently listed */ + $client->withDockerListResponse(output: ''); + + /** @When runIfNotExists is called */ + $container->runIfNotExists(); + + /** @Then a reaper container should have been started with the docker run command */ + $commandLines = implode(PHP_EOL, $client->getExecutedCommandLines()); + self::assertStringContainsString('docker run --rm -d --name tiny-blocks-reaper-reaper-start', $commandLines); + + /** @And the reaper script should watch the test-runner hostname before pruning */ + self::assertStringContainsString('while docker inspect', $commandLines); } - public function testStopExecutesDockerStopCommand(): void + public function testContainerWithMixedValidAndNullHostBindingsRetainsValidPorts(): void { - /** @Given a running container */ + /** @Given a container whose inspect payload mixes valid and null host bindings */ $container = TestableGenericDockerContainer::createWith( - image: 'alpine:latest', - name: 'stop-cmd', + name: 'mixed-bindings', + image: 'nginx:latest', client: $this->client - ); + ) + ->withPortMapping(portOnHost: 8080, portOnContainer: 80) + ->withPortMapping(portOnHost: 8443, portOnContainer: 443); - /** @And the Docker daemon returns valid responses */ + /** @And the Docker daemon returns an inspect response with a trailing null binding */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-cmd')); - $this->client->withDockerStopResponse(output: ''); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'mixed-bindings', + exposedPorts: ['80/tcp' => (object)[], '443/tcp' => (object)[], '5432/tcp' => (object)[]], + hostPortBindings: [ + '80/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8080']], + '443/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '8443']], + '5432/tcp' => null + ] + ) + ); - /** @And the container is started */ + /** @When the container is started */ $started = $container->run(); - /** @When the container is stopped */ - $started->stop(); - - /** @Then a docker stop command should have been executed with the container ID */ - $stopCommand = $this->client->getExecutedCommandLines()[2]; - self::assertStringStartsWith(prefix: 'docker stop', string: $stopCommand); - self::assertStringContainsString(needle: InspectResponseFixture::shortContainerId(), haystack: $stopCommand); + /** @Then the host-mapped ports should retain all previously collected ports when a null binding follows */ + self::assertSame([8080, 8443], $started->getAddress()->getPorts()->hostPorts()); } - public function testExecuteAfterStartedRunsDockerExecCommand(): void + #[RunInSeparateProcess] + public function testGetHostForConnectionWhenOutsideDockerReturnsLoopbackAddress(): void { - /** @Given a running container */ + $template = '%s/Internal/Containers/Overrides/file_exists_outside_docker.php'; + require_once sprintf($template, __DIR__); + + /** @Given a Docker client */ + $client = new ClientMock(); + + /** @And a container with a known hostname */ $container = TestableGenericDockerContainer::createWith( + name: 'conn-host', image: 'alpine:latest', - name: 'exec-cmd', - client: $this->client + client: $client ); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'exec-cmd')); - $this->client->withDockerExecuteResponse(output: ''); + $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + + /** @And the inspect response carries the hostname */ + $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'conn-host')); /** @And the container is started */ $started = $container->run(); - /** @When executing commands inside the container */ - $started->executeAfterStarted(commands: ['ls', '-la', '/tmp']); + /** @When the connection host is resolved outside Docker */ + $host = $started->getAddress()->getHostForConnection(); - /** @Then a docker exec command should have been executed with the container name and commands */ - $execCommand = $this->client->getExecutedCommandLines()[2]; - self::assertSame(expected: 'docker exec exec-cmd ls -la /tmp', actual: $execCommand); + /** @Then it should be the loopback address */ + self::assertSame('127.0.0.1', $host); } - public function testRunContainerWithPullImage(): void + #[RunInSeparateProcess] + public function testRunContainerWithNetworkWhenOutsideDockerSkipsHostConnection(): void { - /** @Given a container with image pulling enabled */ + $template = '%s/Internal/Containers/Overrides/file_exists_outside_docker.php'; + require_once sprintf($template, __DIR__); + + /** @Given a container configured with a network, running outside a Docker environment */ + $client = new ClientMock(); $container = TestableGenericDockerContainer::createWith( + name: 'outside-docker', image: 'alpine:latest', - name: 'pull-test', - client: $this->client - )->pullImage(); + client: $client + )->withNetwork(name: 'my-network'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'pull-test')); + $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build(hostname: 'outside-docker') + ); - /** @When the container is started (waiting for the image pull to complete first) */ + /** @When the container is started */ $started = $container->run(); /** @Then the container should be running */ - self::assertSame(expected: 'pull-test', actual: $started->getName()); + self::assertSame('outside-docker', $started->getName()); - /** @And the docker pull command should have been executed */ - $commandLines = $this->client->getExecutedCommandLines(); - self::assertStringContainsString(needle: 'docker pull alpine:latest', haystack: $commandLines[0]); + /** @And no network connect command should have been executed for the host */ + $commandLines = $client->getExecutedCommandLines(); + + foreach ($commandLines as $commandLine) { + self::assertStringNotContainsString( + 'docker network connect', + $commandLine + ); + } } - public function testRemoveExecutesDockerRmAndNetworkPrune(): void + public function testRunIfNotExistsWhenContainerIsMissingThenStartedWasNotReused(): void { - /** @Given a running container */ + /** @Given a container that does not exist */ $container = TestableGenericDockerContainer::createWith( + name: 'fresh-flag', image: 'alpine:latest', - name: 'force-remove', client: $this->client ); - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'force-remove') - ); + /** @And the Docker list returns empty so the container is created */ + $this->client->withDockerListResponse(output: ''); - /** @And the container is started */ - $started = $container->run(); + /** @And the Docker daemon returns valid run and inspect responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'fresh-flag')); - /** @When remove is called */ - $started->remove(); + /** @When runIfNotExists is called */ + $started = $container->runIfNotExists(); - /** @Then a docker rm command should have been executed with the container ID */ - $commandLines = $this->client->getExecutedCommandLines(); - $removeCommand = $commandLines[2]; + /** @Then the started container should report that it was not reused */ + self::assertFalse($started->wasReused()); + } - self::assertStringContainsString(needle: 'docker rm --force --volumes', haystack: $removeCommand); - self::assertStringContainsString( - needle: InspectResponseFixture::shortContainerId(), - haystack: $removeCommand + public function testExceptionWhenRunFailsRendersCommandWithShellEscapedArguments(): void + { + /** @Given a container that will fail to start */ + $container = TestableGenericDockerContainer::createWith( + name: 'fail-render-test', + image: 'invalid:image', + client: $this->client ); - /** @And a docker network prune command should have been executed with the managed label */ - $pruneCommand = $commandLines[3]; + /** @And the Docker daemon returns a failure */ + $this->client->withDockerRunResponse(output: 'boom', isSuccessful: false); - self::assertStringContainsString( - needle: 'docker network prune --force --filter label=tiny-blocks.docker-container=true', - haystack: $pruneCommand - ); + /** @Then the failure message should render each argument shell-escaped */ + $this->expectException(DockerCommandExecutionFailed::class); + $this->expectExceptionMessageMatches("/'docker' 'run'/"); + + /** @When the container is started */ + $container->run(); } - public function testRemoveCanBeCalledMultipleTimes(): void + #[RunInSeparateProcess] + public function testGetHostForConnectionWhenInsideDockerReturnsContainerHostname(): void { - /** @Given a running container */ + $template = '%s/Internal/Containers/Overrides/file_exists_inside_docker.php'; + require_once sprintf($template, __DIR__); + + /** @Given a Docker client */ + $client = new ClientMock(); + + /** @And a container with a known hostname */ $container = TestableGenericDockerContainer::createWith( + name: 'conn-host', image: 'alpine:latest', - name: 'already-removed', - client: $this->client + client: $client ); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'already-removed') - ); + $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + + /** @And the inspect response carries the hostname */ + $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'conn-host')); /** @And the container is started */ $started = $container->run(); - /** @When remove is called twice */ - $started->remove(); - $started->remove(); + /** @When the connection host is resolved inside Docker */ + $host = $started->getAddress()->getHostForConnection(); - /** @Then no exception should be thrown */ - self::assertTrue(true); + /** @Then it should be the container hostname */ + self::assertSame('conn-host', $host); } - public function testStopOnShutdownRegistersRemove(): void + #[RunInSeparateProcess] + public function testRunContainerWithNetworkWhenInsideDockerConnectsHostToNetwork(): void { - /** @Given a ShutdownHook that tracks registration */ - $shutdownHook = new ShutdownHookMock(); + $template = '%s/Internal/Containers/Overrides/file_exists_inside_docker.php'; + require_once sprintf($template, __DIR__); - /** @And a running container using the tracked hook */ + /** @Given a Docker client */ + $client = new ClientMock(); + + /** @And a container configured with a network, running inside a Docker environment */ $container = TestableGenericDockerContainer::createWith( + name: 'inside-docker', image: 'alpine:latest', - name: 'shutdown-test', - client: $this->client, - shutdownHook: $shutdownHook - ); + client: $client + )->withNetwork(name: 'my-network'); /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'shutdown-test') - ); + $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - /** @And the container is started */ - $started = $container->run(); + /** @And the inspect response carries the hostname */ + $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'inside-docker')); - /** @When stopOnShutdown is called */ - $started->stopOnShutdown(); + /** @When the container is started */ + $container->run(); - /** @Then the shutdown hook should have registered the remove callback */ - self::assertSame(1, $shutdownHook->getRegistrationCount()); + /** @Then a docker network connect command should connect the host to the network */ + $connectCommand = $client->getExecutedCommandLines()[1]; + self::assertStringStartsWith('docker network connect my-network', $connectCommand); } - public function testRemoveOnReusedContainerIsNoOp(): void + public function testRunIfNotExistsTreatsWhitespaceOnlyListOutputAsMissingContainer(): void { - /** @Given a container returned by runIfNotExists (a Reused instance) */ + /** @Given a container that does not exist according to a whitespace-only docker list response */ $container = TestableGenericDockerContainer::createWith( + name: 'whitespace-list', image: 'alpine:latest', - name: 'reused-remove', client: $this->client ); - /** @And the Docker list returns an existing container */ - $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + /** @And the Docker list returns only whitespace */ + $this->client->withDockerListResponse(output: " \n\t "); - /** @And the Docker inspect returns the container details */ + /** @And the Docker daemon returns valid run and inspect responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'reused-remove') + inspectResult: InspectResponseFixture::build(hostname: 'whitespace-list') ); - /** @When runIfNotExists returns a reused container */ + /** @When runIfNotExists is called */ $started = $container->runIfNotExists(); - /** @And remove is called on the reused container */ - $started->remove(); + /** @Then a new container should be created because the whitespace list output is trimmed */ + self::assertSame('whitespace-list', $started->getName()); + } - /** @Then the container should still be accessible (remove is a no-op for reused containers) */ - self::assertSame(expected: 'reused-remove', actual: $started->getName()); + public function testRunContainerWhenContainerIdIsExactlyMinimumLengthThenIdIsAccepted(): void + { + /** @Given a container */ + $container = TestableGenericDockerContainer::createWith( + name: 'min-id', + image: 'alpine:latest', + client: $this->client + ); + + /** @And the Docker daemon returns a container ID exactly at the minimum length */ + $this->client->withDockerRunResponse(output: '123456789012'); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'min-id')); + + /** @When the container is started */ + $started = $container->run(); + + /** @Then the full minimum-length ID should be accepted and preserved */ + self::assertSame('123456789012', $started->getId()); } #[RunInSeparateProcess] - public function testRunIfNotExistsSkipsReaperCreationWhenReaperAlreadyExists(): void + public function testRunIfNotExistsWhenReaperListIsWhitespaceThenStartsReaperContainer(): void { - require_once __DIR__ . '/Internal/Containers/Overrides/file_exists_inside_docker.php'; + $template = '%s/Internal/Containers/Overrides/file_exists_inside_docker.php'; + require_once sprintf($template, __DIR__); - /** @Given a container that already exists */ + /** @Given a Docker client */ $client = new ClientMock(); + + /** @And a container that already exists */ $container = TestableGenericDockerContainer::createWith( + name: 'reaper-blank', image: 'alpine:latest', - name: 'reaper-skip', client: $client ); @@ -1363,66 +1904,46 @@ public function testRunIfNotExistsSkipsReaperCreationWhenReaperAlreadyExists(): $client->withDockerListResponse(output: InspectResponseFixture::containerId()); /** @And the Docker inspect returns the container details */ - $client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'reaper-skip') - ); + $client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'reaper-blank')); - /** @And the reaper container already exists */ - $client->withDockerListResponse(output: 'existing-reaper-id'); + /** @And the reaper list response contains only whitespace */ + $client->withDockerListResponse(output: " \n\t "); /** @When runIfNotExists is called */ - $started = $container->runIfNotExists(); - - /** @Then the container should be returned */ - self::assertSame(expected: 'reaper-skip', actual: $started->getName()); - - /** @And the reused container should have probed for the reaper list to exist */ - $commandLines = $client->getExecutedCommandLines(); - $reaperListed = false; + $container->runIfNotExists(); - foreach ($commandLines as $commandLine) { - $reaperListed = $reaperListed || str_contains($commandLine, 'tiny-blocks-reaper-reaper-skip'); - self::assertStringNotContainsString( - needle: 'docker run --rm -d --name tiny-blocks-reaper', - haystack: $commandLine - ); - } - self::assertTrue($reaperListed); + /** @Then a reaper container should have been started because the whitespace list is treated as empty */ + self::assertStringContainsString( + 'docker run --rm -d --name tiny-blocks-reaper-reaper-blank', + implode(PHP_EOL, $client->getExecutedCommandLines()) + ); } - #[RunInSeparateProcess] - public function testRunContainerWithNetworkWhenOutsideDockerSkipsHostConnection(): void + public function testStopContainerWhenCustomTimeoutThenCommandCarriesTimeoutAsStringArgument(): void { - require_once __DIR__ . '/Internal/Containers/Overrides/file_exists_outside_docker.php'; - - /** @Given a container configured with a network, running outside a Docker environment */ - $client = new ClientMock(); + /** @Given a running container */ $container = TestableGenericDockerContainer::createWith( + name: 'stop-args', image: 'alpine:latest', - name: 'outside-docker', - client: $client - )->withNetwork(name: 'my-network'); + client: $this->client + ); /** @And the Docker daemon returns valid responses */ - $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build(hostname: 'outside-docker') - ); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse(inspectResult: InspectResponseFixture::build(hostname: 'stop-args')); + $this->client->withDockerStopResponse(output: ''); - /** @When the container is started */ + /** @And the container is started */ $started = $container->run(); - /** @Then the container should be running */ - self::assertSame(expected: 'outside-docker', actual: $started->getName()); - - /** @And no network connect command should have been executed for the host */ - $commandLines = $client->getExecutedCommandLines(); + /** @When the container is stopped with a custom timeout */ + $started->stop(timeoutInWholeSeconds: 45); - foreach ($commandLines as $commandLine) { - self::assertStringNotContainsString( - needle: 'docker network connect', - haystack: $commandLine - ); - } + /** @Then the stop command arguments should carry the timeout as a string */ + $stopArguments = $this->client->getExecutedArguments()[2]; + self::assertSame( + ['docker', 'stop', '--time', '45', InspectResponseFixture::shortContainerId()], + $stopArguments + ); } } diff --git a/tests/Unit/HostEnvironmentTest.php b/tests/Unit/HostEnvironmentTest.php new file mode 100644 index 0000000..b5212a8 --- /dev/null +++ b/tests/Unit/HostEnvironmentTest.php @@ -0,0 +1,27 @@ +newInstanceWithoutConstructor(); + + /** @When the private constructor is invoked through reflection */ + $reflection->getMethod('__construct')->invoke($instance); + + /** @Then the host environment instance is created */ + self::assertInstanceOf(HostEnvironment::class, $instance); + } +} diff --git a/tests/Unit/Internal/Client/DockerClientTest.php b/tests/Unit/Internal/Client/DockerClientTest.php index e898c10..c3c7f3b 100644 --- a/tests/Unit/Internal/Client/DockerClientTest.php +++ b/tests/Unit/Internal/Client/DockerClientTest.php @@ -5,8 +5,8 @@ namespace Test\Unit\Internal\Client; use PHPUnit\Framework\TestCase; -use Test\Unit\Mocks\CommandMock; -use Test\Unit\Mocks\CommandWithTimeoutMock; +use Test\Unit\CommandMock; +use Test\Unit\CommandWithTimeoutMock; use TinyBlocks\DockerContainer\Internal\Client\DockerClient; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; @@ -29,7 +29,7 @@ public function testExecuteCommandSuccessfully(): void /** @Then the output should contain the expected result */ self::assertTrue($execution->isSuccessful()); - self::assertStringContainsString(needle: 'Hello', haystack: $execution->getOutput()); + self::assertStringContainsString('Hello', $execution->getOutput()); } public function testExecuteCommandWithValidTimeout(): void @@ -44,19 +44,6 @@ public function testExecuteCommandWithValidTimeout(): void self::assertTrue($execution->isSuccessful()); } - public function testExceptionFromProcessWhenTimeoutIsInvalid(): void - { - /** @Given a command with an invalid negative timeout */ - $command = new CommandWithTimeoutMock(command: 'echo Hello', timeoutInWholeSeconds: -10); - - /** @Then a DockerCommandExecutionFailed exception should be thrown via fromProcess */ - $this->expectException(DockerCommandExecutionFailed::class); - $this->expectExceptionMessageMatches('/Failed to execute command .* Reason: .*timeout/i'); - - /** @When the command is executed */ - $this->client->execute(command: $command); - } - public function testExecuteCommandReturnsErrorOutput(): void { /** @Given a command that will fail */ @@ -69,4 +56,17 @@ public function testExecuteCommandReturnsErrorOutput(): void self::assertFalse($execution->isSuccessful()); self::assertNotEmpty($execution->getOutput()); } + + public function testExceptionFromProcessWhenTimeoutIsInvalid(): void + { + /** @Given a command with an invalid negative timeout */ + $command = new CommandWithTimeoutMock(command: 'echo Hello', timeoutInWholeSeconds: -10); + + /** @Then a DockerCommandExecutionFailed exception should be thrown via fromProcess */ + $this->expectException(DockerCommandExecutionFailed::class); + $this->expectExceptionMessageMatches('/Failed to execute command .* Reason: .*timeout/i'); + + /** @When the command is executed */ + $this->client->execute(command: $command); + } } diff --git a/tests/Unit/Internal/Commands/DockerCommandsTest.php b/tests/Unit/Internal/Commands/DockerCommandsTest.php deleted file mode 100644 index b522965..0000000 --- a/tests/Unit/Internal/Commands/DockerCommandsTest.php +++ /dev/null @@ -1,192 +0,0 @@ -toArguments(); - - /** @Then the arguments should represent pulling the specified image */ - self::assertSame(['docker', 'pull', 'mysql:8.4'], $arguments); - } - - public function testDockerRemoveGeneratesCorrectCommand(): void - { - /** @Given a Docker remove command for a specific container */ - $containerId = ContainerId::from(value: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8'); - $command = DockerRemove::from(id: $containerId); - - /** @When the argument list is generated */ - $arguments = $command->toArguments(); - - /** @Then the arguments should force-remove the container with its volumes */ - self::assertSame(['docker', 'rm', '--force', '--volumes', '6acae5967be0'], $arguments); - } - - public function testDockerNetworkCreateGeneratesCommandWithLabel(): void - { - /** @Given a Docker network create command */ - $command = DockerNetworkCreate::from(network: 'my-network'); - - /** @When the argument list is generated */ - $arguments = $command->toArguments(); - - /** @Then the arguments should create the network with the managed label */ - self::assertSame( - ['docker', 'network', 'create', '--label', 'tiny-blocks.docker-container=true', 'my-network'], - $arguments - ); - } - - public function testDockerNetworkPruneGeneratesCommandFilteredByLabel(): void - { - /** @Given a Docker network prune command */ - $command = DockerNetworkPrune::create(); - - /** @When the argument list is generated */ - $arguments = $command->toArguments(); - - /** @Then the arguments should prune only networks with the managed label */ - self::assertSame( - ['docker', 'network', 'prune', '--force', '--filter', 'label=tiny-blocks.docker-container=true'], - $arguments - ); - } - - public function testDockerRunIncludesManagedLabel(): void - { - /** @Given a Docker run command built from a container definition */ - $definition = ContainerDefinition::create( - image: 'alpine:latest', - name: 'test-label' - ); - $command = DockerRun::from(definition: $definition); - - /** @When the argument list is generated */ - $arguments = $command->toArguments(); - - /** @Then the arguments should include the managed label flag and value */ - self::assertContains('--label', $arguments); - self::assertContains(DockerRun::MANAGED_LABEL, $arguments); - } - - public function testDockerInspectGeneratesCorrectCommand(): void - { - /** @Given a Docker inspect command for a specific container */ - $containerId = ContainerId::from(value: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8'); - $command = DockerInspect::from(id: $containerId); - - /** @When the argument list is generated */ - $arguments = $command->toArguments(); - - /** @Then the arguments should invoke docker inspect on the container id */ - self::assertSame(['docker', 'inspect', '6acae5967be0'], $arguments); - } - - public function testDockerListGeneratesCorrectCommand(): void - { - /** @Given a Docker list command filtered by container name */ - $command = DockerList::from(name: Name::from(value: 'my-container')); - - /** @When the argument list is generated */ - $arguments = $command->toArguments(); - - /** @Then the arguments should list containers filtered by the exact name */ - self::assertSame( - ['docker', 'ps', '--all', '--quiet', '--filter', 'name=^my-container$'], - $arguments - ); - } - - public function testDockerNetworkConnectGeneratesCorrectCommand(): void - { - /** @Given a Docker network connect command */ - $command = DockerNetworkConnect::from(network: 'my-network', container: 'my-container'); - - /** @When the argument list is generated */ - $arguments = $command->toArguments(); - - /** @Then the arguments should connect the container to the network */ - self::assertSame(['docker', 'network', 'connect', 'my-network', 'my-container'], $arguments); - } - - public function testDockerStopGeneratesCorrectCommandWithStringTimeout(): void - { - /** @Given a Docker stop command with an integer timeout */ - $containerId = ContainerId::from(value: '6acae5967be05d8441b4109eea3e4dec5e775068a2a99d95808afb21b2e0a2c8'); - $command = DockerStop::from(id: $containerId, timeoutInWholeSeconds: 45); - - /** @When the argument list is generated */ - $arguments = $command->toArguments(); - - /** @Then the arguments should include the timeout as a string argument */ - self::assertSame(['docker', 'stop', '--time', '45', '6acae5967be0'], $arguments); - } - - public function testDockerReaperGeneratesCommandWithDockerAndScript(): void - { - /** @Given a Docker reaper command */ - $command = DockerReaper::from( - reaperName: 'tiny-blocks-reaper-db', - containerName: 'db', - testRunnerHostname: 'runner-host' - ); - - /** @When the argument list is generated */ - $arguments = $command->toArguments(); - - /** @Then the command should start with docker run and set the reaper name */ - self::assertSame('docker', $arguments[0]); - self::assertSame('run', $arguments[1]); - self::assertContains('tiny-blocks-reaper-db', $arguments); - - /** @And the embedded shell script should poll, remove and prune by watching the runner hostname */ - $script = end($arguments); - self::assertStringContainsString('while docker inspect runner-host', $script); - self::assertStringContainsString('docker rm -fv db', $script); - self::assertStringContainsString(sprintf('label=%s', DockerRun::MANAGED_LABEL), $script); - } - - public function testContainerIdAcceptsExactMinimumLength(): void - { - /** @Given a container ID whose length is exactly the minimum */ - $containerId = ContainerId::from(value: '123456789012'); - - /** @Then the full string should be preserved as the container value */ - self::assertSame('123456789012', $containerId->value); - } - - public function testContainerIdRejectsElevenCharacterValue(): void - { - /** @Then an InvalidArgumentException should be thrown for a value one character below the minimum */ - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Container ID <12345678901> is too short. Minimum length is <12> characters.'); - - /** @When a container ID shorter than the minimum length is created */ - ContainerId::from(value: '12345678901'); - } -} diff --git a/tests/Unit/Internal/Containers/Address/AddressTest.php b/tests/Unit/Internal/Containers/Address/AddressTest.php deleted file mode 100644 index 7877322..0000000 --- a/tests/Unit/Internal/Containers/Address/AddressTest.php +++ /dev/null @@ -1,60 +0,0 @@ -getHostForConnection(); - - /** @Then it should return 127.0.0.1 */ - self::assertSame('127.0.0.1', $host); - } - - #[RunInSeparateProcess] - public function testGetHostForConnectionReturnsHostnameWhenInsideDocker(): void - { - require_once __DIR__ . '/../Overrides/file_exists_inside_docker.php'; - - /** @Given an Address with a known hostname */ - $address = Address::from( - ip: IP::from(value: '172.17.0.2'), - ports: Ports::from( - exposedPorts: Collection::createFrom(elements: [3306]), - hostMappedPorts: Collection::createFrom(elements: [49153]) - ), - hostname: Hostname::from(value: 'my-container') - ); - - /** @When getHostForConnection is called inside Docker */ - $host = $address->getHostForConnection(); - - /** @Then it should return the container hostname */ - self::assertSame('my-container', $host); - } -} diff --git a/tests/Unit/Internal/Containers/Address/PortsTest.php b/tests/Unit/Internal/Containers/Address/PortsTest.php deleted file mode 100644 index eb630a9..0000000 --- a/tests/Unit/Internal/Containers/Address/PortsTest.php +++ /dev/null @@ -1,66 +0,0 @@ -getPortForConnection(); - - /** @Then it should return the host-mapped port */ - self::assertSame(49153, $port); - } - - #[RunInSeparateProcess] - public function testGetPortForConnectionReturnsExposedPortWhenInsideDocker(): void - { - require_once __DIR__ . '/../Overrides/file_exists_inside_docker.php'; - - /** @Given Ports with known exposed and host-mapped ports */ - $ports = Ports::from( - exposedPorts: Collection::createFrom(elements: [3306]), - hostMappedPorts: Collection::createFrom(elements: [49153]) - ); - - /** @When getPortForConnection is called inside Docker */ - $port = $ports->getPortForConnection(); - - /** @Then it should return the container-internal exposed port */ - self::assertSame(3306, $port); - } - - public function testPortsDropsFalsyValuesAndReindexesSequentially(): void - { - /** @Given Ports built from collections containing zeros between valid port numbers */ - $ports = Ports::from( - exposedPorts: Collection::createFrom(elements: [0, 3306, 0, 8080]), - hostMappedPorts: Collection::createFrom(elements: [0, 49153, 0, 49154]) - ); - - /** @Then exposed ports should drop zero values and use sequential numeric keys from zero */ - self::assertSame([3306, 8080], $ports->exposedPorts()); - self::assertSame(3306, $ports->firstExposedPort()); - - /** @And host-mapped ports should drop zero values and use sequential numeric keys from zero */ - self::assertSame([49153, 49154], $ports->hostPorts()); - self::assertSame(49153, $ports->firstHostPort()); - } -} diff --git a/tests/Unit/Internal/Containers/ContainerReaperTest.php b/tests/Unit/Internal/Containers/ContainerReaperTest.php deleted file mode 100644 index c926613..0000000 --- a/tests/Unit/Internal/Containers/ContainerReaperTest.php +++ /dev/null @@ -1,74 +0,0 @@ -ensureRunningFor(containerName: 'test-container'); - - /** @Then no Docker commands should have been executed */ - self::assertEmpty($client->getExecutedCommandLines()); - } - - #[RunInSeparateProcess] - public function testEnsureRunningForStartsReaperWhenReaperIsMissing(): void - { - require_once __DIR__ . '/Overrides/file_exists_inside_docker.php'; - - /** @Given a ContainerReaper with a mock client */ - $client = new ClientMock(); - $reaper = new ContainerReaper(client: $client); - - /** @And no existing reaper container is listed */ - $client->withDockerListResponse(output: ''); - - /** @When ensureRunningFor is called inside a Docker environment */ - $reaper->ensureRunningFor(containerName: 'test-container'); - - /** @Then a reaper container should have been started for the target container */ - self::assertStringContainsString( - 'docker run --rm -d --name tiny-blocks-reaper-test-container', - implode(PHP_EOL, $client->getExecutedCommandLines()) - ); - } - - #[RunInSeparateProcess] - public function testEnsureRunningForStartsReaperWhenListOutputIsOnlyWhitespace(): void - { - require_once __DIR__ . '/Overrides/file_exists_inside_docker.php'; - - /** @Given a ContainerReaper with a mock client */ - $client = new ClientMock(); - $reaper = new ContainerReaper(client: $client); - - /** @And the Docker list response contains only whitespace */ - $client->withDockerListResponse(output: " \n\t "); - - /** @When ensureRunningFor is called inside a Docker environment */ - $reaper->ensureRunningFor(containerName: 'whitespace-container'); - - /** @Then a reaper container should have been started for the target container */ - self::assertStringContainsString( - 'docker run --rm -d --name tiny-blocks-reaper-whitespace-container', - implode(PHP_EOL, $client->getExecutedCommandLines()) - ); - } -} - diff --git a/tests/Unit/Internal/Containers/Overrides/file_exists_outside_docker.php b/tests/Unit/Internal/Containers/Overrides/file_exists_outside_docker.php index 9b0800b..a835329 100644 --- a/tests/Unit/Internal/Containers/Overrides/file_exists_outside_docker.php +++ b/tests/Unit/Internal/Containers/Overrides/file_exists_outside_docker.php @@ -8,4 +8,3 @@ function file_exists(string $filename): bool { return false; } - diff --git a/tests/Unit/Internal/Containers/Overrides/register_shutdown_function_spy.php b/tests/Unit/Internal/Containers/Overrides/register_shutdown_function_spy.php index cd5e2d9..5b65642 100644 --- a/tests/Unit/Internal/Containers/Overrides/register_shutdown_function_spy.php +++ b/tests/Unit/Internal/Containers/Overrides/register_shutdown_function_spy.php @@ -4,11 +4,13 @@ namespace TinyBlocks\DockerContainer\Internal\Containers; -$registeredShutdownCallbacks = []; - function register_shutdown_function(callable $callback): void { global $registeredShutdownCallbacks; + + if (!is_array($registeredShutdownCallbacks)) { + $registeredShutdownCallbacks = []; + } + $registeredShutdownCallbacks[] = $callback; } - diff --git a/tests/Unit/Internal/Containers/ShutdownHookTest.php b/tests/Unit/Internal/Containers/ShutdownHookTest.php deleted file mode 100644 index 5617165..0000000 --- a/tests/Unit/Internal/Containers/ShutdownHookTest.php +++ /dev/null @@ -1,37 +0,0 @@ -register(callback: $callback); - - /** @Then the callback should have been captured by the shutdown function */ - global $registeredShutdownCallbacks; - self::assertCount(expectedCount: 1, haystack: $registeredShutdownCallbacks); - - /** @And the registered callback should be executable */ - ($registeredShutdownCallbacks[0])(); - self::assertTrue($callbackExecuted); - } -} - diff --git a/tests/Unit/MySQLCommandsTest.php b/tests/Unit/MySQLCommandsTest.php new file mode 100644 index 0000000..f0f0494 --- /dev/null +++ b/tests/Unit/MySQLCommandsTest.php @@ -0,0 +1,27 @@ +newInstanceWithoutConstructor(); + + /** @When the private constructor is invoked through reflection */ + $reflection->getMethod('__construct')->invoke($instance); + + /** @Then the MySQL commands instance is created */ + self::assertInstanceOf(MySQLCommands::class, $instance); + } +} diff --git a/tests/Unit/MySQLDockerContainerTest.php b/tests/Unit/MySQLDockerContainerTest.php index 6557fc5..241540d 100644 --- a/tests/Unit/MySQLDockerContainerTest.php +++ b/tests/Unit/MySQLDockerContainerTest.php @@ -5,13 +5,10 @@ namespace Test\Unit; use PHPUnit\Framework\TestCase; -use Test\Unit\Mocks\ClientMock; -use Test\Unit\Mocks\InspectResponseFixture; -use Test\Unit\Mocks\ShutdownHookMock; -use Test\Unit\Mocks\TestableMySQLDockerContainer; -use TinyBlocks\DockerContainer\Contracts\MySQL\MySQLContainerStarted; +use Test\Models\InspectResponseFixture; use TinyBlocks\DockerContainer\Internal\Exceptions\ContainerWaitTimeout; use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed; +use TinyBlocks\DockerContainer\MySQL\MySQLContainerStarted; use TinyBlocks\DockerContainer\Waits\Conditions\ContainerReady; use TinyBlocks\DockerContainer\Waits\ContainerWaitForDependency; @@ -24,12 +21,106 @@ protected function setUp(): void $this->client = new ClientMock(); } + public function testGetJdbcUrlWithoutOptions(): void + { + /** @Given a running MySQL container */ + $started = RunningMySQLContainer::startWith( + client: $this->client, + database: 'test_adm', + hostname: 'test-db', + port: 3306 + ); + + /** @When getting the JDBC URL with empty options */ + $jdbcUrl = $started->getJdbcUrl(options: []); + + /** @Then the URL should not include any query string */ + self::assertSame('jdbc:mysql://test-db:3306/test_adm', $jdbcUrl); + } + + public function testGetJdbcUrlWithCustomOptions(): void + { + /** @Given a running MySQL container */ + $started = RunningMySQLContainer::startWith( + client: $this->client, + database: 'test_adm', + hostname: 'test-db', + port: 3306 + ); + + /** @When getting the JDBC URL with custom options */ + $jdbcUrl = $started->getJdbcUrl(options: ['connectTimeout' => '5000', 'useSSL' => 'true']); + + /** @Then the URL should include the custom options */ + self::assertSame( + 'jdbc:mysql://test-db:3306/test_adm?connectTimeout=5000&useSSL=true', + $jdbcUrl + ); + } + + public function testCustomReadinessTimeoutIsUsed(): void + { + /** @Given a MySQL container with a custom readiness timeout */ + $container = TestableMySQLDockerContainer::createWith( + name: 'timeout-db', + image: 'mysql:8.1', + client: $this->client + ) + ->withDatabase(database: 'test_db') + ->withRootPassword(rootPassword: 'root') + ->withReadinessTimeout(timeoutInSeconds: 60); + + /** @And the Docker daemon starts the container */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'timeout-db', + environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] + ) + ); + + /** @And the MySQL readiness check succeeds on first attempt */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + $this->client->withDockerExecuteResponse(output: ''); + + /** @When the MySQL container is started */ + $started = $container->run(); + + /** @Then the container should start successfully */ + self::assertSame('timeout-db', $started->getName()); + } + + public function testGetJdbcUrlWithDefaultOptions(): void + { + /** @Given a running MySQL container */ + $started = RunningMySQLContainer::startWith( + client: $this->client, + database: 'test_adm', + hostname: 'test-db', + port: 3306 + ); + + /** @When getting the JDBC URL with default options */ + $jdbcUrl = $started->getJdbcUrl(); + + /** @Then the URL should include default JDBC options */ + $options = implode('&', [ + 'useSSL=false', + 'useUnicode=yes', + 'characterEncoding=UTF-8', + 'allowPublicKeyRetrieval=true' + ]); + $template = 'jdbc:mysql://test-db:3306/test_adm?%s'; + + self::assertSame(sprintf($template, $options), $jdbcUrl); + } + public function testRunMySQLContainerSuccessfully(): void { /** @Given a MySQL container with full configuration */ $container = TestableMySQLDockerContainer::createWith( - image: 'mysql:8.1', name: 'test-db', + image: 'mysql:8.1', client: $this->client ) ->withNetwork(name: 'my-net') @@ -70,244 +161,270 @@ public function testRunMySQLContainerSuccessfully(): void $started = $container->run(); /** @Then it should return a MySQLContainerStarted instance */ - self::assertSame(expected: 'test-db', actual: $started->getName()); - self::assertSame(expected: InspectResponseFixture::shortContainerId(), actual: $started->getId()); + self::assertSame('test-db', $started->getName()); + self::assertSame(InspectResponseFixture::shortContainerId(), $started->getId()); /** @And the environment variables should be accessible */ self::assertSame( - expected: 'test_adm', - actual: $started->getEnvironmentVariables()->getValueBy( + 'test_adm', + $started->getEnvironmentVariables()->getValueBy( key: 'MYSQL_DATABASE' ) ); self::assertSame( - expected: 'app_user', - actual: $started->getEnvironmentVariables()->getValueBy( + 'app_user', + $started->getEnvironmentVariables()->getValueBy( key: 'MYSQL_USER' ) ); self::assertSame( - expected: 'secret', - actual: $started->getEnvironmentVariables()->getValueBy( + 'secret', + $started->getEnvironmentVariables()->getValueBy( key: 'MYSQL_PASSWORD' ) ); self::assertSame( - expected: 'root', - actual: $started->getEnvironmentVariables()->getValueBy( + 'root', + $started->getEnvironmentVariables()->getValueBy( key: 'MYSQL_ROOT_PASSWORD' ) ); /** @And the port should be exposed */ - self::assertSame(expected: 3306, actual: $started->getAddress()->getPorts()->firstExposedPort()); + self::assertSame(3306, $started->getAddress()->getPorts()->firstExposedPort()); /** @And the docker run command should reflect delegated configuration */ $commandLines = $this->client->getExecutedCommandLines(); $runCommand = $commandLines[2]; - self::assertStringNotContainsString(needle: '--rm', haystack: $runCommand); - self::assertStringContainsString(needle: '--volume /var/lib/mysql:/var/lib/mysql', haystack: $runCommand); - self::assertStringContainsString(needle: '--publish 3306:3306', haystack: $runCommand); - self::assertStringContainsString(needle: 'TZ=America/Sao_Paulo', haystack: $runCommand); - self::assertStringContainsString(needle: 'MYSQL_USER=app_user', haystack: $runCommand); - self::assertStringContainsString(needle: 'MYSQL_PASSWORD=secret', haystack: $runCommand); - self::assertStringContainsString(needle: 'MYSQL_DATABASE=test_adm', haystack: $runCommand); - self::assertStringContainsString(needle: 'MYSQL_ROOT_PASSWORD=root', haystack: $runCommand); + self::assertStringNotContainsString('--rm', $runCommand); + self::assertStringContainsString('--volume /var/lib/mysql:/var/lib/mysql', $runCommand); + self::assertStringContainsString('--publish 3306:3306', $runCommand); + self::assertStringContainsString('TZ=America/Sao_Paulo', $runCommand); + self::assertStringContainsString('MYSQL_USER=app_user', $runCommand); + self::assertStringContainsString('MYSQL_PASSWORD=secret', $runCommand); + self::assertStringContainsString('MYSQL_DATABASE=test_adm', $runCommand); + self::assertStringContainsString('MYSQL_ROOT_PASSWORD=root', $runCommand); /** @And the readiness probe should execute env-prefixed mysqladmin ping via docker exec */ $readinessCommand = $commandLines[4]; self::assertStringContainsString( - needle: 'docker exec test-db env MYSQL_PWD=root mysqladmin ping -h 127.0.0.1', - haystack: $readinessCommand + 'docker exec test-db env MYSQL_PWD=root mysqladmin ping -h 127.0.0.1', + $readinessCommand ); /** @And the database setup should include CREATE DATABASE, GRANT, and FLUSH */ $setupCommand = $commandLines[5]; - self::assertStringContainsString(needle: 'CREATE DATABASE IF NOT EXISTS test_adm', haystack: $setupCommand); - self::assertStringContainsString(needle: 'GRANT ALL PRIVILEGES', haystack: $setupCommand); - self::assertStringContainsString(needle: 'FLUSH PRIVILEGES', haystack: $setupCommand); + self::assertStringContainsString('CREATE DATABASE IF NOT EXISTS test_adm', $setupCommand); + self::assertStringContainsString('GRANT ALL PRIVILEGES', $setupCommand); + self::assertStringContainsString('FLUSH PRIVILEGES', $setupCommand); /** @And the GRANT statements should include both default hosts */ - self::assertStringContainsString(needle: "'root'@'%'", haystack: $setupCommand); - self::assertStringContainsString(needle: "'root'@'172.%'", haystack: $setupCommand); + self::assertStringContainsString("'root'@'%'", $setupCommand); + self::assertStringContainsString("'root'@'172.%'", $setupCommand); } - public function testRunIfNotExistsReturnsMySQLContainerStarted(): void + public function testRunMySQLContainerWithPullImage(): void { - /** @Given a MySQL container */ + /** @Given a MySQL container with image pulling enabled */ $container = TestableMySQLDockerContainer::createWith( + name: 'pull-db', image: 'mysql:8.1', - name: 'existing-db', client: $this->client ) - ->withDatabase(database: 'my_db') - ->withRootPassword(rootPassword: 'root'); + ->withRootPassword(rootPassword: 'root') + ->pullImage(); - /** @And the container already exists */ - $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + /** @And the Docker daemon returns valid responses */ + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'existing-db', - environment: ['MYSQL_DATABASE=my_db', 'MYSQL_ROOT_PASSWORD=root'], - exposedPorts: ['3306/tcp' => (object)[]] + hostname: 'pull-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); - /** @When runIfNotExists is called */ - $started = $container->runIfNotExists(); + /** @And the MySQL readiness check succeeds */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - /** @Then it should return a MySQLContainerStarted wrapping the existing container */ - self::assertSame(expected: 'existing-db', actual: $started->getName()); + /** @When the container is started (waiting for the image pull to complete first) */ + $started = $container->run(); + + /** @Then the container should be running */ + self::assertSame('pull-db', $started->getName()); + + /** @And the docker pull command should have been executed */ + $commandLines = $this->client->getExecutedCommandLines(); + + self::assertStringContainsString('docker pull mysql:8.1', implode(PHP_EOL, $commandLines)); } - public function testRunIfNotExistsCreatesNewMySQLContainer(): void + public function testRunMySQLContainerWithoutDatabase(): void { - /** @Given a MySQL container that does not exist */ + /** @Given a MySQL container without a database configured */ $container = TestableMySQLDockerContainer::createWith( + name: 'no-db', image: 'mysql:8.1', - name: 'new-db', client: $this->client - ) - ->withDatabase(database: 'new_db') - ->withRootPassword(rootPassword: 'root'); - - /** @And the Docker list returns empty (container does not exist) */ - $this->client->withDockerListResponse(output: ''); + )->withRootPassword(rootPassword: 'root'); - /** @And the Docker daemon returns valid run and inspect responses */ + /** @And the Docker daemon returns valid responses with no MYSQL_DATABASE */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'new-db', - environment: ['MYSQL_DATABASE=new_db', 'MYSQL_ROOT_PASSWORD=root'] + hostname: 'no-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And the MySQL readiness check and CREATE DATABASE succeed */ + /** @And the MySQL readiness check succeeds */ $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - $this->client->withDockerExecuteResponse(output: ''); - /** @When runIfNotExists is called */ - $started = $container->runIfNotExists(); + /** @When the MySQL container is started (no CREATE DATABASE should be called) */ + $started = $container->run(); - /** @Then a new container should be created */ - self::assertSame(expected: 'new-db', actual: $started->getName()); + /** @Then the container should start without errors */ + self::assertSame('no-db', $started->getName()); } - public function testRunMySQLContainerRetriesReadinessCheckBeforeSucceeding(): void + public function testMySQLContainerDelegatesGetAddress(): void { - /** @Given a MySQL container */ + /** @Given a running MySQL container */ + $started = RunningMySQLContainer::startWith( + client: $this->client, + database: 'test_adm', + hostname: 'address-db', + port: 3306 + ); + + /** @When getting the container address */ + $address = $started->getAddress(); + + /** @Then the address should delegate correctly */ + self::assertSame('address-db', $address->getHostname()); + self::assertSame('172.22.0.2', $address->getIp()); + self::assertSame(3306, $address->getPorts()->firstExposedPort()); + self::assertSame([3306], $address->getPorts()->exposedPorts()); + } + + public function testRunMySQLContainerWithWaitBeforeRun(): void + { + /** @Given a MySQL container with a wait-before-run condition */ + $condition = $this->createMock(ContainerReady::class); + $condition->expects(self::once())->method('isReady')->willReturn(true); + + /** @And the container is configured */ $container = TestableMySQLDockerContainer::createWith( + name: 'wait-db', image: 'mysql:8.1', - name: 'retry-db', client: $this->client ) - ->withDatabase(database: 'test_db') ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 10); + ->withWaitBeforeRun( + wait: ContainerWaitForDependency::untilReady(condition: $condition) + ); - /** @And the Docker daemon starts the container */ + /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'retry-db', - environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] + hostname: 'wait-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And the MySQL readiness check fails twice before succeeding */ - $this->client->withDockerExecuteResponse(output: 'not ready', isSuccessful: false); - $this->client->withDockerExecuteResponse(output: 'not ready', isSuccessful: false); + /** @And the MySQL readiness check succeeds */ $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - /** @And the CREATE DATABASE command succeeds */ - $this->client->withDockerExecuteResponse(output: ''); - - /** @When the MySQL container is started */ + /** @When the container is started */ $started = $container->run(); - /** @Then the container should start after retries */ - self::assertSame(expected: 'retry-db', actual: $started->getName()); + /** @Then the wait-before-run condition should have been evaluated */ + self::assertSame('wait-db', $started->getName()); } - public function testRunMySQLContainerRetriesWhenReadinessCheckThrowsException(): void + public function testExceptionWhenMySQLNeverBecomesReady(): void { - /** @Given a MySQL container */ + /** @Given a MySQL container with a very short readiness timeout */ $container = TestableMySQLDockerContainer::createWith( + name: 'stuck-db', image: 'mysql:8.1', - name: 'exception-db', client: $this->client ) ->withDatabase(database: 'test_db') ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 10); + ->withReadinessTimeout(timeoutInSeconds: 1); /** @And the Docker daemon starts the container */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'exception-db', + hostname: 'stuck-db', environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And the MySQL readiness check throws an exception first, then succeeds */ - $this->client->withDockerExecuteException( - exception: new DockerCommandExecutionFailed(reason: 'container not running', command: 'docker exec') - ); - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - - /** @And the CREATE DATABASE command succeeds */ - $this->client->withDockerExecuteResponse(output: ''); + /** @And the MySQL readiness check always fails */ + for ($index = 0; $index < 100; $index++) { + $this->client->withDockerExecuteResponse(output: 'mysqld is not ready', isSuccessful: false); + } - /** @When the MySQL container is started */ - $started = $container->run(); + /** @Then a ContainerWaitTimeout exception should be thrown */ + $this->expectException(ContainerWaitTimeout::class); + $this->expectExceptionMessage('Container readiness check timed out after <1> seconds.'); - /** @Then the container should start after the exception was caught and retried */ - self::assertSame(expected: 'exception-db', actual: $started->getName()); + /** @When attempting to start the MySQL container */ + $container->run(); } - public function testRunMySQLContainerWithSingleGrantedHost(): void + public function testMySQLContainerDelegatesStopCorrectly(): void { - /** @Given a MySQL container with a single granted host */ - $container = TestableMySQLDockerContainer::createWith( - image: 'mysql:8.1', - name: 'single-grant', - client: $this->client - ) - ->withDatabase(database: 'test_db') - ->withRootPassword(rootPassword: 'root') - ->withGrantedHosts(hosts: ['%']); - - /** @And the Docker daemon returns valid responses */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); - $this->client->withDockerInspectResponse( - inspectResult: InspectResponseFixture::build( - hostname: 'single-grant', - environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] - ) + /** @Given a running MySQL container */ + $started = RunningMySQLContainer::startWith( + client: $this->client, + database: 'test_adm', + hostname: 'stop-db', + port: 3306 ); - /** @And readiness and database setup succeed */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - $this->client->withDockerExecuteResponse(output: ''); + /** @And the Docker stop command succeeds */ + $this->client->withDockerStopResponse(output: ''); - /** @When the container is started */ - $started = $container->run(); + /** @When the container is stopped */ + $stopped = $started->stop(); - /** @Then the container should start successfully */ - self::assertSame(expected: 'single-grant', actual: $started->getName()); + /** @Then the stop should be successful */ + self::assertTrue($stopped->isSuccessful()); } - public function testRunMySQLContainerWithCopyToContainer(): void + public function testRemoveDelegatesToUnderlyingContainer(): void { - /** @Given a MySQL container with files to copy */ + /** @Given a running MySQL container */ + $started = RunningMySQLContainer::startWith( + client: $this->client, + database: 'test_adm', + hostname: 'remove-db', + port: 3306 + ); + + /** @When remove is called */ + $started->remove(); + + /** @Then the docker rm command should have been executed */ + $commandLines = $this->client->getExecutedCommandLines(); + $removeCommand = $commandLines[4]; + + self::assertStringContainsString('docker rm --force --volumes', $removeCommand); + } + + public function testRunMySQLContainerWithCopyToContainer(): void + { + /** @Given a MySQL container with files to copy */ $container = TestableMySQLDockerContainer::createWith( - image: 'mysql:8.1', name: 'copy-db', + image: 'mysql:8.1', client: $this->client ) ->withRootPassword(rootPassword: 'root') @@ -329,273 +446,179 @@ public function testRunMySQLContainerWithCopyToContainer(): void $started = $container->run(); /** @Then the container should be running with copy instructions executed */ - self::assertSame(expected: 'copy-db', actual: $started->getName()); + self::assertSame('copy-db', $started->getName()); /** @And the docker cp command should have been executed */ $commandLines = $this->client->getExecutedCommandLines(); - $hasCopyCommand = false; - - foreach ($commandLines as $commandLine) { - if (str_contains($commandLine, 'docker cp') && str_contains($commandLine, '/host/init')) { - $hasCopyCommand = true; - } - } - self::assertTrue($hasCopyCommand); + self::assertNotEmpty( + array_filter( + $commandLines, + static fn(string $line): bool => str_contains($line, 'docker cp') && str_contains($line, '/host/init') + ) + ); } - public function testRunMySQLContainerWithWaitBeforeRun(): void + public function testRunMySQLContainerWithoutGrantedHosts(): void { - /** @Given a MySQL container with a wait-before-run condition */ - $condition = $this->createMock(ContainerReady::class); - $condition->expects(self::once())->method('isReady')->willReturn(true); - - /** @And the container is configured */ + /** @Given a MySQL container without granted hosts */ $container = TestableMySQLDockerContainer::createWith( + name: 'no-grants', image: 'mysql:8.1', - name: 'wait-db', client: $this->client ) - ->withRootPassword(rootPassword: 'root') - ->withWaitBeforeRun( - wait: ContainerWaitForDependency::untilReady(condition: $condition) - ); + ->withDatabase(database: 'test_db') + ->withRootPassword(rootPassword: 'root'); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'wait-db', - environment: ['MYSQL_ROOT_PASSWORD=root'] + hostname: 'no-grants', + environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And the MySQL readiness check succeeds */ + /** @And the MySQL readiness and CREATE DATABASE calls succeed */ $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + $this->client->withDockerExecuteResponse(output: ''); - /** @When the container is started */ + /** @When the MySQL container is started (no GRANT PRIVILEGES should be called) */ $started = $container->run(); - /** @Then the wait-before-run condition should have been evaluated */ - self::assertSame(expected: 'wait-db', actual: $started->getName()); - } - - public function testGetJdbcUrlWithDefaultOptions(): void - { - /** @Given a running MySQL container */ - $started = $this->createRunningMySQLContainer( - hostname: 'test-db', - database: 'test_adm', - port: 3306 - ); - - /** @When getting the JDBC URL with default options */ - $jdbcUrl = $started->getJdbcUrl(); - - /** @Then the URL should include default JDBC options */ - self::assertSame( - expected: 'jdbc:mysql://test-db:3306/test_adm?useSSL=false&useUnicode=yes&characterEncoding=UTF-8&allowPublicKeyRetrieval=true', - actual: $jdbcUrl - ); - } - - public function testGetJdbcUrlWithCustomOptions(): void - { - /** @Given a running MySQL container */ - $started = $this->createRunningMySQLContainer( - hostname: 'test-db', - database: 'test_adm', - port: 3306 - ); - - /** @When getting the JDBC URL with custom options */ - $jdbcUrl = $started->getJdbcUrl(options: ['connectTimeout' => '5000', 'useSSL' => 'true']); - - /** @Then the URL should include the custom options */ - self::assertSame( - expected: 'jdbc:mysql://test-db:3306/test_adm?connectTimeout=5000&useSSL=true', - actual: $jdbcUrl - ); - } - - public function testGetJdbcUrlWithoutOptions(): void - { - /** @Given a running MySQL container */ - $started = $this->createRunningMySQLContainer( - hostname: 'test-db', - database: 'test_adm', - port: 3306 - ); - - /** @When getting the JDBC URL with empty options */ - $jdbcUrl = $started->getJdbcUrl(options: []); - - /** @Then the URL should not include any query string */ - self::assertSame(expected: 'jdbc:mysql://test-db:3306/test_adm', actual: $jdbcUrl); - } - - public function testGetJdbcUrlDefaultsToPort3306WhenNoPortExposed(): void - { - /** @Given a running MySQL container with no exposed ports */ - $started = $this->createRunningMySQLContainer( - hostname: 'test-db', - database: 'test_adm', - port: null - ); - - /** @When getting the JDBC URL */ - $jdbcUrl = $started->getJdbcUrl(options: []); - - /** @Then the URL should use the default MySQL port 3306 */ - self::assertSame(expected: 'jdbc:mysql://test-db:3306/test_adm', actual: $jdbcUrl); - } - - public function testGetJdbcUrlUsesExposedPortWhenDifferentFromDefault(): void - { - /** @Given a running MySQL container with a non-default port */ - $started = $this->createRunningMySQLContainer( - hostname: 'custom-port-db', - database: 'test_adm', - port: 3307 - ); + /** @Then the container should start without errors */ + self::assertSame('no-grants', $started->getName()); - /** @When getting the JDBC URL */ - $jdbcUrl = $started->getJdbcUrl(options: []); + /** @And the setup should include CREATE DATABASE but no GRANT statements */ + $commandLines = $this->client->getExecutedCommandLines(); + $setupCommand = $commandLines[3]; - /** @Then the URL should use the exposed port 3307 instead of the default 3306 */ - self::assertSame(expected: 'jdbc:mysql://custom-port-db:3307/test_adm', actual: $jdbcUrl); + self::assertStringContainsString('CREATE DATABASE IF NOT EXISTS test_db', $setupCommand); + self::assertStringNotContainsString('GRANT ALL PRIVILEGES', $setupCommand); } - public function testRunMySQLContainerWithoutDatabase(): void + public function testRunIfNotExistsCreatesNewMySQLContainer(): void { - /** @Given a MySQL container without a database configured */ + /** @Given a MySQL container that does not exist */ $container = TestableMySQLDockerContainer::createWith( + name: 'new-db', image: 'mysql:8.1', - name: 'no-db', client: $this->client - )->withRootPassword(rootPassword: 'root'); + ) + ->withDatabase(database: 'new_db') + ->withRootPassword(rootPassword: 'root'); - /** @And the Docker daemon returns valid responses with no MYSQL_DATABASE */ + /** @And the Docker list returns empty (container does not exist) */ + $this->client->withDockerListResponse(output: ''); + + /** @And the Docker daemon returns valid run and inspect responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'no-db', - environment: ['MYSQL_ROOT_PASSWORD=root'] + hostname: 'new-db', + environment: ['MYSQL_DATABASE=new_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And the MySQL readiness check succeeds */ + /** @And the MySQL readiness check and CREATE DATABASE succeed */ $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + $this->client->withDockerExecuteResponse(output: ''); - /** @When the MySQL container is started (no CREATE DATABASE should be called) */ - $started = $container->run(); + /** @When runIfNotExists is called */ + $started = $container->runIfNotExists(); - /** @Then the container should start without errors */ - self::assertSame(expected: 'no-db', actual: $started->getName()); + /** @Then a new container should be created */ + self::assertSame('new-db', $started->getName()); } - public function testRunMySQLContainerWithoutGrantedHosts(): void + public function testRunMySQLContainerWithSingleGrantedHost(): void { - /** @Given a MySQL container without granted hosts */ + /** @Given a MySQL container with a single granted host */ $container = TestableMySQLDockerContainer::createWith( + name: 'single-grant', image: 'mysql:8.1', - name: 'no-grants', client: $this->client ) ->withDatabase(database: 'test_db') - ->withRootPassword(rootPassword: 'root'); + ->withRootPassword(rootPassword: 'root') + ->withGrantedHosts(hosts: ['%']); /** @And the Docker daemon returns valid responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'no-grants', + hostname: 'single-grant', environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And the MySQL readiness and CREATE DATABASE calls succeed */ + /** @And readiness and database setup succeed */ $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); $this->client->withDockerExecuteResponse(output: ''); - /** @When the MySQL container is started (no GRANT PRIVILEGES should be called) */ + /** @When the container is started */ $started = $container->run(); - /** @Then the container should start without errors */ - self::assertSame(expected: 'no-grants', actual: $started->getName()); + /** @Then the container should start successfully */ + self::assertSame('single-grant', $started->getName()); + } - /** @And the setup should include CREATE DATABASE but no GRANT statements */ - $commandLines = $this->client->getExecutedCommandLines(); - $setupCommand = $commandLines[3]; + public function testMySQLContainerDelegatesExecuteAfterStarted(): void + { + /** @Given a running MySQL container */ + $started = RunningMySQLContainer::startWith( + client: $this->client, + database: 'test_adm', + hostname: 'exec-db', + port: 3306 + ); - self::assertStringContainsString(needle: 'CREATE DATABASE IF NOT EXISTS test_db', haystack: $setupCommand); - self::assertStringNotContainsString(needle: 'GRANT ALL PRIVILEGES', haystack: $setupCommand); + /** @And a command execution returns output */ + $this->client->withDockerExecuteResponse(output: 'SHOW DATABASES output'); + + /** @When commands are executed inside the container */ + $execution = $started->executeAfterStarted(commands: ['mysql', '-e', 'SHOW DATABASES']); + + /** @Then the execution should return the output */ + self::assertTrue($execution->isSuccessful()); + self::assertSame('SHOW DATABASES output', $execution->getOutput()); } - public function testRunMySQLContainerWithGrantedHostsButNoDatabase(): void + public function testRunIfNotExistsReturnsMySQLContainerStarted(): void { - /** @Given a MySQL container with granted hosts but no database */ + /** @Given a MySQL container */ $container = TestableMySQLDockerContainer::createWith( + name: 'existing-db', image: 'mysql:8.1', - name: 'grants-only', client: $this->client ) - ->withRootPassword(rootPassword: 'root') - ->withGrantedHosts(hosts: ['%']); + ->withDatabase(database: 'my_db') + ->withRootPassword(rootPassword: 'root'); - /** @And the Docker daemon returns valid responses without MYSQL_DATABASE */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + /** @And the container already exists */ + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'grants-only', - environment: ['MYSQL_ROOT_PASSWORD=root'] + hostname: 'existing-db', + environment: ['MYSQL_DATABASE=my_db', 'MYSQL_ROOT_PASSWORD=root'], + exposedPorts: ['3306/tcp' => (object)[]] ) ); - /** @And the MySQL readiness check and setup succeed */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - $this->client->withDockerExecuteResponse(output: ''); - - /** @When the MySQL container is started */ - $started = $container->run(); - - /** @Then the container should start successfully */ - self::assertSame(expected: 'grants-only', actual: $started->getName()); - - /** @And the setup should include GRANT and FLUSH but no CREATE DATABASE */ - $commandLines = $this->client->getExecutedCommandLines(); - $setupCommand = $commandLines[3]; - - self::assertStringNotContainsString(needle: 'CREATE DATABASE', haystack: $setupCommand); - self::assertStringContainsString(needle: 'GRANT ALL PRIVILEGES', haystack: $setupCommand); - self::assertStringContainsString(needle: 'FLUSH PRIVILEGES', haystack: $setupCommand); - } - - public function testMySQLContainerDelegatesStopCorrectly(): void - { - /** @Given a running MySQL container */ - $started = $this->createRunningMySQLContainer( - hostname: 'stop-db', - database: 'test_adm', - port: 3306 - ); - - /** @And the Docker stop command succeeds */ - $this->client->withDockerStopResponse(output: ''); - - /** @When the container is stopped */ - $stopped = $started->stop(); + /** @When runIfNotExists is called */ + $started = $container->runIfNotExists(); - /** @Then the stop should be successful */ - self::assertTrue($stopped->isSuccessful()); + /** @Then it should return a MySQLContainerStarted wrapping the existing container */ + self::assertSame('existing-db', $started->getName()); } public function testMySQLContainerDelegatesStopWithCustomTimeout(): void { /** @Given a running MySQL container */ - $started = $this->createRunningMySQLContainer( - hostname: 'stop-timeout-db', + $started = RunningMySQLContainer::startWith( + client: $this->client, database: 'test_adm', + hostname: 'stop-timeout-db', port: 3306 ); @@ -609,312 +632,324 @@ public function testMySQLContainerDelegatesStopWithCustomTimeout(): void self::assertTrue($stopped->isSuccessful()); } - public function testMySQLContainerDelegatesExecuteAfterStarted(): void + public function testStopOnShutdownDelegatesToUnderlyingContainer(): void { - /** @Given a running MySQL container */ - $started = $this->createRunningMySQLContainer( - hostname: 'exec-db', - database: 'test_adm', - port: 3306 - ); + /** @Given a ShutdownHook that tracks registration */ + $shutdownHook = new ShutdownHookMock(); - /** @And a command execution returns output */ - $this->client->withDockerExecuteResponse(output: 'SHOW DATABASES output'); + /** @And a running MySQL container using the tracked hook */ + $container = TestableMySQLDockerContainer::createWith( + name: 'shutdown-db', + image: 'mysql:8.1', + client: $this->client, + shutdownHook: $shutdownHook + ) + ->withDatabase(database: 'test_adm') + ->withRootPassword(rootPassword: 'root'); - /** @When commands are executed inside the container */ - $execution = $started->executeAfterStarted(commands: ['mysql', '-e', 'SHOW DATABASES']); + $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $this->client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: 'shutdown-db', + environment: ['MYSQL_DATABASE=test_adm', 'MYSQL_ROOT_PASSWORD=root'], + exposedPorts: ['3306/tcp' => (object)[]] + ) + ); + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + $this->client->withDockerExecuteResponse(output: ''); - /** @Then the execution should return the output */ - self::assertTrue($execution->isSuccessful()); - self::assertSame(expected: 'SHOW DATABASES output', actual: $execution->getOutput()); + /** @And the container is started */ + $started = $container->run(); + + /** @When stopOnShutdown is called */ + $started->stopOnShutdown(); + + /** @Then the shutdown hook should have registered the remove callback */ + self::assertSame(1, $shutdownHook->getRegistrationCount()); } - public function testMySQLContainerDelegatesGetAddress(): void + public function testGetJdbcUrlDefaultsToPort3306WhenNoPortExposed(): void { - /** @Given a running MySQL container */ - $started = $this->createRunningMySQLContainer( - hostname: 'address-db', + /** @Given a running MySQL container with no exposed ports */ + $started = RunningMySQLContainer::startWith( + client: $this->client, database: 'test_adm', - port: 3306 + hostname: 'test-db' ); - /** @When getting the container address */ - $address = $started->getAddress(); + /** @When getting the JDBC URL */ + $jdbcUrl = $started->getJdbcUrl(options: []); - /** @Then the address should delegate correctly */ - self::assertSame(expected: 'address-db', actual: $address->getHostname()); - self::assertSame(expected: '172.22.0.2', actual: $address->getIp()); - self::assertSame(expected: 3306, actual: $address->getPorts()->firstExposedPort()); - self::assertSame(expected: [3306], actual: $address->getPorts()->exposedPorts()); + /** @Then the URL should use the default MySQL port 3306 */ + self::assertSame('jdbc:mysql://test-db:3306/test_adm', $jdbcUrl); } - public function testExceptionWhenMySQLNeverBecomesReady(): void + public function testMySQLContainerWithEnvironmentVariableDirectly(): void { - /** @Given a MySQL container with a very short readiness timeout */ + /** @Given a MySQL container with a custom environment variable */ $container = TestableMySQLDockerContainer::createWith( + name: 'env-db', image: 'mysql:8.1', - name: 'stuck-db', client: $this->client ) - ->withDatabase(database: 'test_db') ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 1); + ->withEnvironmentVariable(key: 'CUSTOM_KEY', value: 'custom_value'); - /** @And the Docker daemon starts the container */ + /** @And the Docker daemon returns valid responses including the custom env var */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'stuck-db', - environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] + hostname: 'env-db', + environment: ['MYSQL_ROOT_PASSWORD=root', 'CUSTOM_KEY=custom_value'] ) ); - /** @And the MySQL readiness check always fails */ - for ($index = 0; $index < 100; $index++) { - $this->client->withDockerExecuteResponse(output: 'mysqld is not ready', isSuccessful: false); - } + /** @And the MySQL readiness check succeeds */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - /** @Then a ContainerWaitTimeout exception should be thrown */ - $this->expectException(ContainerWaitTimeout::class); - $this->expectExceptionMessage('Container readiness check timed out after <1> seconds.'); + /** @When the MySQL container is started */ + $started = $container->run(); - /** @When attempting to start the MySQL container */ - $container->run(); + /** @Then the custom environment variable should be accessible */ + self::assertSame( + 'custom_value', + $started->getEnvironmentVariables()->getValueBy( + key: 'CUSTOM_KEY' + ) + ); + + /** @And the docker run command should include the custom environment variable */ + $runCommand = $this->client->getExecutedCommandLines()[0]; + + self::assertStringContainsString('CUSTOM_KEY=custom_value', $runCommand); } - public function testExceptionWhenMySQLReadinessCheckAlwaysThrowsExceptions(): void + public function testRunMySQLContainerWithGrantedHostsButNoDatabase(): void { - /** @Given a MySQL container with a very short readiness timeout */ + /** @Given a MySQL container with granted hosts but no database */ $container = TestableMySQLDockerContainer::createWith( + name: 'grants-only', image: 'mysql:8.1', - name: 'crash-db', client: $this->client ) ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 1); + ->withGrantedHosts(hosts: ['%']); - /** @And the Docker daemon starts the container */ + /** @And the Docker daemon returns valid responses without MYSQL_DATABASE */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'crash-db', + hostname: 'grants-only', environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And the MySQL readiness check always throws exceptions */ - for ($index = 0; $index < 100; $index++) { - $this->client->withDockerExecuteException( - exception: new DockerCommandExecutionFailed(reason: 'container crashed', command: 'docker exec') - ); - } + /** @And the MySQL readiness check and setup succeed */ + $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + $this->client->withDockerExecuteResponse(output: ''); - /** @Then a ContainerWaitTimeout exception should be thrown (not DockerCommandExecutionFailed) */ - $this->expectException(ContainerWaitTimeout::class); + /** @When the MySQL container is started */ + $started = $container->run(); - /** @When attempting to start the MySQL container */ - $container->run(); + /** @Then the container should start successfully */ + self::assertSame('grants-only', $started->getName()); + + /** @And the setup should include GRANT and FLUSH but no CREATE DATABASE */ + $commandLines = $this->client->getExecutedCommandLines(); + $setupCommand = $commandLines[3]; + + self::assertStringNotContainsString('CREATE DATABASE', $setupCommand); + self::assertStringContainsString('GRANT ALL PRIVILEGES', $setupCommand); + self::assertStringContainsString('FLUSH PRIVILEGES', $setupCommand); } - public function testCustomReadinessTimeoutIsUsed(): void + public function testGetJdbcUrlUsesExposedPortWhenDifferentFromDefault(): void { - /** @Given a MySQL container with a custom readiness timeout */ + /** @Given a running MySQL container with a non-default port */ + $started = RunningMySQLContainer::startWith( + client: $this->client, + database: 'test_adm', + hostname: 'custom-port-db', + port: 3307 + ); + + /** @When getting the JDBC URL */ + $jdbcUrl = $started->getJdbcUrl(options: []); + + /** @Then the URL should use the exposed port 3307 instead of the default 3306 */ + self::assertSame('jdbc:mysql://custom-port-db:3307/test_adm', $jdbcUrl); + } + + public function testRunWhenGateHoldsThenMySQLStartedIsPassedToCallback(): void + { + /** @Given a MySQL container */ $container = TestableMySQLDockerContainer::createWith( + name: 'gate-db', image: 'mysql:8.1', - name: 'timeout-db', client: $this->client - ) - ->withDatabase(database: 'test_db') - ->withRootPassword(rootPassword: 'root') - ->withReadinessTimeout(timeoutInSeconds: 60); + )->withRootPassword(rootPassword: 'root'); - /** @And the Docker daemon starts the container */ + /** @And the Docker list is empty so a fresh container is started */ + $this->client->withDockerListResponse(output: ''); + + /** @And the Docker daemon returns valid run and inspect responses */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'timeout-db', - environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] + hostname: 'gate-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And the MySQL readiness check succeeds on first attempt */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - $this->client->withDockerExecuteResponse(output: ''); + /** @And a callback that captures the started container it receives */ + $received = null; - /** @When the MySQL container is started */ - $started = $container->run(); + /** @When runWhen is invoked with a gate that holds */ + $container->runWhen( + gate: static fn(): bool => true, + then: static function (MySQLContainerStarted $started) use (&$received): void { + $received = $started; + } + ); - /** @Then the container should start successfully */ - self::assertSame(expected: 'timeout-db', actual: $started->getName()); + /** @Then the callback should have received a MySQL started container */ + self::assertInstanceOf(MySQLContainerStarted::class, $received); + + /** @And the received container should carry the expected name */ + self::assertSame('gate-db', $received->getName()); } - public function testMySQLContainerWithEnvironmentVariableDirectly(): void + public function testRunIfNotExistsWhenContainerExistsThenStartedWasReused(): void { - /** @Given a MySQL container with a custom environment variable */ + /** @Given a MySQL container that already exists */ $container = TestableMySQLDockerContainer::createWith( + name: 'reused-db', image: 'mysql:8.1', - name: 'env-db', client: $this->client - ) - ->withRootPassword(rootPassword: 'root') - ->withEnvironmentVariable(key: 'CUSTOM_KEY', value: 'custom_value'); + )->withRootPassword(rootPassword: 'root'); - /** @And the Docker daemon returns valid responses including the custom env var */ - $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + /** @And the Docker list returns the existing container ID */ + $this->client->withDockerListResponse(output: InspectResponseFixture::containerId()); + + /** @And the Docker inspect returns the container details */ $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'env-db', - environment: ['MYSQL_ROOT_PASSWORD=root', 'CUSTOM_KEY=custom_value'] - ) - ); - - /** @And the MySQL readiness check succeeds */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - - /** @When the MySQL container is started */ - $started = $container->run(); - - /** @Then the custom environment variable should be accessible */ - self::assertSame( - expected: 'custom_value', - actual: $started->getEnvironmentVariables()->getValueBy( - key: 'CUSTOM_KEY' + hostname: 'reused-db', + environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And the docker run command should include the custom environment variable */ - $runCommand = $this->client->getExecutedCommandLines()[0]; + /** @When runIfNotExists is called */ + $started = $container->runIfNotExists(); - self::assertStringContainsString(needle: 'CUSTOM_KEY=custom_value', haystack: $runCommand); + /** @Then the started container should report that it was reused */ + self::assertTrue($started->wasReused()); } - public function testRunMySQLContainerWithPullImage(): void + public function testExceptionWhenMySQLReadinessCheckAlwaysThrowsExceptions(): void { - /** @Given a MySQL container with image pulling enabled */ + /** @Given a MySQL container with a very short readiness timeout */ $container = TestableMySQLDockerContainer::createWith( + name: 'crash-db', image: 'mysql:8.1', - name: 'pull-db', client: $this->client ) ->withRootPassword(rootPassword: 'root') - ->pullImage(); + ->withReadinessTimeout(timeoutInSeconds: 1); - /** @And the Docker daemon returns valid responses */ + /** @And the Docker daemon starts the container */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'pull-db', + hostname: 'crash-db', environment: ['MYSQL_ROOT_PASSWORD=root'] ) ); - /** @And the MySQL readiness check succeeds */ - $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); - - /** @When the container is started (waiting for the image pull to complete first) */ - $started = $container->run(); - - /** @Then the container should be running */ - self::assertSame(expected: 'pull-db', actual: $started->getName()); - - /** @And the docker pull command should have been executed */ - $commandLines = $this->client->getExecutedCommandLines(); - $hasPullCommand = false; - - foreach ($commandLines as $commandLine) { - if (str_contains($commandLine, 'docker pull mysql:8.1')) { - $hasPullCommand = true; - } + /** @And the MySQL readiness check always throws exceptions */ + for ($index = 0; $index < 100; $index++) { + $this->client->withDockerExecuteException( + exception: new DockerCommandExecutionFailed(reason: 'container crashed', command: 'docker exec') + ); } - self::assertTrue($hasPullCommand); + /** @Then a ContainerWaitTimeout exception should be thrown (not DockerCommandExecutionFailed) */ + $this->expectException(ContainerWaitTimeout::class); + + /** @When attempting to start the MySQL container */ + $container->run(); } - public function testStopOnShutdownDelegatesToUnderlyingContainer(): void + public function testRunMySQLContainerRetriesReadinessCheckBeforeSucceeding(): void { - /** @Given a ShutdownHook that tracks registration */ - $shutdownHook = new ShutdownHookMock(); - - /** @And a running MySQL container using the tracked hook */ + /** @Given a MySQL container */ $container = TestableMySQLDockerContainer::createWith( + name: 'retry-db', image: 'mysql:8.1', - name: 'shutdown-db', - client: $this->client, - shutdownHook: $shutdownHook + client: $this->client ) - ->withDatabase(database: 'test_adm') - ->withRootPassword(rootPassword: 'root'); + ->withDatabase(database: 'test_db') + ->withRootPassword(rootPassword: 'root') + ->withReadinessTimeout(timeoutInSeconds: 10); + /** @And the Docker daemon starts the container */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: 'shutdown-db', - environment: ['MYSQL_DATABASE=test_adm', 'MYSQL_ROOT_PASSWORD=root'], - exposedPorts: ['3306/tcp' => (object)[]] + hostname: 'retry-db', + environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); + + /** @And the MySQL readiness check fails twice before succeeding */ + $this->client->withDockerExecuteResponse(output: 'not ready', isSuccessful: false); + $this->client->withDockerExecuteResponse(output: 'not ready', isSuccessful: false); $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @And the CREATE DATABASE command succeeds */ $this->client->withDockerExecuteResponse(output: ''); - /** @And the container is started */ + /** @When the MySQL container is started */ $started = $container->run(); - /** @When stopOnShutdown is called */ - $started->stopOnShutdown(); - - /** @Then the shutdown hook should have registered the remove callback */ - self::assertSame(1, $shutdownHook->getRegistrationCount()); + /** @Then the container should start after retries */ + self::assertSame('retry-db', $started->getName()); } - public function testRemoveDelegatesToUnderlyingContainer(): void + public function testRunMySQLContainerRetriesWhenReadinessCheckThrowsException(): void { - /** @Given a running MySQL container */ - $started = $this->createRunningMySQLContainer( - hostname: 'remove-db', - database: 'test_adm', - port: 3306 - ); - - /** @When remove is called */ - $started->remove(); - - /** @Then the docker rm command should have been executed */ - $commandLines = $this->client->getExecutedCommandLines(); - $removeCommand = $commandLines[4]; - - self::assertStringContainsString(needle: 'docker rm --force --volumes', haystack: $removeCommand); - } - - protected function createRunningMySQLContainer( - string $hostname, - string $database, - ?int $port - ): MySQLContainerStarted { + /** @Given a MySQL container */ $container = TestableMySQLDockerContainer::createWith( + name: 'exception-db', image: 'mysql:8.1', - name: $hostname, client: $this->client ) - ->withDatabase(database: $database) - ->withRootPassword(rootPassword: 'root'); - - $exposedPorts = !is_null($port) ? [sprintf('%d/tcp', $port) => (object)[]] : []; + ->withDatabase(database: 'test_db') + ->withRootPassword(rootPassword: 'root') + ->withReadinessTimeout(timeoutInSeconds: 10); + /** @And the Docker daemon starts the container */ $this->client->withDockerRunResponse(output: InspectResponseFixture::containerId()); $this->client->withDockerInspectResponse( inspectResult: InspectResponseFixture::build( - hostname: $hostname, - environment: [ - sprintf('MYSQL_DATABASE=%s', $database), - 'MYSQL_ROOT_PASSWORD=root' - ], - exposedPorts: $exposedPorts + hostname: 'exception-db', + environment: ['MYSQL_DATABASE=test_db', 'MYSQL_ROOT_PASSWORD=root'] ) ); + /** @And the MySQL readiness check throws an exception first, then succeeds */ + $this->client->withDockerExecuteException( + exception: new DockerCommandExecutionFailed(reason: 'container not running', command: 'docker exec') + ); $this->client->withDockerExecuteResponse(output: 'mysqld is alive'); + + /** @And the CREATE DATABASE command succeeds */ $this->client->withDockerExecuteResponse(output: ''); - return $container->run(); + /** @When the MySQL container is started */ + $started = $container->run(); + + /** @Then the container should start after the exception was caught and retried */ + self::assertSame('exception-db', $started->getName()); } } diff --git a/tests/Unit/RegisteredShutdownHookTest.php b/tests/Unit/RegisteredShutdownHookTest.php new file mode 100644 index 0000000..b3da5eb --- /dev/null +++ b/tests/Unit/RegisteredShutdownHookTest.php @@ -0,0 +1,31 @@ + true; + + /** @And a registered shutdown hook */ + $shutdownHook = new RegisteredShutdownHook(); + + /** @When the callback is registered */ + $shutdownHook->register(callback: $callback); + + /** @Then the callback is captured for shutdown exactly once */ + self::assertSame([$callback], $GLOBALS['registeredShutdownCallbacks']); + } +} diff --git a/tests/Unit/RunningMySQLContainer.php b/tests/Unit/RunningMySQLContainer.php new file mode 100644 index 0000000..6a84d05 --- /dev/null +++ b/tests/Unit/RunningMySQLContainer.php @@ -0,0 +1,52 @@ +withDatabase(database: $database) + ->withRootPassword(rootPassword: 'root'); + + $portTemplate = '%d/tcp'; + $exposedPorts = !is_null($port) ? [sprintf($portTemplate, $port) => (object)[]] : []; + + $databaseTemplate = 'MYSQL_DATABASE=%s'; + + $client->withDockerRunResponse(output: InspectResponseFixture::containerId()); + $client->withDockerInspectResponse( + inspectResult: InspectResponseFixture::build( + hostname: $hostname, + environment: [ + sprintf($databaseTemplate, $database), + 'MYSQL_ROOT_PASSWORD=root' + ], + exposedPorts: $exposedPorts + ) + ); + + $client->withDockerExecuteResponse(output: 'mysqld is alive'); + $client->withDockerExecuteResponse(output: ''); + + return $container->run(); + } +} diff --git a/tests/Unit/Mocks/ShutdownHookMock.php b/tests/Unit/ShutdownHookMock.php similarity index 81% rename from tests/Unit/Mocks/ShutdownHookMock.php rename to tests/Unit/ShutdownHookMock.php index 40a2469..8008c57 100644 --- a/tests/Unit/Mocks/ShutdownHookMock.php +++ b/tests/Unit/ShutdownHookMock.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Test\Unit\Mocks; +namespace Test\Unit; use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; -final class ShutdownHookMock extends ShutdownHook +final class ShutdownHookMock implements ShutdownHook { private int $registrations = 0; @@ -20,4 +20,3 @@ public function getRegistrationCount(): int return $this->registrations; } } - diff --git a/tests/Unit/Mocks/TestableFlywayDockerContainer.php b/tests/Unit/TestableFlywayDockerContainer.php similarity index 58% rename from tests/Unit/Mocks/TestableFlywayDockerContainer.php rename to tests/Unit/TestableFlywayDockerContainer.php index dea2a0a..00ae74d 100644 --- a/tests/Unit/Mocks/TestableFlywayDockerContainer.php +++ b/tests/Unit/TestableFlywayDockerContainer.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Test\Unit\Mocks; +namespace Test\Unit; use TinyBlocks\DockerContainer\FlywayDockerContainer; use TinyBlocks\DockerContainer\Internal\Client\Client; final class TestableFlywayDockerContainer extends FlywayDockerContainer { - public static function createWith(string $image, ?string $name, Client $client): static + public static function createWith(?string $name, string $image, Client $client): self { - $container = TestableGenericDockerContainer::createWith(image: $image, name: $name, client: $client); + $container = TestableGenericDockerContainer::createWith(name: $name, image: $image, client: $client); - return new static(container: $container); + return new self(container: $container); } } diff --git a/tests/Unit/Mocks/TestableGenericDockerContainer.php b/tests/Unit/TestableGenericDockerContainer.php similarity index 76% rename from tests/Unit/Mocks/TestableGenericDockerContainer.php rename to tests/Unit/TestableGenericDockerContainer.php index c733bc9..5654ee0 100644 --- a/tests/Unit/Mocks/TestableGenericDockerContainer.php +++ b/tests/Unit/TestableGenericDockerContainer.php @@ -2,30 +2,31 @@ declare(strict_types=1); -namespace Test\Unit\Mocks; +namespace Test\Unit; use TinyBlocks\DockerContainer\GenericDockerContainer; use TinyBlocks\DockerContainer\Internal\Client\Client; use TinyBlocks\DockerContainer\Internal\CommandHandler\ContainerCommandHandler; use TinyBlocks\DockerContainer\Internal\Containers\ContainerReaper; use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition; +use TinyBlocks\DockerContainer\Internal\Containers\RegisteredShutdownHook; use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; final class TestableGenericDockerContainer extends GenericDockerContainer { public static function createWith( - string $image, ?string $name, + string $image, Client $client, ?ShutdownHook $shutdownHook = null - ): static { + ): self { $definition = ContainerDefinition::create(image: $image, name: $name); $reaper = new ContainerReaper(client: $client); $commandHandler = new ContainerCommandHandler( client: $client, - shutdownHook: $shutdownHook ?? new ShutdownHook() + shutdownHook: $shutdownHook ?? new RegisteredShutdownHook() ); - return new static(reaper: $reaper, definition: $definition, commandHandler: $commandHandler); + return new self(reaper: $reaper, definition: $definition, commandHandler: $commandHandler); } } diff --git a/tests/Unit/Mocks/TestableMySQLDockerContainer.php b/tests/Unit/TestableMySQLDockerContainer.php similarity index 87% rename from tests/Unit/Mocks/TestableMySQLDockerContainer.php rename to tests/Unit/TestableMySQLDockerContainer.php index e2bc29d..0f1cf64 100644 --- a/tests/Unit/Mocks/TestableMySQLDockerContainer.php +++ b/tests/Unit/TestableMySQLDockerContainer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\Unit\Mocks; +namespace Test\Unit; use TinyBlocks\DockerContainer\Internal\Client\Client; use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook; @@ -11,18 +11,18 @@ final class TestableMySQLDockerContainer extends MySQLDockerContainer { public static function createWith( - string $image, ?string $name, + string $image, Client $client, ?ShutdownHook $shutdownHook = null - ): static { + ): self { $container = TestableGenericDockerContainer::createWith( - image: $image, name: $name, + image: $image, client: $client, shutdownHook: $shutdownHook ); - return new static(container: $container); + return new self(container: $container); } } diff --git a/tests/Unit/Waits/ContainerWaitForDependencyTest.php b/tests/Unit/Waits/ContainerWaitForDependencyTest.php index 59a6a75..57220a2 100644 --- a/tests/Unit/Waits/ContainerWaitForDependencyTest.php +++ b/tests/Unit/Waits/ContainerWaitForDependencyTest.php @@ -11,18 +11,23 @@ final class ContainerWaitForDependencyTest extends TestCase { - public function testWaitBeforeWhenConditionIsImmediatelyReady(): void + public function testExceptionWhenWaitTimesOut(): void { - /** @Given a condition that is immediately ready */ - $condition = $this->createMock(ContainerReady::class); - $condition->expects(self::once())->method('isReady')->willReturn(true); + /** @Given a condition that never becomes ready */ + $condition = $this->createStub(ContainerReady::class); + $condition->method('isReady')->willReturn(false); - /** @When waiting for the dependency */ - $wait = ContainerWaitForDependency::untilReady(condition: $condition); - $wait->waitBefore(); + /** @Then a ContainerWaitTimeout exception should be thrown */ + $this->expectException(ContainerWaitTimeout::class); + $this->expectExceptionMessage('Container readiness check timed out after <1> seconds.'); - /** @Then the condition should have been checked exactly once */ - self::assertTrue(true); + /** @When waiting with a short timeout */ + $wait = ContainerWaitForDependency::untilReady( + condition: $condition, + timeoutInSeconds: 1, + pollIntervalInMicroseconds: 50_000 + ); + $wait->waitBefore(); } public function testWaitBeforeRetriesUntilReady(): void @@ -45,25 +50,6 @@ public function testWaitBeforeRetriesUntilReady(): void self::assertTrue(true); } - public function testExceptionWhenWaitTimesOut(): void - { - /** @Given a condition that never becomes ready */ - $condition = $this->createStub(ContainerReady::class); - $condition->method('isReady')->willReturn(false); - - /** @Then a ContainerWaitTimeout exception should be thrown */ - $this->expectException(ContainerWaitTimeout::class); - $this->expectExceptionMessage('Container readiness check timed out after <1> seconds.'); - - /** @When waiting with a short timeout */ - $wait = ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: 1, - pollIntervalInMicroseconds: 50_000 - ); - $wait->waitBefore(); - } - public function testCustomPollIntervalIsRespected(): void { /** @Given a condition that becomes ready after some retries */ @@ -85,8 +71,42 @@ public function testCustomPollIntervalIsRespected(): void $elapsed = microtime(true) - $start; /** @Then the wait should complete quickly (well under 1 second) */ - self::assertLessThan(maximum: 1.0, actual: $elapsed); - self::assertSame(expected: 3, actual: $callCount); + self::assertLessThan(1.0, $elapsed); + self::assertSame(3, $callCount); + } + + public function testWaitBeforeSleepsBetweenReadinessChecks(): void + { + /** @Given a condition that only becomes ready after the configured poll interval elapses */ + $start = microtime(true); + $condition = $this->createStub(ContainerReady::class); + $condition->method('isReady')->willReturnCallback(static function () use ($start): bool { + return microtime(true) - $start >= 0.2; + }); + + /** @When waiting with a timeout that would expire instantly if sleeps were skipped */ + ContainerWaitForDependency::untilReady( + condition: $condition, + timeoutInSeconds: 2, + pollIntervalInMicroseconds: 100_000 + )->waitBefore(); + + /** @Then the wait should have taken at least the poll interval to observe readiness */ + self::assertGreaterThanOrEqual(0.2, microtime(true) - $start); + } + + public function testWaitBeforeWhenConditionIsImmediatelyReady(): void + { + /** @Given a condition that is immediately ready */ + $condition = $this->createMock(ContainerReady::class); + $condition->expects(self::once())->method('isReady')->willReturn(true); + + /** @When waiting for the dependency */ + $wait = ContainerWaitForDependency::untilReady(condition: $condition); + $wait->waitBefore(); + + /** @Then the condition should have been checked exactly once */ + self::assertTrue(true); } public function testWaitBeforeGuaranteesAtLeastOneReadinessCheckEvenWhenTimeoutIsZero(): void @@ -126,24 +146,4 @@ public function testWaitBeforeThrowsWhenSingleAttemptFailsAndConditionIsReadyOnS pollIntervalInMicroseconds: 1 )->waitBefore(); } - - public function testWaitBeforeSleepsBetweenReadinessChecks(): void - { - /** @Given a condition that only becomes ready after the configured poll interval elapses */ - $start = microtime(true); - $condition = $this->createStub(ContainerReady::class); - $condition->method('isReady')->willReturnCallback(static function () use ($start): bool { - return microtime(true) - $start >= 0.2; - }); - - /** @When waiting with a timeout that would expire instantly if sleeps were skipped */ - ContainerWaitForDependency::untilReady( - condition: $condition, - timeoutInSeconds: 2, - pollIntervalInMicroseconds: 100_000 - )->waitBefore(); - - /** @Then the wait should have taken at least the poll interval to observe readiness */ - self::assertGreaterThanOrEqual(minimum: 0.2, actual: microtime(true) - $start); - } } diff --git a/tests/Unit/Waits/ContainerWaitForTimeTest.php b/tests/Unit/Waits/ContainerWaitForTimeTest.php index 299cf75..8e55835 100644 --- a/tests/Unit/Waits/ContainerWaitForTimeTest.php +++ b/tests/Unit/Waits/ContainerWaitForTimeTest.php @@ -5,40 +5,40 @@ namespace Test\Unit\Waits; use PHPUnit\Framework\TestCase; -use TinyBlocks\DockerContainer\Contracts\ContainerStarted; +use TinyBlocks\DockerContainer\ContainerStarted; use TinyBlocks\DockerContainer\Waits\ContainerWaitForTime; final class ContainerWaitForTimeTest extends TestCase { - public function testWaitBeforePausesForSpecifiedDuration(): void + public function testWaitAfterPausesForSpecifiedDuration(): void { /** @Given a wait-for-time of 1 second */ $wait = ContainerWaitForTime::forSeconds(seconds: 1); - /** @When waiting before */ + /** @And a container started stub */ + $containerStarted = $this->createStub(ContainerStarted::class); + + /** @When waiting after */ $start = microtime(true); - $wait->waitBefore(); + $wait->waitAfter(containerStarted: $containerStarted); $elapsed = microtime(true) - $start; /** @Then at least 0.9 seconds should have elapsed */ - self::assertGreaterThanOrEqual(minimum: 0.9, actual: $elapsed); + self::assertGreaterThanOrEqual(0.9, $elapsed); } - public function testWaitAfterPausesForSpecifiedDuration(): void + public function testWaitBeforePausesForSpecifiedDuration(): void { /** @Given a wait-for-time of 1 second */ $wait = ContainerWaitForTime::forSeconds(seconds: 1); - /** @And a container started stub */ - $containerStarted = $this->createStub(ContainerStarted::class); - - /** @When waiting after */ + /** @When waiting before */ $start = microtime(true); - $wait->waitAfter(containerStarted: $containerStarted); + $wait->waitBefore(); $elapsed = microtime(true) - $start; /** @Then at least 0.9 seconds should have elapsed */ - self::assertGreaterThanOrEqual(minimum: 0.9, actual: $elapsed); + self::assertGreaterThanOrEqual(0.9, $elapsed); } public function testWaitForZeroSecondsReturnsImmediately(): void @@ -52,6 +52,6 @@ public function testWaitForZeroSecondsReturnsImmediately(): void $elapsed = microtime(true) - $start; /** @Then the wait should complete almost instantly */ - self::assertLessThan(maximum: 0.1, actual: $elapsed); + self::assertLessThan(0.1, $elapsed); } } From 148ea296ae7af7222f4bc3d3537b9438db4e383d Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 27 Jun 2026 11:06:06 -0300 Subject: [PATCH 2/5] chore: Restructure agent rules, skills, and hooks. --- .claude/CLAUDE.md | 32 - .claude/hooks/php-ordering-conformance.py | 607 ++++++++++ .../php-prose-punctuation-conformance.py | 176 +++ .claude/rules/github-workflows.md | 78 -- .claude/rules/php-library-architecture.md | 147 +++ .claude/rules/php-library-code-style.md | 1020 +++++++++++++++-- .claude/rules/php-library-documentation.md | 217 +++- .claude/rules/php-library-github-workflows.md | 104 ++ .claude/rules/php-library-modeling.md | 353 ++++-- .claude/rules/php-library-testing.md | 382 +++++- .claude/rules/php-library-tooling.md | 138 +++ .claude/settings.json | 249 ++++ .claude/skills/commit-message/SKILL.md | 119 ++ .claude/skills/tiny-blocks-consume/SKILL.md | 68 ++ .../tiny-blocks-consume/references/catalog.md | 32 + .../scripts/refresh-catalog.py | 102 ++ .claude/skills/tiny-blocks-create/SKILL.md | 158 +++ .../assets/config/.editorconfig | 19 + .../assets/config/.gitattributes | 19 + .../assets/config/.gitignore | 28 + .../tiny-blocks-create/assets/config/Makefile | 74 ++ .../assets/config/composer.json | 70 ++ .../assets/config/infection.json.dist | 23 + .../assets/config/phpcs.xml | 7 + .../assets/config/phpstan.neon.dist | 6 + .../assets/config/phpunit.xml | 39 + .../assets/docs/SECURITY.md | 12 + .../github/ISSUE_TEMPLATE/bug_report.md | 29 + .../github/ISSUE_TEMPLATE/feature_request.md | 17 + .../assets/github/PULL_REQUEST_TEMPLATE.md | 16 + .../assets/github/workflows/ci.yml | 105 ++ CLAUDE.md | 61 + 32 files changed, 4099 insertions(+), 408 deletions(-) delete mode 100644 .claude/CLAUDE.md create mode 100644 .claude/hooks/php-ordering-conformance.py create mode 100644 .claude/hooks/php-prose-punctuation-conformance.py delete mode 100644 .claude/rules/github-workflows.md create mode 100644 .claude/rules/php-library-architecture.md create mode 100644 .claude/rules/php-library-github-workflows.md create mode 100644 .claude/rules/php-library-tooling.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/commit-message/SKILL.md create mode 100644 .claude/skills/tiny-blocks-consume/SKILL.md create mode 100644 .claude/skills/tiny-blocks-consume/references/catalog.md create mode 100644 .claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py create mode 100644 .claude/skills/tiny-blocks-create/SKILL.md create mode 100644 .claude/skills/tiny-blocks-create/assets/config/.editorconfig create mode 100644 .claude/skills/tiny-blocks-create/assets/config/.gitattributes create mode 100644 .claude/skills/tiny-blocks-create/assets/config/.gitignore create mode 100644 .claude/skills/tiny-blocks-create/assets/config/Makefile create mode 100644 .claude/skills/tiny-blocks-create/assets/config/composer.json create mode 100644 .claude/skills/tiny-blocks-create/assets/config/infection.json.dist create mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpcs.xml create mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist create mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpunit.xml create mode 100644 .claude/skills/tiny-blocks-create/assets/docs/SECURITY.md create mode 100644 .claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md create mode 100644 .claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml create mode 100644 CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index 11885b0..0000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,32 +0,0 @@ -# Project - -PHP library (tiny-blocks ecosystem). Self-contained package: immutable models, zero infrastructure -dependencies in core, small public surface area. Public API at `src/` root; implementation details -under `src/Internal/`. - -## Rules - -All coding standards, architecture, naming, testing, and documentation conventions -are defined in `rules/`. Read the applicable rule files before generating any code or documentation. - -## Commands - -- `make test` — run tests with coverage. -- `make mutation-test` — run mutation testing (Infection). -- `make review` — run lint. -- `make help` — list all available commands. - -## Post-change validation - -After any code change, run `make review`, `make test`, and `make mutation-test`. -If any fails, iterate on the fix while respecting all project rules until all pass. -Never deliver code that breaks lint, tests, or leaves surviving mutants. - -## File formatting - -Every file produced or modified must: - -- Use **LF** line endings. Never CRLF. -- Have no trailing whitespace on any line. -- End with a single trailing newline. -- Have no consecutive blank lines (max one blank line between blocks). diff --git a/.claude/hooks/php-ordering-conformance.py b/.claude/hooks/php-ordering-conformance.py new file mode 100644 index 0000000..21a3ec0 --- /dev/null +++ b/.claude/hooks/php-ordering-conformance.py @@ -0,0 +1,607 @@ +#!/usr/bin/env python3 +"""PHP ordering conformance hook for tiny-blocks PHP libraries. + +Self-contained PostToolUse hook on Edit|Write|MultiEdit. Verifies the deterministic +ordering conventions for PHP declarations: + +- Parameter ordering: declaration parameters (constructors, factories, methods, + property promotion) in three tiers, required parameters first, then defaulted + parameters, then a variadic, each tier by identifier length ascending, + alphabetical tie-breaker, semantic pairs preserved. A PHPUnit test method fed by + a data provider is exempt, its parameters are the columns of its data set. +- Member ordering: constants, enum cases, constructor, static methods, instance + methods, in that group order, each group length-ascending with alphabetical + tie-breaker. PHPUnit test classes instead order methods as lifecycle hooks (in + execution order), then other methods, then data providers. + +The analysis is pure (FileUnit in, Violation out) and runs in three passes over +well-formed PHP: a lexical pass blanks every comment, string, and heredoc/nowdoc +body (LITERALS), a structural pass maps every bracket to its pair (bracket_spans); +extraction assigns tokens of interest to their containers by flat walks. Control +flow uses guard clauses only and nesting never exceeds two levels. Reports +violations to stderr and exits 2 to prompt Claude, exits 0 silently if no violations +or the file is out of scope. +""" + +import json +import re +import sys +from dataclasses import dataclass +from enum import Enum +from functools import cached_property +from pathlib import Path +from typing import Final + +# --- Configuration ---------------------------------------------------------- + +# In-scope files: PHP sources under src/ or tests/. +SCOPE_PATTERN: Final = re.compile(r"(^|/)(src|tests)/.+\.php$") + +# Semantic pairs (exhaustive). Natural order wins between +# the two members when both appear in the same parameter list. +SEMANTIC_PAIRS: Final = ( + ("start", "end"), + ("from", "to"), + ("startAt", "endAt"), + ("createdAt", "updatedAt"), + ("before", "after"), + ("min", "max"), +) + +# Each member maps to (first, second, position). Both members keep their natural +# order only when both are present, sorting as a unit at the lead member's key. +PAIR_MEMBER: Final = { + member: (first, second, position) + for first, second in SEMANTIC_PAIRS + for position, member in enumerate((first, second)) +} + +MODIFIERS: Final = ("abstract", "final", "private", "protected", "public", "static") + +# The lexical grammar: every PHP construct that must not be scanned as code. +# Alternatives are ordered, the heredoc label closes via backreference. +LITERALS: Final = re.compile( + r""" + /\*.*?\*/ # block comment + | //[^\n]* # line comment + | \#(?!\[)[^\n]* # hash comment, never a #[ attribute + | <<<[ \t]*(?P['"]?)(?P