initial commit

This commit is contained in:
Soner Sayakci
2022-10-22 23:59:29 +00:00
commit be3be06681
21 changed files with 4942 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Container;
use Symfony\Component\Process\Process;
use Testcontainer\Exception\ContainerNotReadyException;
use Testcontainer\Registry;
use Testcontainer\Wait\WaitForNothing;
use Testcontainer\Wait\WaitInterface;
/**
* @phpstan-type ContainerInspect array{0: array{NetworkSettings: array{IPAddress: string}}}
*/
class Container
{
private string $id;
/**
* @var array<string, string>
*/
private array $env = [];
private Process $process;
private WaitInterface $wait;
private ?string $healthCheckCommand = null;
private int $healthCheckIntervalInMS;
/**
* @var ContainerInspect
*/
private array $inspectedData;
/**
* @var array<string>
*/
private array $mounts = [];
public function __construct(private string $image)
{
$this->wait = new WaitForNothing();
}
public static function make(string $image): self
{
return new Container($image);
}
public function withEnvironment(string $name, string $value): self
{
$this->env[$name] = $value;
return $this;
}
public function withImage(string $image): self
{
$this->image = $image;
return $this;
}
public function withWait(WaitInterface $wait): self
{
$this->wait = $wait;
return $this;
}
public function withHealthCheckCommand(string $command, int $healthCheckIntervalInMS = 1000): self
{
$this->healthCheckCommand = $command;
$this->healthCheckIntervalInMS = $healthCheckIntervalInMS;
return $this;
}
public function withMount(string $localPath, string $containerPath): self
{
$this->mounts[] = '-v';
$this->mounts[] = sprintf('%s:%s', $localPath, $containerPath);
return $this;
}
public function run(bool $wait = true): self
{
$this->id = uniqid('testcontainer', true);
$params = [
'docker',
'run',
'--rm',
'--detach',
'--name',
$this->id,
...$this->mounts,
];
foreach ($this->env as $name => $value) {
$params[] = '--env';
$params[] = $name . '=' . $value;
}
if ($this->healthCheckCommand !== null) {
$params[] = '--health-cmd';
$params[] = $this->healthCheckCommand;
$params[] = '--health-interval';
$params[] = $this->healthCheckIntervalInMS . 'ms';
}
$params[] = $this->image;
$this->process = new Process($params);
$this->process->mustRun();
$inspect = new Process(['docker', 'inspect', $this->id]);
$inspect->mustRun();
/** @var ContainerInspect $inspectedData */
$inspectedData = json_decode($inspect->getOutput(), true, 512, JSON_THROW_ON_ERROR);
$this->inspectedData = $inspectedData;
Registry::add($this);
if ($wait) {
$this->wait();
}
return $this;
}
public function wait(int $wait = 100): self
{
for ($i = 0; $i < $wait; $i++) {
try {
$this->wait->wait($this->id);
return $this;
} catch (ContainerNotReadyException $e) {
usleep(500000);
}
}
throw new ContainerNotReadyException($this->id);
}
public function stop(): self
{
$stop = new Process(['docker', 'stop', $this->id]);
$stop->mustRun();
return $this;
}
public function start(): self
{
$start = new Process(['docker', 'start', $this->id]);
$start->mustRun();
return $this;
}
public function restart(): self
{
$restart = new Process(['docker', 'restart', $this->id]);
$restart->mustRun();
return $this;
}
public function remove(): self
{
$remove = new Process(['docker', 'rm', '-f', $this->id]);
$remove->mustRun();
Registry::remove($this);
return $this;
}
public function kill(): self
{
$kill = new Process(['docker', 'kill', $this->id]);
$kill->mustRun();
return $this;
}
/**
* @param array<string> $command
*/
public function execute(array $command): Process
{
$process = new Process(['docker', 'exec', $this->id, ...$command]);
$process->mustRun();
return $process;
}
public function logs(): string
{
$logs = new Process(['docker', 'logs', $this->id]);
$logs->mustRun();
return $logs->getOutput();
}
public function getAddress(): string
{
return $this->inspectedData[0]['NetworkSettings']['IPAddress'];
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Container;
use Testcontainer\Wait\WaitForExec;
class MariaDBContainer extends Container
{
public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root')
{
parent::__construct('mariadb:' . $version);
$this->withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword);
$this->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1']));
}
public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self
{
return new self($version, $mysqlRootPassword);
}
public function withMariaDBUser(string $username, string $password): self
{
$this->withEnvironment('MARIADB_USER', $username);
$this->withEnvironment('MARIADB_PASSWORD', $password);
return $this;
}
public function withMariaDBDatabase(string $database): self
{
$this->withEnvironment('MARIADB_DATABASE', $database);
return $this;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Container;
use Testcontainer\Wait\WaitForExec;
class MySQLContainer extends Container
{
public function __construct(string $version = 'latest', string $mysqlRootPassword = 'root')
{
parent::__construct('mysql:' . $version);
$this->withEnvironment('MYSQL_ROOT_PASSWORD', $mysqlRootPassword);
$this->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1']));
}
public static function make(string $version = 'latest', string $mysqlRootPassword = 'root'): self
{
return new self($version, $mysqlRootPassword);
}
public function withMySQLUser(string $username, string $password): self
{
$this->withEnvironment('MYSQL_USER', $username);
$this->withEnvironment('MYSQL_PASSWORD', $password);
return $this;
}
public function withMySQLDatabase(string $database): self
{
$this->withEnvironment('MYSQL_DATABASE', $database);
return $this;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Container;
use Testcontainer\Wait\WaitForHttp;
class OpenSearchContainer extends Container
{
public function __construct(string $version = 'latest')
{
parent::__construct('opensearchproject/opensearch:' . $version);
$this->withEnvironment('discovery.type', 'single-node');
$this->withWait(WaitForHttp::make(9200));
}
public static function make(string $version = 'latest'): self
{
return new self($version);
}
public function disableSecurityPlugin(): self
{
$this->withEnvironment('plugins.security.disabled', 'true');
return $this;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Container;
use Testcontainer\Wait\WaitForLog;
class RedisContainer extends Container
{
public function __construct(string $version = 'latest')
{
parent::__construct('redis:' . $version);
$this->withWait(new WaitForLog('Ready to accept connections'));
}
public static function make(string $version = 'latest'): self
{
return new self($version);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Exception;
class ContainerNotReadyException extends \RuntimeException
{
public function __construct(string $id, ?\Throwable $previous = null)
{
parent::__construct(sprintf('Container %s is not ready', $id), 0, $previous);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Testcontainer;
use Testcontainer\Container\Container;
class Registry
{
private static bool $registeredCleanup = false;
/**
* @var array<int|string, Container>
*/
private static array $registry = [];
public static function add(Container $container): void
{
self::$registry[spl_object_id($container)] = $container;
if (!self::$registeredCleanup) {
register_shutdown_function([self::class, 'cleanup']);
self::$registeredCleanup = true;
}
}
public static function remove(Container $container): void
{
unset(self::$registry[spl_object_id($container)]);
}
public static function cleanup(): void
{
foreach (self::$registry as $container) {
$container->remove();
}
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Wait;
use Closure;
use Symfony\Component\Process\Process;
use Testcontainer\Exception\ContainerNotReadyException;
class WaitForExec implements WaitInterface
{
/**
* @param array<string> $command
*/
public function __construct(private array $command, private ?Closure $checkFunction = null)
{
}
public function wait(string $id): void
{
$process = new Process(['docker', 'exec', $id, ...$this->command]);
try {
$process->mustRun();
} catch (\Exception $e) {
throw new ContainerNotReadyException($id, $e);
}
if ($this->checkFunction !== null) {
$func = $this->checkFunction;
$func($process);
}
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Wait;
use RuntimeException;
use Symfony\Component\Process\Process;
use Testcontainer\Exception\ContainerNotReadyException;
class WaitForHealthCheck implements WaitInterface
{
public function wait(string $id): void
{
$process = new Process(['docker', 'inspect', '--format', '{{json .State.Health.Status}}', $id]);
$process->mustRun();
$status = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
if (!is_string($status)) {
throw new ContainerNotReadyException($id, new RuntimeException('Invalid json output'));
}
$status = trim($status, '"');
if ($status !== 'healthy') {
throw new ContainerNotReadyException($id);
}
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Wait;
use Symfony\Component\Process\Process;
use Testcontainer\Exception\ContainerNotReadyException;
/**
* @phpstan-import-type ContainerInspect from \Testcontainer\Container\Container
*/
class WaitForHttp implements WaitInterface
{
public const METHOD_GET = 'GET';
public const METHOD_POST = 'POST';
public const METHOD_PUT = 'PUT';
public const METHOD_DELETE = 'DELETE';
public const METHOD_HEAD = 'HEAD';
public const METHOD_OPTIONS = 'OPTIONS';
private string $method = 'GET';
private string $path = '/';
private int $statusCode = 200;
public function __construct(private int $port)
{
}
public static function make(int $port): self
{
return new WaitForHttp($port);
}
/**
* @param WaitForHttp::METHOD_* $method
*/
public function withMethod(string $method): self
{
$this->method = $method;
return $this;
}
public function withPath(string $path): self
{
$this->path = $path;
return $this;
}
public function withStatusCode(int $statusCode): self
{
$this->statusCode = $statusCode;
return $this;
}
public function wait(string $id): void
{
$process = new Process(['docker', 'inspect', $id]);
$process->mustRun();
/** @var ContainerInspect $data */
$data = json_decode($process->getOutput(), true);
$ip = $data[0]['NetworkSettings']['IPAddress'];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d%s', $ip, $this->port, $this->path));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_NOBODY, true);
curl_exec($ch);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== $this->statusCode) {
throw new ContainerNotReadyException($id, new \RuntimeException('HTTP status code does not match'));
}
curl_close($ch);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Wait;
use Symfony\Component\Process\Process;
use Testcontainer\Exception\ContainerNotReadyException;
class WaitForLog implements WaitInterface
{
public function __construct(private string $message, private bool $enableRegex = false)
{
}
public function wait(string $id): void
{
$process = new Process(['docker', 'logs', $id]);
$process->mustRun();
$output = $process->getOutput() . PHP_EOL . $process->getErrorOutput();
if ($this->enableRegex) {
if (!preg_match($this->message, $output)) {
throw new ContainerNotReadyException($id, new \RuntimeException('Message not found in logs'));
}
} else {
if (!str_contains($output, $this->message)) {
throw new ContainerNotReadyException($id, new \RuntimeException('Message not found in logs'));
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Wait;
class WaitForNothing implements WaitInterface
{
public function wait(string $id): void
{
// does nothing
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Testcontainer\Wait;
interface WaitInterface
{
public function wait(string $id): void;
}