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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@
"issues": "https://github.com/tiny-blocks/docker-container/issues",
"source": "https://github.com/tiny-blocks/docker-container"
},
"config": {
"sort-packages": true
},
"autoload": {
"psr-4": {
"TinyBlocks\\DockerContainer\\": "src/"
Expand All @@ -49,21 +46,30 @@
"phpunit/phpunit": "^11.5",
"phpstan/phpstan": "^2.1",
"dg/bypass-finals": "^1.9",
"infection/infection": "^0.32",
"squizlabs/php_codesniffer": "^4.0"
},
"config": {
"sort-packages": true,
"allow-plugins": {
"infection/extension-installer": true
}
},
"scripts": {
"test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
"phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src",
"phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress",
"test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
"mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage",
"test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests",
"unit-tests-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --testsuite unit",
"review": [
"@phpcs",
"@phpstan"
],
"tests": [
"@test"
"@test",
"@mutation-test"
],
Comment on lines 59 to 73
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mutation-test script passes --coverage=report/coverage, but PHPUnit’s XML coverage is configured to be written under report/coverage/coverage-xml (see phpunit.xml). If Infection expects the coverage-XML directory, this path mismatch can cause mutation testing to fail on a clean run. Consider pointing --coverage at report/coverage/coverage-xml (or adjust PHPUnit’s output directory to match).

Copilot uses AI. Check for mistakes.
"tests-no-coverage": [
"@test-no-coverage"
Expand Down
24 changes: 24 additions & 0 deletions infection.json.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"logs": {
"text": "report/infection/logs/infection-text.log",
"summary": "report/infection/logs/infection-summary.log"
},
"tmpDir": "report/infection/cache/",
"minMsi": 100,
"timeout": 120,
"source": {
"directories": [
"src"
]
},
"phpUnit": {
"configDir": "",
"customPath": "./vendor/bin/phpunit"
},
"mutators": {
"@default": true
},
"minCoveredMsi": 100,
"testFramework": "phpunit",
"testFrameworkOptions": "--testsuite=unit"
}
3 changes: 3 additions & 0 deletions src/DockerContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterS

/**
* 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<int, string> $commands Commands to execute on container startup.
* @param ContainerWaitAfterStarted|null $waitAfterStarted Optional wait strategy applied after
Expand Down
20 changes: 10 additions & 10 deletions src/FlywayDockerContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,6 @@ public function withConnectRetries(int $retries): static
return $this;
}

public function withValidateMigrationNaming(bool $enabled): static
{
$this->container->withEnvironmentVariable(
key: 'FLYWAY_VALIDATE_MIGRATION_NAMING',
value: $enabled ? 'true' : 'false'
);

return $this;
}

public function cleanAndMigrate(): ContainerStarted
{
return $this->container->run(
Expand All @@ -104,6 +94,16 @@ public function withMigrations(string $pathOnHost): static
return $this;
}

public function withValidateMigrationNaming(bool $enabled): static
{
$this->container->withEnvironmentVariable(
key: 'FLYWAY_VALIDATE_MIGRATION_NAMING',
value: $enabled ? 'true' : 'false'
);

return $this;
}

public function withSource(MySQLContainerStarted $container, string $username, string $password): static
{
$schema = $container->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE');
Expand Down
53 changes: 29 additions & 24 deletions src/GenericDockerContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,41 @@

namespace TinyBlocks\DockerContainer;

use Symfony\Component\Process\Process;
use TinyBlocks\DockerContainer\Contracts\ContainerStarted;
use TinyBlocks\DockerContainer\Internal\Client\DockerClient;
use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler;
use TinyBlocks\DockerContainer\Internal\CommandHandler\ContainerCommandHandler;
use TinyBlocks\DockerContainer\Internal\Commands\DockerPull;
use TinyBlocks\DockerContainer\Internal\Commands\DockerRun;
use TinyBlocks\DockerContainer\Internal\Containers\ContainerReaper;
use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition;
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 ?Process $imagePullProcess = null;

private ?ContainerWaitBeforeStarted $waitBeforeStarted = null;

protected function __construct(ContainerDefinition $definition, private CommandHandler $commandHandler)
{
protected function __construct(
private readonly ContainerReaper $reaper,
ContainerDefinition $definition,
private readonly CommandHandler $commandHandler
) {
$this->definition = $definition;
}

public static function from(string $image, ?string $name = null): static
{
$client = new DockerClient();
$definition = ContainerDefinition::create(image: $image, name: $name);
$commandHandler = new ContainerCommandHandler(client: new DockerClient());
$reaper = new ContainerReaper(client: $client);
$commandHandler = new ContainerCommandHandler(client: $client, shutdownHook: new ShutdownHook());

return new static(definition: $definition, commandHandler: $commandHandler);
return new static(reaper: $reaper, definition: $definition, commandHandler: $commandHandler);
}

public function withNetwork(string $name): static
Expand Down Expand Up @@ -66,9 +71,7 @@ public function withEnvironmentVariable(string $key, string $value): static

public function pullImage(): static
{
$command = DockerPull::from(image: $this->definition->image->name);
$this->imagePullProcess = Process::fromShellCommandline(command: $command->toCommandLine());
$this->imagePullProcess->start();
$this->commandHandler->execute(command: DockerPull::from(image: $this->definition->image->name));

return $this;
}
Expand Down Expand Up @@ -103,22 +106,8 @@ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer):
return $this;
}

public function runIfNotExists(
array $commands = [],
?ContainerWaitAfterStarted $waitAfterStarted = null
): ContainerStarted {
$existing = $this->commandHandler->findBy(definition: $this->definition);

if (!is_null($existing)) {
return $existing;
}

return $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted);
}

public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted
{
$this->imagePullProcess?->wait();
$this->waitBeforeStarted?->waitBefore();

$dockerRun = DockerRun::from(definition: $this->definition, commands: $commands);
Expand All @@ -128,4 +117,20 @@ public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterS

return $containerStarted;
}

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);
}

return new Reused(
reaper: $this->reaper,
containerStarted: $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted)
);
}
}
5 changes: 3 additions & 2 deletions src/Internal/CommandHandler/ContainerCommandHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@
use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition;
use TinyBlocks\DockerContainer\Internal\Containers\Definitions\CopyInstruction;
use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId;
use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook;
use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed;

final readonly class ContainerCommandHandler implements CommandHandler
{
private ContainerLookup $lookup;

public function __construct(private Client $client)
public function __construct(private Client $client, ShutdownHook $shutdownHook)
{
$this->lookup = new ContainerLookup(client: $client);
$this->lookup = new ContainerLookup(client: $client, shutdownHook: $shutdownHook);
}

public function execute(Command $command): ExecutionCompleted
Expand Down
2 changes: 1 addition & 1 deletion src/Internal/Commands/DockerList.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ public static function from(Name $name): DockerList

public function toCommandLine(): string
{
return sprintf('docker ps --all --quiet --filter name=%s', $this->name->value);
return sprintf('docker ps --all --quiet --filter name=^%s$', $this->name->value);
Comment thread
gustavofreze marked this conversation as resolved.
}
}
49 changes: 49 additions & 0 deletions src/Internal/Commands/DockerReaper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\DockerContainer\Internal\Commands;

final readonly class DockerReaper implements Command
{
private function __construct(
private string $reaperName,
private string $containerName,
private string $testRunnerHostname
) {
}

public static function from(string $reaperName, string $containerName, string $testRunnerHostname): DockerReaper
{
return new DockerReaper(
reaperName: $reaperName,
containerName: $containerName,
testRunnerHostname: $testRunnerHostname
);
}

public function toCommandLine(): string
{
$script = 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
);

return sprintf(
implode(' ', [
'docker run --rm -d --name %s --label %s',
'-v /var/run/docker.sock:/var/run/docker.sock',
'docker:cli sh -c %s'
]),
$this->reaperName,
DockerRun::MANAGED_LABEL,
escapeshellarg($script)
);
Comment on lines +38 to +47
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DockerReaper::toCommandLine() interpolates $reaperName and $containerName directly into a shell command without quoting/escaping. Because container names come from consumer input, this can break the command (or enable shell injection) if the name contains spaces or shell metacharacters. Use escapeshellarg() for dynamic segments (names/labels) or construct the command via an argument array in the client layer to avoid shell interpretation.

Copilot uses AI. Check for mistakes.
}
}
3 changes: 2 additions & 1 deletion src/Internal/Containers/ContainerLookup.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

final readonly class ContainerLookup
{
public function __construct(private Client $client)
public function __construct(private Client $client, private ShutdownHook $shutdownHook)
{
}

Expand All @@ -38,6 +38,7 @@ public function byId(
id: $id,
name: $definition->name,
address: $inspection->toAddress(),
shutdownHook: $this->shutdownHook,
commandHandler: $commandHandler,
environmentVariables: $inspection->toEnvironmentVariables()
);
Expand Down
41 changes: 41 additions & 0 deletions src/Internal/Containers/ContainerReaper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\DockerContainer\Internal\Containers;

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
{
public function __construct(private Client $client)
{
}


public function ensureRunningFor(string $containerName): void
{
Comment thread
gustavofreze marked this conversation as resolved.
if (!file_exists('/.dockerenv')) {
return;
}

$reaperName = sprintf('tiny-blocks-reaper-%s', $containerName);
$reaperList = DockerList::from(name: Name::from(value: $reaperName));
$reaperExists = !empty(trim($this->client->execute(command: $reaperList)->getOutput()));

if ($reaperExists) {
return;
}

$this->client->execute(
command: DockerReaper::from(
reaperName: $reaperName,
containerName: $containerName,
testRunnerHostname: gethostname()
)
);
}
}
Loading
Loading