Skip to content

Commit daf19a4

Browse files
committed
feat: implement container reaper and shutdown hook functionality.
1 parent f6bc5d0 commit daf19a4

24 files changed

Lines changed: 626 additions & 77 deletions

composer.json

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@
2424
"issues": "https://github.com/tiny-blocks/docker-container/issues",
2525
"source": "https://github.com/tiny-blocks/docker-container"
2626
},
27-
"config": {
28-
"sort-packages": true
29-
},
3027
"autoload": {
3128
"psr-4": {
3229
"TinyBlocks\\DockerContainer\\": "src/"
@@ -49,21 +46,30 @@
4946
"phpunit/phpunit": "^11.5",
5047
"phpstan/phpstan": "^2.1",
5148
"dg/bypass-finals": "^1.9",
49+
"infection/infection": "^0.32",
5250
"squizlabs/php_codesniffer": "^4.0"
5351
},
52+
"config": {
53+
"sort-packages": true,
54+
"allow-plugins": {
55+
"infection/extension-installer": true
56+
}
57+
},
5458
"scripts": {
5559
"test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests",
5660
"phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src",
5761
"phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress",
5862
"test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter",
63+
"mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage",
5964
"test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests",
6065
"unit-tests-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --testsuite unit",
6166
"review": [
6267
"@phpcs",
6368
"@phpstan"
6469
],
6570
"tests": [
66-
"@test"
71+
"@test",
72+
"@mutation-test"
6773
],
6874
"tests-no-coverage": [
6975
"@test-no-coverage"

infection.json.dist

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"logs": {
3+
"text": "report/infection/logs/infection-text.log",
4+
"summary": "report/infection/logs/infection-summary.log"
5+
},
6+
"tmpDir": "report/infection/cache/",
7+
"minMsi": 100,
8+
"timeout": 120,
9+
"source": {
10+
"directories": [
11+
"src"
12+
]
13+
},
14+
"phpUnit": {
15+
"configDir": "",
16+
"customPath": "./vendor/bin/phpunit"
17+
},
18+
"mutators": {
19+
"@default": true
20+
},
21+
"minCoveredMsi": 100,
22+
"testFramework": "phpunit",
23+
"testFrameworkOptions": "--testsuite=unit"
24+
}

src/DockerContainer.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterS
3636

3737
/**
3838
* Runs the container only if a container with the same name does not already exist.
39+
* The returned instance treats the container as shared: calling stopOnShutdown() or
40+
* remove() on it has no effect, allowing the container to persist across multiple
41+
* PHP processes (e.g., mutation testing).
3942
*
4043
* @param array<int, string> $commands Commands to execute on container startup.
4144
* @param ContainerWaitAfterStarted|null $waitAfterStarted Optional wait strategy applied after

src/FlywayDockerContainer.php

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,6 @@ public function withConnectRetries(int $retries): static
7878
return $this;
7979
}
8080

81-
public function withValidateMigrationNaming(bool $enabled): static
82-
{
83-
$this->container->withEnvironmentVariable(
84-
key: 'FLYWAY_VALIDATE_MIGRATION_NAMING',
85-
value: $enabled ? 'true' : 'false'
86-
);
87-
88-
return $this;
89-
}
90-
9181
public function cleanAndMigrate(): ContainerStarted
9282
{
9383
return $this->container->run(
@@ -104,6 +94,16 @@ public function withMigrations(string $pathOnHost): static
10494
return $this;
10595
}
10696

97+
public function withValidateMigrationNaming(bool $enabled): static
98+
{
99+
$this->container->withEnvironmentVariable(
100+
key: 'FLYWAY_VALIDATE_MIGRATION_NAMING',
101+
value: $enabled ? 'true' : 'false'
102+
);
103+
104+
return $this;
105+
}
106+
107107
public function withSource(MySQLContainerStarted $container, string $username, string $password): static
108108
{
109109
$schema = $container->getEnvironmentVariables()->getValueBy(key: 'MYSQL_DATABASE');

src/GenericDockerContainer.php

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,41 @@
44

55
namespace TinyBlocks\DockerContainer;
66

7-
use Symfony\Component\Process\Process;
87
use TinyBlocks\DockerContainer\Contracts\ContainerStarted;
98
use TinyBlocks\DockerContainer\Internal\Client\DockerClient;
109
use TinyBlocks\DockerContainer\Internal\CommandHandler\CommandHandler;
1110
use TinyBlocks\DockerContainer\Internal\CommandHandler\ContainerCommandHandler;
1211
use TinyBlocks\DockerContainer\Internal\Commands\DockerPull;
1312
use TinyBlocks\DockerContainer\Internal\Commands\DockerRun;
13+
use TinyBlocks\DockerContainer\Internal\Containers\ContainerReaper;
1414
use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition;
15+
use TinyBlocks\DockerContainer\Internal\Containers\Reused;
16+
use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook;
1517
use TinyBlocks\DockerContainer\Waits\ContainerWaitAfterStarted;
1618
use TinyBlocks\DockerContainer\Waits\ContainerWaitBeforeStarted;
1719

1820
class GenericDockerContainer implements DockerContainer
1921
{
2022
protected ContainerDefinition $definition;
2123

22-
private ?Process $imagePullProcess = null;
23-
2424
private ?ContainerWaitBeforeStarted $waitBeforeStarted = null;
2525

26-
protected function __construct(ContainerDefinition $definition, private CommandHandler $commandHandler)
27-
{
26+
protected function __construct(
27+
private readonly ContainerReaper $reaper,
28+
ContainerDefinition $definition,
29+
private readonly CommandHandler $commandHandler
30+
) {
2831
$this->definition = $definition;
2932
}
3033

3134
public static function from(string $image, ?string $name = null): static
3235
{
36+
$client = new DockerClient();
3337
$definition = ContainerDefinition::create(image: $image, name: $name);
34-
$commandHandler = new ContainerCommandHandler(client: new DockerClient());
38+
$reaper = new ContainerReaper(client: $client);
39+
$commandHandler = new ContainerCommandHandler(client: $client, shutdownHook: new ShutdownHook());
3540

36-
return new static(definition: $definition, commandHandler: $commandHandler);
41+
return new static(reaper: $reaper, definition: $definition, commandHandler: $commandHandler);
3742
}
3843

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

6772
public function pullImage(): static
6873
{
69-
$command = DockerPull::from(image: $this->definition->image->name);
70-
$this->imagePullProcess = Process::fromShellCommandline(command: $command->toCommandLine());
71-
$this->imagePullProcess->start();
74+
$this->commandHandler->execute(command: DockerPull::from(image: $this->definition->image->name));
7275

7376
return $this;
7477
}
@@ -103,22 +106,8 @@ public function withVolumeMapping(string $pathOnHost, string $pathOnContainer):
103106
return $this;
104107
}
105108

106-
public function runIfNotExists(
107-
array $commands = [],
108-
?ContainerWaitAfterStarted $waitAfterStarted = null
109-
): ContainerStarted {
110-
$existing = $this->commandHandler->findBy(definition: $this->definition);
111-
112-
if (!is_null($existing)) {
113-
return $existing;
114-
}
115-
116-
return $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted);
117-
}
118-
119109
public function run(array $commands = [], ?ContainerWaitAfterStarted $waitAfterStarted = null): ContainerStarted
120110
{
121-
$this->imagePullProcess?->wait();
122111
$this->waitBeforeStarted?->waitBefore();
123112

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

129118
return $containerStarted;
130119
}
120+
121+
public function runIfNotExists(
122+
array $commands = [],
123+
?ContainerWaitAfterStarted $waitAfterStarted = null
124+
): ContainerStarted {
125+
$existing = $this->commandHandler->findBy(definition: $this->definition);
126+
127+
if (!is_null($existing)) {
128+
return new Reused(reaper: $this->reaper, containerStarted: $existing);
129+
}
130+
131+
return new Reused(
132+
reaper: $this->reaper,
133+
containerStarted: $this->run(commands: $commands, waitAfterStarted: $waitAfterStarted)
134+
);
135+
}
131136
}

src/Internal/CommandHandler/ContainerCommandHandler.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,16 @@
1616
use TinyBlocks\DockerContainer\Internal\Containers\Definitions\ContainerDefinition;
1717
use TinyBlocks\DockerContainer\Internal\Containers\Definitions\CopyInstruction;
1818
use TinyBlocks\DockerContainer\Internal\Containers\Models\ContainerId;
19+
use TinyBlocks\DockerContainer\Internal\Containers\ShutdownHook;
1920
use TinyBlocks\DockerContainer\Internal\Exceptions\DockerCommandExecutionFailed;
2021

2122
final readonly class ContainerCommandHandler implements CommandHandler
2223
{
2324
private ContainerLookup $lookup;
2425

25-
public function __construct(private Client $client)
26+
public function __construct(private Client $client, ShutdownHook $shutdownHook)
2627
{
27-
$this->lookup = new ContainerLookup(client: $client);
28+
$this->lookup = new ContainerLookup(client: $client, shutdownHook: $shutdownHook);
2829
}
2930

3031
public function execute(Command $command): ExecutionCompleted

src/Internal/Commands/DockerList.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ public static function from(Name $name): DockerList
1919

2020
public function toCommandLine(): string
2121
{
22-
return sprintf('docker ps --all --quiet --filter name=%s', $this->name->value);
22+
return sprintf('docker ps --all --quiet --filter name=^%s$', $this->name->value);
2323
}
2424
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\DockerContainer\Internal\Commands;
6+
7+
final readonly class DockerReaper implements Command
8+
{
9+
private function __construct(
10+
private string $reaperName,
11+
private string $containerName,
12+
private string $testRunnerHostname
13+
) {
14+
}
15+
16+
public static function from(string $reaperName, string $containerName, string $testRunnerHostname): DockerReaper
17+
{
18+
return new DockerReaper(
19+
reaperName: $reaperName,
20+
containerName: $containerName,
21+
testRunnerHostname: $testRunnerHostname
22+
);
23+
}
24+
25+
public function toCommandLine(): string
26+
{
27+
$script = sprintf(
28+
implode(' ', [
29+
'while docker inspect %s >/dev/null 2>&1; do sleep 2; done;',
30+
'docker rm -fv %s 2>/dev/null;',
31+
'docker network prune -f --filter label=%s 2>/dev/null'
32+
]),
33+
$this->testRunnerHostname,
34+
$this->containerName,
35+
DockerRun::MANAGED_LABEL
36+
);
37+
38+
return sprintf(
39+
implode(' ', [
40+
'docker run --rm -d --name %s --label %s',
41+
'-v /var/run/docker.sock:/var/run/docker.sock',
42+
'docker:cli sh -c %s'
43+
]),
44+
$this->reaperName,
45+
DockerRun::MANAGED_LABEL,
46+
escapeshellarg($script)
47+
);
48+
}
49+
}

src/Internal/Containers/ContainerLookup.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
final readonly class ContainerLookup
1616
{
17-
public function __construct(private Client $client)
17+
public function __construct(private Client $client, private ShutdownHook $shutdownHook)
1818
{
1919
}
2020

@@ -38,6 +38,7 @@ public function byId(
3838
id: $id,
3939
name: $definition->name,
4040
address: $inspection->toAddress(),
41+
shutdownHook: $this->shutdownHook,
4142
commandHandler: $commandHandler,
4243
environmentVariables: $inspection->toEnvironmentVariables()
4344
);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TinyBlocks\DockerContainer\Internal\Containers;
6+
7+
use TinyBlocks\DockerContainer\Internal\Client\Client;
8+
use TinyBlocks\DockerContainer\Internal\Commands\DockerList;
9+
use TinyBlocks\DockerContainer\Internal\Commands\DockerReaper;
10+
use TinyBlocks\DockerContainer\Internal\Containers\Models\Name;
11+
12+
final readonly class ContainerReaper
13+
{
14+
public function __construct(private Client $client)
15+
{
16+
}
17+
18+
19+
public function ensureRunningFor(string $containerName): void
20+
{
21+
if (!file_exists('/.dockerenv')) {
22+
return;
23+
}
24+
25+
$reaperName = sprintf('tiny-blocks-reaper-%s', $containerName);
26+
$reaperList = DockerList::from(name: Name::from(value: $reaperName));
27+
$reaperExists = !empty(trim($this->client->execute(command: $reaperList)->getOutput()));
28+
29+
if ($reaperExists) {
30+
return;
31+
}
32+
33+
$this->client->execute(
34+
command: DockerReaper::from(
35+
reaperName: $reaperName,
36+
containerName: $containerName,
37+
testRunnerHostname: gethostname()
38+
)
39+
);
40+
}
41+
}

0 commit comments

Comments
 (0)