diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 23c29bf..4273c5a 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -5,8 +5,6 @@ on: branches: - main pull_request: - branches: - - main permissions: contents: read diff --git a/README.md b/README.md index ab5a7fd..788453f 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,15 @@ composer req --dev testcontainers/testcontainers ```php withEnvironment('name', 'var'); +$container->withEnvironment([ + 'key1' => 'val1', + 'key2' => 'val2' +]); // enable health check for an container $container->withHealthCheckCommand('curl --fail localhost'); @@ -35,10 +38,19 @@ Normally you have to wait until the Container is ready. so for this you can defi ```php +use Testcontainers\Container\GenericContainer; +use Testcontainers\Wait\WaitForExec; +use Testcontainers\Wait\WaitForLog; +use Testcontainers\Wait\WaitForHttp; +use Testcontainers\Wait\WaitForHealthCheck; +use Testcontainers\Wait\WaitForHostPort; + +$container = new GenericContainer('nginx:alpine'); + // Run mysqladmin ping until the command returns exit code 0 $container->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1'])); -$container->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1']), function(Process $process) { +$container->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1']), function($exitCode, $contents) { // throw exception if process result is bad }); @@ -47,7 +59,10 @@ $container->withWait(new WaitForLog('Ready to accept connections')); // Wait for an http request to succeed -$container->withWait(WaitForHttp::make($port, $method = 'GET', $path = '/')); +$container->withWait(new WaitForHttp($port, $method = 'GET', $path = '/')); + +// Wait for all bound ports to be open +$container->withWait(new WaitForHostPort()); // Wait until the docker heartcheck is green $container->withWait(new WaitForHealthCheck()); @@ -58,16 +73,19 @@ $container->withWait(new WaitForHealthCheck()); ```php withMySQLDatabase('foo'); -$container->withMySQLUser('bar', 'baz'); - -$container->run(); +$container = (new MySQLContainer('8.0')) + ->withMySQLDatabase('foo') + ->withMySQLUser('bar', 'baz') + ->start(); $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', $container->getAddress()), + sprintf( + 'mysql:host=%s;port=%d', + $container->getHost(), + $container->getFirstMappedPort() + ), 'bar', 'baz', ); @@ -80,16 +98,19 @@ $pdo = new \PDO( ```php withMariaDBDatabase('foo'); -$container->withMariaDBUser('bar', 'baz'); - -$container->run(); +$container = $container = (new MariaDBContainer()) + ->withMariaDBDatabase('foo') + ->withMariaDBUser('bar', 'baz') + ->start(); $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', $container->getAddress()), + sprintf( + 'mysql:host=%s;port=%d', + $container->getHost(), + $container->getFirstMappedPort() + ), 'bar', 'baz', ); @@ -102,18 +123,21 @@ $pdo = new \PDO( ```php withPostgresDatabase('database'); -$container->withPostgresUser('username'); - -$container->run(); +$container = (new PostgresContainer()) + ->withPostgresUser('bar') + ->withPostgresDatabase('foo') + ->start(); $pdo = new \PDO( - sprintf('pgsql:host=%s;port=5432;dbname=database', $container->getAddress()), - 'username', - 'password', + sprintf( + 'pgsql:host=%s;port=%d;dbname=foo', + self::$container->getHost(), + self::$container->getFirstMappedPort() + ), + 'bar', + 'test', ); // Do something with pdo @@ -123,14 +147,13 @@ $pdo = new \PDO( ```php -use Testcontainers\Container\RedisContainer; +use Testcontainers\Modules\RedisContainer; -$container = RedisContainer::make('6.0'); - -$container->run(); +$container = (new RedisContainer()) + ->start(); $redis = new \Redis(); -$redis->connect($container->getAddress()); +$redis->connect($container->getHost(), $container->getFirstMappedPort()); // Do something with redis ``` @@ -139,12 +162,11 @@ $redis->connect($container->getAddress()); ```php -use Testcontainers\Container\OpenSearchContainer; +use Testcontainers\Modules\OpenSearchContainer; -$container = OpenSearchContainer::make('2'); -$container->disableSecurityPlugin(); - -$container->run(); +$container = (new OpenSearchContainer()) + ->withDisabledSecurityPlugin() + ->start(); // Do something with opensearch ``` @@ -166,7 +188,7 @@ use Doctrine\Bundle\DoctrineBundle\ConnectionFactory; use Doctrine\Common\EventManager; use Doctrine\DBAL\Configuration; use Doctrine\DBAL\Tools\DsnParser; -use Testcontainers\Container\PostgresContainer; +use Testcontainers\Modules\PostgresContainer; class TestConnectionFactory extends ConnectionFactory { @@ -175,11 +197,12 @@ class TestConnectionFactory extends ConnectionFactory public function __construct(array $typesConfig, ?DsnParser $dsnParser = null) { if (!$this::$testDsn) { - $psql = PostgresContainer::make('14.0', 'password'); - $psql->withPostgresDatabase('database'); - $psql->withPostgresUser('user'); - $psql->run(); - $this::$testDsn = sprintf('postgresql://user:password@%s:5432/database?serverVersion=14&charset=utf8', $psql->getAddress()); + $psql = (new PostgresContainer()) + ->withPostgresUser('user') + ->withPostgresPassword('password') + ->withPostgresDatabase('database') + ->start(); + $this::$testDsn = sprintf('postgresql://user:password@%s:%d/database?serverVersion=14&charset=utf8', $psql->getAddress(), $psql->getFirstMappedPort()); } parent::__construct($typesConfig, $dsnParser); } diff --git a/composer.json b/composer.json index dc070bd..4632765 100644 --- a/composer.json +++ b/composer.json @@ -14,12 +14,16 @@ } ], "require": { + "ext-curl": "*", "php": ">= 8.1", - "symfony/process": "^5.0|^6.0|^7.0" + "beluga-php/docker-php": "^1.45" }, "require-dev": { + "ext-pdo": "*", + "ext-pdo_mysql": "*", + "ext-pdo_pgsql": "*", "phpunit/phpunit": "^9.5", - "brianium/paratest": "^6.6", + "brianium/paratest": "^6.11", "friendsofphp/php-cs-fixer": "^3.12", "phpstan/phpstan": "^1.8", "phpstan/phpstan-phpunit": "^1.1", @@ -40,11 +44,12 @@ "integration": "paratest tests/ --bootstrap vendor/autoload.php -f", "cs": "php-cs-fixer fix --dry-run", "cs:fix": "php-cs-fixer fix", - "phpstan": "phpstan analyse" + "phpstan": "phpstan analyse --memory-limit=256M" }, "config": { "allow-plugins": { - "phpstan/extension-installer": true + "phpstan/extension-installer": true, + "php-http/discovery": false } } } diff --git a/src/Container/Container.php b/src/Container/Container.php deleted file mode 100644 index cc8bbec..0000000 --- a/src/Container/Container.php +++ /dev/null @@ -1,309 +0,0 @@ - - * @phpstan-type ContainerInspectMultipleNetworks array}}> - * @phpstan-type ContainerInspect ContainerInspectSingleNetwork|ContainerInspectMultipleNetworks - * @phpstan-type DockerNetwork array{CreatedAt: string, Driver: string, ID: string, IPv6: string, Internal: string, Labels: string, Name: string, Scope: string} - */ -class Container -{ - use DockerContainerAwareTrait; - - private string $id; - - private ?string $entryPoint = null; - - /** - * @var array - */ - private array $env = []; - - private Process $process; - private WaitInterface $wait; - - private ?string $hostname = null; - private bool $privileged = false; - private ?string $network = null; - private ?string $healthCheckCommand = null; - private int $healthCheckIntervalInMS; - - /** - * @var array - */ - private array $cmd = []; - - /** - * @var ContainerInspect - */ - private array $inspectedData; - - /** - * @var array - */ - private array $mounts = []; - - /** - * @var array - */ - private array $ports = []; - - protected function __construct(private string $image) - { - $this->wait = new WaitForNothing(); - } - - public static function make(string $image): self - { - return new Container($image); - } - - public function getId(): string - { - return $this->id; - } - - public function withHostname(string $hostname): self - { - $this->hostname = $hostname; - - return $this; - } - - public function withEntryPoint(string $entryPoint): self - { - $this->entryPoint = $entryPoint; - - return $this; - } - - 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; - } - - /** - * @param array $cmd - */ - public function withCmd(array $cmd): self - { - $this->cmd = $cmd; - - return $this; - } - - public function withMount(string $localPath, string $containerPath): self - { - $this->mounts[] = '-v'; - $this->mounts[] = sprintf('%s:%s', $localPath, $containerPath); - - return $this; - } - - public function withPort(string $localPort, string $containerPort): self - { - $this->ports[] = '-p'; - $this->ports[] = sprintf('%s:%s', $localPort, $containerPort); - - return $this; - } - - public function withPrivileged(bool $privileged = true): self - { - $this->privileged = $privileged; - - return $this; - } - - public function withNetwork(string $network): self - { - $this->network = $network; - - return $this; - } - - public function run(bool $wait = true): self - { - $this->id = uniqid('testcontainer', true); - - $params = [ - 'docker', - 'run', - '--rm', - '--detach', - '--name', - $this->id, - ...$this->mounts, - ...$this->ports, - ]; - - 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'; - } - - if ($this->network !== null) { - $params[] = '--network'; - $params[] = $this->network; - } - - if ($this->hostname !== null) { - $params[] = '--hostname'; - $params[] = $this->hostname; - } - - if ($this->entryPoint !== null) { - $params[] = '--entrypoint'; - $params[] = $this->entryPoint; - } - - if ($this->privileged) { - $params[] = '--privileged'; - } - - $params[] = $this->image; - - if (count($this->cmd) > 0) { - array_push($params, ...$this->cmd); - } - - $this->process = new Process($params); - $this->process->mustRun(); - - $this->inspectedData = self::dockerContainerInspect($this->id); - - 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 $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 self::dockerContainerAddress( - containerId: $this->id, - networkName: $this->network, - inspectedData: $this->inspectedData - ); - } -} diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php new file mode 100644 index 0000000..abdbc5c --- /dev/null +++ b/src/Container/GenericContainer.php @@ -0,0 +1,480 @@ +|null $labels + */ + protected ?array $labels = null; + + protected ?string $hostname = null; + + protected string $id; + + /** @var list */ + protected array $command = []; + + protected ?string $entryPoint = null; + + protected ?HealthConfig $healthConfig = null; + + /** + * @var array + */ + protected array $env = []; + + protected WaitStrategy $waitStrategy; + + protected PortGenerator $portGenerator; + + protected bool $isPrivileged = false; + + protected ?string $networkName = null; + + protected ?string $user = null; + + protected ?string $workingDir = null; + + /** + * @var array + */ + protected array $filesToCopy = []; + + /** + * @var array + */ + protected array $directoriesToCopy = []; + + /** + * @var array + */ + protected array $contentsToCopy = []; + + protected int $startAttempts = 0; + protected const MAX_START_ATTEMPTS = 2; + + /** + * @var array + */ + protected array $mounts = []; + + /** @var array List of exposed ports in the format ['8080/tcp'] */ + protected array $exposedPorts = []; + + public function __construct(string $image) + { + $this->image = $image; + $this->dockerClient = DockerContainerClient::getDockerClient(); + $this->waitStrategy = new WaitForContainer(); + $this->portGenerator = new RandomUniquePortGenerator(); + } + + public function getId(): string + { + return $this->id; + } + + /** + * @param list $command + */ + public function withCommand(array $command): static + { + $this->command = $command; + + return $this; + } + + /** + * @param array $files + */ + public function withCopyFilesToContainer(array $files): static + { + $this->filesToCopy = array_merge($this->filesToCopy, $files); + + return $this; + } + + /** + * @param array $directories + */ + public function withCopyDirectoriesToContainer(array $directories): static + { + $this->directoriesToCopy = array_merge($this->directoriesToCopy, $directories); + + return $this; + } + + /** + * @param array $contents + */ + public function withCopyContentToContainer(array $contents): static + { + $this->contentsToCopy = array_merge($this->contentsToCopy, $contents); + + return $this; + } + + public function withEntryPoint(string $entryPoint): static + { + $this->entryPoint = $entryPoint; + + return $this; + } + + /** + * @param array $env An array of key-value pairs: $object->withEnvironment(['key1' => 'value1', 'key2' => 'value2']); + * @return static Returns itself for chaining purposes. + */ + public function withEnvironment(array $env): static + { + foreach ($env as $key => $val) { + $this->env[$key] = $val; + } + + return $this; + } + + public function withWait(WaitStrategy $waitStrategy): static + { + $this->waitStrategy = $waitStrategy; + + return $this; + } + + public function withHealthCheckCommand( + string $command, + int $intervalInMilliseconds = 1000, + int $timeoutInMilliseconds = 3000, + int $retries = 3, + int $startPeriodInMilliseconds = 0 + ): static { + $this->healthConfig = new HealthConfig(); + $this->healthConfig->setTest(['CMD-SHELL', $command]); + $this->healthConfig->setInterval($intervalInMilliseconds * 1_000_000); + $this->healthConfig->setTimeout($timeoutInMilliseconds * 1_000_000); + $this->healthConfig->setRetries($retries); + $this->healthConfig->setStartPeriod($startPeriodInMilliseconds * 1_000_000); + + return $this; + } + + public function withHostname(string $hostname): static + { + $this->hostname = $hostname; + + return $this; + } + + public function withMount(string $localPath, string $containerPath): static + { + $this->mounts[] = new Mount([ + 'type' => 'bind', + 'source' => $localPath, + 'target' => $containerPath, + ]); + + return $this; + } + + /** + * Add ports to be exposed by the Docker container. + * This method accepts multiple inputs: single port, multiple ports, or ports with specific protocols + * to attempt to align with other language implementations. + * + * @psalm-param int|string|array $ports One or more ports to expose. + * @return static Fluent interface for chaining. + */ + public function withExposedPorts(...$ports): static + { + foreach ($ports as $port) { + if (is_array($port)) { + // Flatten the array and recurse + $this->withExposedPorts(...$port); + } else { + // Handle single port entry, either string or int + $this->exposedPorts[] = PortNormalizer::normalizePort($port); + } + } + + return $this; + } + + public function withName(string $name): static + { + $this->name = $name; + + return $this; + } + + /** + * @param array $labels + */ + public function withLabels(array $labels): static + { + $this->labels = $labels; + + return $this; + } + + public function withPrivilegedMode(bool $privileged = true): static + { + $this->isPrivileged = $privileged; + + return $this; + } + + public function withNetwork(string $networkName): static + { + $this->networkName = $networkName; + + return $this; + } + + public function withPortGenerator(PortGenerator $portGenerator): static + { + $this->portGenerator = $portGenerator; + + return $this; + } + + public function withUser(string $user): static + { + $this->user = $user; + + return $this; + } + + public function withWorkingDir(string $workingDir): static + { + $this->workingDir = $workingDir; + + return $this; + } + + public function start(): StartedGenericContainer + { + $this->startAttempts++; + $containerConfig = $this->createContainerConfig(); + $queryParameters = []; + if ($this->name !== null) { + $queryParameters['name'] = $this->name; + } + try { + /** @var ContainerCreateResponse|null $containerCreateResponse */ + $containerCreateResponse = $this->dockerClient->containerCreate($containerConfig, $queryParameters); + $this->id = $containerCreateResponse?->getId() ?? ''; + } catch (ContainerCreateNotFoundException) { + if ($this->startAttempts >= self::MAX_START_ATTEMPTS) { + throw new RuntimeException("Failed to start container after pulling image."); + } + // If the image is not found, pull it and try again + // TODO: add withPullPolicy support + $this->pullImage(); + return $this->start(); + } + + $this->dockerClient->containerStart($this->id); + + if ($this->filesToCopy !== [] || $this->directoriesToCopy !== [] || $this->contentsToCopy !== []) { + $this->copyToContainer(); + } + + $startedContainer = new StartedGenericContainer($this->id); + $this->waitStrategy->wait($startedContainer); + + return $startedContainer; + } + + /** + * Uploads a tar archive containing files/directories/content to the container, + * extracting it into a chosen directory (`$containerPath`). Allows setting + * Docker's `noOverwriteDirNonDir` and `copyUIDGID` query parameters. + * + * @param string $containerPath Path within the container to extract the tar contents. Must be a directory in the container. + * @param bool $noOverwriteDirNonDir If true, Docker will error if it would replace an existing directory with a non-directory and vice versa. + * @param bool $copyUIDGID If true, Docker will attempt to preserve UID/GID from the tar entries. + * @throws RuntimeException|InvalidArgumentException + */ + protected function copyToContainer( + string $containerPath = '/', + bool $noOverwriteDirNonDir = false, + bool $copyUIDGID = false + ): void { + $tarBuilder = new TarBuilder(); + foreach ($this->filesToCopy as $file) { + $tarBuilder->addFile($file['source'], $file['target'], $file['mode'] ?? null); + } + + foreach ($this->directoriesToCopy as $directory) { + $tarBuilder->addDirectory($directory['source'], $directory['target'], $directory['mode'] ?? null); + } + + foreach ($this->contentsToCopy as $content) { + $tarBuilder->addContent($content['content'], $content['target'], $content['mode'] ?? null); + } + + $tarFilePath = $tarBuilder->buildTarArchive(); + + if (!is_file($tarFilePath)) { + throw new RuntimeException("Tar file does not exist at: $tarFilePath"); + } + + $handle = fopen($tarFilePath, 'rb'); + + if ($handle === false) { + throw new RuntimeException("Cannot open temporary tar archive at: $tarFilePath"); + } + + $queryParams = [ + 'path' => $containerPath, + ]; + + if ($noOverwriteDirNonDir) { + $queryParams['noOverwriteDirNonDir'] = 'true'; + } + + if ($copyUIDGID) { + $queryParams['copyUIDGID'] = 'true'; + } + + /** + * TODO: should be improved. Currently without using dummy $result or FETCH_RESPONSE, the request is failing. + * Probably an issue with the beluga-php/docker-php client library. + * */ + $result = $this->dockerClient->putContainerArchive( + $this->id, + $handle, + $queryParams, + $this->dockerClient::FETCH_RESPONSE + ); + + fclose($handle); + unlink($tarFilePath); + } + + + protected function createContainerConfig(): ContainersCreatePostBody + { + $containerCreatePostBody = new ContainersCreatePostBody(); + $containerCreatePostBody->setImage($this->image); + $containerCreatePostBody->setCmd($this->command); + $containerCreatePostBody->setLabels($this->labels); + $containerCreatePostBody->setHostname($this->hostname); + $containerCreatePostBody->setWorkingDir($this->workingDir); + $containerCreatePostBody->setUser($this->user); + + $envs = array_map(static fn ($key, $value) => "$key=$value", array_keys($this->env), $this->env); + $containerCreatePostBody->setEnv($envs); + + $hostConfig = $this->createHostConfig(); + $containerCreatePostBody->setHostConfig($hostConfig); + + if ($this->entryPoint !== null) { + $containerCreatePostBody->setEntrypoint([$this->entryPoint]); + } + + if ($this->healthConfig !== null) { + $containerCreatePostBody->setHealthcheck($this->healthConfig); + } + + if ($this->networkName !== null) { + $networkingConfig = new NetworkingConfig(); + $endpointsConfig = new \ArrayObject([ + $this->networkName => new EndpointSettings(), + ]); + $networkingConfig->setEndpointsConfig($endpointsConfig); + $containerCreatePostBody->setNetworkingConfig($networkingConfig); + } + + return $containerCreatePostBody; + } + + protected function createHostConfig(): ?HostConfig + { + /** + * For some reason, if some of the properties are not set, but HostConfig is returned, + * the API will throw ContainerCreateBadRequestException: bad parameter. + * Until it will be checked and fixed, we just return null if these properties are not set. + * */ + if ($this->exposedPorts === [] && !$this->isPrivileged && $this->mounts === []) { + return null; + } + + $hostConfig = new HostConfig(); + + if ($this->exposedPorts !== []) { + $portBindings = $this->createPortBindings(); + $hostConfig->setPortBindings($portBindings); + } + + if ($this->isPrivileged) { + $hostConfig->setPrivileged(true); + } + + if ($this->mounts !== []) { + $hostConfig->setMounts($this->mounts); + } + + return $hostConfig; + } + + /** + * @return array> + */ + protected function createPortBindings(): array + { + $portBindings = []; + + foreach ($this->exposedPorts as $port) { + $portBinding = new PortBinding(); + $portBinding->setHostPort((string)$this->portGenerator->generatePort()); + $portBinding->setHostIp('0.0.0.0'); + $portBindings[$port] = [$portBinding]; + } + + return $portBindings; + } + + protected function pullImage(): void + { + [$fromImage, $tag] = explode(':', $this->image) + [1 => 'latest']; + /** @var CreateImageStream $imageCreateResponse */ + $imageCreateResponse = $this->dockerClient->imageCreate(null, [ + 'fromImage' => $fromImage, + 'tag' => $tag, + ]); + $imageCreateResponse->wait(); + } +} diff --git a/src/Container/HttpMethod.php b/src/Container/HttpMethod.php new file mode 100644 index 0000000..4950c16 --- /dev/null +++ b/src/Container/HttpMethod.php @@ -0,0 +1,20 @@ +value); + } + + public static function fromDockerNotation(string $protocol): self + { + return self::from(strtoupper($protocol)); + } +} diff --git a/src/Container/MariaDBContainer.php b/src/Container/MariaDBContainer.php deleted file mode 100644 index 4ff9c4e..0000000 --- a/src/Container/MariaDBContainer.php +++ /dev/null @@ -1,44 +0,0 @@ -withEnvironment('MARIADB_ROOT_PASSWORD', $mysqlRootPassword); - - $binary = 'mysqladmin'; - - if ($version === 'latest' || version_compare($version, '11.0.0', '>')) { - $binary = 'mariadb-admin'; - } - - $this->withWait(new WaitForExec([$binary, '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; - } -} diff --git a/src/Container/MySQLContainer.php b/src/Container/MySQLContainer.php deleted file mode 100644 index 7b1fdbb..0000000 --- a/src/Container/MySQLContainer.php +++ /dev/null @@ -1,37 +0,0 @@ -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; - } -} diff --git a/src/Container/OpenSearchContainer.php b/src/Container/OpenSearchContainer.php deleted file mode 100644 index 783edb3..0000000 --- a/src/Container/OpenSearchContainer.php +++ /dev/null @@ -1,30 +0,0 @@ -withEnvironment('discovery.type', 'single-node'); - $this->withEnvironment('OPENSEARCH_INITIAL_ADMIN_PASSWORD', 'c3o_ZPHo!'); - $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; - } -} diff --git a/src/Container/PostgresContainer.php b/src/Container/PostgresContainer.php deleted file mode 100644 index 46a912f..0000000 --- a/src/Container/PostgresContainer.php +++ /dev/null @@ -1,36 +0,0 @@ -withEnvironment('POSTGRES_PASSWORD', $rootPassword); - $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1"])); - } - - public static function make(string $version = 'latest', string $dbPassword = 'root'): self - { - return new self($version, $dbPassword); - } - - public function withPostgresUser(string $username): self - { - $this->withEnvironment('POSTGRES_USER', $username); - - return $this; - } - - public function withPostgresDatabase(string $database): self - { - $this->withEnvironment('POSTGRES_DB', $database); - - return $this; - } -} diff --git a/src/Container/RedisContainer.php b/src/Container/RedisContainer.php deleted file mode 100644 index a219e57..0000000 --- a/src/Container/RedisContainer.php +++ /dev/null @@ -1,21 +0,0 @@ -withWait(new WaitForLog('Ready to accept connections')); - } - - public static function make(string $version = 'latest'): self - { - return new self($version); - } -} diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php new file mode 100644 index 0000000..d3a8194 --- /dev/null +++ b/src/Container/StartedGenericContainer.php @@ -0,0 +1,214 @@ +dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); + } + + public function getId(): string + { + return $this->id; + } + + public function getLastExecId(): ?string + { + return $this->lastExecId; + } + + public function getClient(): Docker + { + return $this->dockerClient; + } + + /** + * @param list $command + */ + public function exec(array $command): string + { + $execConfig = (new ContainersIdExecPostBody()) + ->setCmd($command) + ->setAttachStdout(true) + ->setAttachStderr(true); + + // Create and start the exec command + /** @var IdResponse | null $exec */ + $exec = $this->dockerClient->containerExec($this->id, $execConfig); + + if ($exec === null || $exec->getId() === null) { + throw new RuntimeException('Failed to create exec command'); + } + + $this->lastExecId = $exec->getId(); + + $contents = $this->dockerClient + ->execStart($this->lastExecId, null, Client::FETCH_RESPONSE) + ?->getBody() + ->getContents() ?? ''; + + return $this->sanitizeOutput($contents); + } + + public function stop(): StoppedTestContainer + { + $this->dockerClient->containerStop($this->id); + $this->dockerClient->containerDelete($this->id); + + return new StoppedGenericContainer($this->id); + } + + public function restart(): self + { + $this->dockerClient->containerRestart($this->id); + + return $this; + } + + public function logs(): string + { + $output = $this->dockerClient + ->containerLogs( + $this->id, + ['stdout' => true, 'stderr' => true], + DockerRuntimeClient::FETCH_RESPONSE + ) + ?->getBody() + ->getContents() ?? ''; + + return $this->sanitizeOutput(mb_convert_encoding($output, 'UTF-8', 'UTF-8')); + } + + public function getHost(): string + { + return (new HostResolver($this->dockerClient))->resolveHost(); + } + + public function getMappedPort(int $port): int + { + $ports = (array) $this->getBoundPorts(); + /** @var PortBinding | null $portBinding */ + $portBinding = $ports["{$port}/tcp"][0] ?? null; + $mappedPort = $portBinding?->getHostPort(); + if ($mappedPort !== null) { + return (int) $mappedPort; + } + + throw new RuntimeException("Failed to get mapped port ‘{$mappedPort}’ for container"); + } + + public function getFirstMappedPort(): int + { + $ports = (array) $this->getBoundPorts(); + $port = array_key_first($ports); + /** @var PortBinding | null $firstPortBinding */ + $firstPortBinding = $ports[$port][0] ?? null; + $firstMappedPort = $firstPortBinding?->getHostPort(); + if ($firstMappedPort !== null) { + return (int) $firstMappedPort; + } + + throw new RuntimeException('Failed to get first mapped port for container'); + } + + public function getName(): string + { + return trim($this->inspect()?->getName() ?? '', '/ '); + } + + /** + * @return array + */ + public function getLabels(): array + { + return (array) $this->inspect()?->getConfig()?->getLabels(); + } + + /** + * @return string[] + */ + public function getNetworkNames(): array + { + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); + return array_keys($networks); + } + + public function getNetworkId(string $networkName): string + { + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); + /** @var EndpointSettings | null $endpointSettings */ + $endpointSettings = $networks[$networkName] ?? null; + $networkID = $endpointSettings?->getNetworkID(); + if ($networkID !== null) { + return $networkID; + } + + throw new RuntimeException("Network with name ‘{$networkName}’ does not exist"); + } + + public function getIpAddress(string $networkName): string + { + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); + /** @var EndpointSettings | null $endpointSettings */ + $endpointSettings = $networks[$networkName] ?? null; + $ipAddress = $endpointSettings?->getIPAddress(); + if ($ipAddress !== null) { + return $ipAddress; + } + + throw new RuntimeException("Network with name ‘{$networkName}’ does not exist"); + } + + protected function inspect(): ContainersIdJsonGetResponse200 | null + { + if ($this->inspectResponse === null) { + /** @var ContainersIdJsonGetResponse200 | null $inspectResponse */ + $inspectResponse = $this->dockerClient->containerInspect($this->id); + $this->inspectResponse = $inspectResponse; + } + + return $this->inspectResponse; + } + + /** + * @return iterable> + * @throws RuntimeException + */ + public function getBoundPorts(): iterable + { + $ports = $this->inspect()?->getNetworkSettings()?->getPorts(); + + if ($ports === null) { + throw new RuntimeException('Failed to get ports from container'); + } + + return $ports; + } + + protected function sanitizeOutput(string $output): string + { + return preg_replace('/[\x00-\x1F\x7F]/u', '', $output) ?? ''; + } +} diff --git a/src/Container/StartedTestContainer.php b/src/Container/StartedTestContainer.php new file mode 100644 index 0000000..551d9be --- /dev/null +++ b/src/Container/StartedTestContainer.php @@ -0,0 +1,55 @@ + $command + */ + public function exec(array $command): string; + + /** + * @return iterable> + */ + public function getBoundPorts(): iterable; + + public function getClient(): Docker; + + public function getFirstMappedPort(): int; + + public function getHost(): string; + + public function getId(): string; + + public function getIpAddress(string $networkName): string; + + /** + * @return array + */ + public function getLabels(): array; + + public function logs(): string; + + public function getLastExecId(): string | null; + + public function getMappedPort(int $port): int; + + public function getName(): string; + + public function getNetworkId(string $networkName): string; + + /** + * @return string[] + */ + public function getNetworkNames(): array; + + public function restart(): self; + + public function stop(): StoppedTestContainer; +} diff --git a/src/Container/StoppedGenericContainer.php b/src/Container/StoppedGenericContainer.php new file mode 100644 index 0000000..f226c8c --- /dev/null +++ b/src/Container/StoppedGenericContainer.php @@ -0,0 +1,17 @@ +id; + } +} diff --git a/src/Container/StoppedTestContainer.php b/src/Container/StoppedTestContainer.php new file mode 100644 index 0000000..423c230 --- /dev/null +++ b/src/Container/StoppedTestContainer.php @@ -0,0 +1,10 @@ + $command + */ + public function withCommand(array $command): static; + + public function withEntrypoint(string $entryPoint): static; + + /** + * @param array $env An array of key-value pairs + */ + public function withEnvironment(array $env): static; + + /** @param int|string|array $ports One or more ports to expose. */ + public function withExposedPorts(...$ports): static; + + public function withHealthCheckCommand( + string $command, + int $intervalInMilliseconds, + int $timeoutInMilliseconds, + int $retries, + int $startPeriodInMilliseconds + ): static; + + public function withHostname(string $hostname): static; + + /** + * @param array $labels + */ + public function withLabels(array $labels): static; + + public function withMount(string $localPath, string $containerPath): static; + + public function withName(string $name): static; + + public function withNetwork(string $networkName): static; + + public function withPortGenerator(PortGenerator $portGenerator): static; + + public function withPrivilegedMode(bool $privileged): static; + + public function withWait(WaitStrategy $waitStrategy): static; +} diff --git a/src/ContainerClient/DockerContainerClient.php b/src/ContainerClient/DockerContainerClient.php new file mode 100644 index 0000000..8001992 --- /dev/null +++ b/src/ContainerClient/DockerContainerClient.php @@ -0,0 +1,44 @@ +containerId = $containerId; + parent::__construct($message, 0, $previous); + } + + public function getContainerId(): string + { + return $this->containerId; + } +} diff --git a/src/Exception/ContainerNotReadyException.php b/src/Exception/ContainerNotReadyException.php index 1b5f677..200983f 100644 --- a/src/Exception/ContainerNotReadyException.php +++ b/src/Exception/ContainerNotReadyException.php @@ -4,10 +4,6 @@ declare(strict_types=1); namespace Testcontainers\Exception; -class ContainerNotReadyException extends \RuntimeException +class ContainerNotReadyException extends ContainerException { - public function __construct(string $id, ?\Throwable $previous = null) - { - parent::__construct(sprintf('Container %s is not ready', $id), 0, $previous); - } } diff --git a/src/Exception/ContainerStateException.php b/src/Exception/ContainerStateException.php new file mode 100644 index 0000000..c91cb07 --- /dev/null +++ b/src/Exception/ContainerStateException.php @@ -0,0 +1,14 @@ +withExposedPorts(3306); + $this->withEnvironment(['MARIADB_ROOT_PASSWORD' => $mysqlRootPassword]); + $this->withWait(new WaitForExec([ + "mariadb-admin", + "ping", + "-h", "127.0.0.1", + ], null, 15000)); + } + + 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; + } +} diff --git a/src/Modules/MySQLContainer.php b/src/Modules/MySQLContainer.php new file mode 100644 index 0000000..580dfd8 --- /dev/null +++ b/src/Modules/MySQLContainer.php @@ -0,0 +1,38 @@ +withExposedPorts(3306); + $this->withEnvironment(['MYSQL_ROOT_PASSWORD' => $mysqlRootPassword]); + $this->withWait(new WaitForExec([ + "mysqladmin", + "ping", + "-h", "127.0.0.1", + ], null, 15000)); + } + + 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; + } +} diff --git a/src/Modules/OpenSearchContainer.php b/src/Modules/OpenSearchContainer.php new file mode 100644 index 0000000..57b1f94 --- /dev/null +++ b/src/Modules/OpenSearchContainer.php @@ -0,0 +1,34 @@ +withExposedPorts(9200); + $this->withEnvironment([ + 'discovery.type' => 'single-node', + 'OPENSEARCH_INITIAL_ADMIN_PASSWORD' => 'c3o_ZPHo!', + ]); + + $this->withWait(new WaitForLog( + '/\]\s+started\?\[/', + true, + 30000 + )); + } + + public function withDisabledSecurityPlugin(): self + { + $this->withEnvironment(['plugins.security.disabled' => 'true']); + + return $this; + } +} diff --git a/src/Modules/PostgresContainer.php b/src/Modules/PostgresContainer.php new file mode 100644 index 0000000..6b5e43f --- /dev/null +++ b/src/Modules/PostgresContainer.php @@ -0,0 +1,48 @@ +withExposedPorts(5432); + $this->withEnvironment([ + 'POSTGRES_USER' => $this->username, + 'POSTGRES_PASSWORD' => $this->password, + 'POSTGRES_DB' => $this->database, + ]); + $this->withWait(new WaitForExec(["pg_isready", "-h", "127.0.0.1", "-U", $this->username])); + } + + public function withPostgresUser(string $username): self + { + $this->withEnvironment(['POSTGRES_USER' => $username]); + + return $this; + } + + public function withPostgresPassword(string $password): self + { + $this->withEnvironment(['POSTGRES_PASSWORD' => $password]); + + return $this; + } + + public function withPostgresDatabase(string $database): self + { + $this->withEnvironment(['POSTGRES_DB' => $database]); + + return $this; + } +} diff --git a/src/Modules/RedisContainer.php b/src/Modules/RedisContainer.php new file mode 100644 index 0000000..e40f068 --- /dev/null +++ b/src/Modules/RedisContainer.php @@ -0,0 +1,18 @@ +withExposedPorts(6379); + $this->withWait(new WaitForLog('Ready to accept connections')); + } +} diff --git a/src/Registry.php b/src/Registry.php deleted file mode 100644 index 0d1d95a..0000000 --- a/src/Registry.php +++ /dev/null @@ -1,39 +0,0 @@ - - */ - 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(); - } - } -} diff --git a/src/Trait/DockerContainerAwareTrait.php b/src/Trait/DockerContainerAwareTrait.php deleted file mode 100644 index 79323bc..0000000 --- a/src/Trait/DockerContainerAwareTrait.php +++ /dev/null @@ -1,108 +0,0 @@ -mustRun(); - - /** @var ContainerInspect */ - return json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); - } - - /** - * @param string $networkName - * @return DockerNetwork|false - * - * @throws JsonException - */ - private static function dockerNetworkFind(string $networkName): array|false - { - $process = new Process(['docker', 'network', 'ls', '--format', 'json', '--filter', 'name=' . $networkName]); - $process->mustRun(); - - $json = $process->getOutput(); - - if ($json === '') { - return false; - } - - $json = str_replace("\n", ',', $json); - $json = '['. rtrim($json, ',') .']'; - - /** @var array $output */ - $output = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - - /** @var array $matchingNetworks */ - $matchingNetworks = array_filter($output, static fn (array $network) => $network['Name'] === $networkName); - - if (count($matchingNetworks) === 0) { - return false; - } - - return $matchingNetworks[0]; - } - - private static function dockerNetworkCreate(string $networkName, string $driver = 'bridge'): void - { - $process = new Process(['docker', 'network', 'create', '--driver', $driver, $networkName]); - $process->mustRun(); - } - - private static function dockerNetworkRemove(string $networkName): void - { - $process = new Process(['docker', 'network', 'rm', $networkName, '-f']); - $process->mustRun(); - } -} diff --git a/src/Utils/HostResolver.php b/src/Utils/HostResolver.php new file mode 100644 index 0000000..6c74dc2 --- /dev/null +++ b/src/Utils/HostResolver.php @@ -0,0 +1,138 @@ +dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); + } + + /** + * Resolves the host address for connecting to a container. + * + * The resolution process is as follows: + * 1. If user overrides are allowed and TESTCONTAINERS_HOST_OVERRIDE is set, its value is returned. + * 2. Otherwise, the DOCKER_HOST environment variable is parsed. + * - If the scheme is one of http, https, or tcp, the hostname is used. + * - If the scheme is unix or npipe and the process is running in a container, the network gateway + * is determined by inspecting the relevant Docker network or running a temporary container. + * 3. If no other value can be determined, "localhost" is returned. + * + * @return string + * @throws RuntimeException If the DOCKER_HOST scheme is unsupported. + */ + public function resolveHost(): string + { + if ($this->allowUserOverrides() && ($override = getenv('TESTCONTAINERS_HOST_OVERRIDE')) !== false) { + return $override; + } + + // Get DOCKER_HOST URI, defaulting to a TCP endpoint if not set. + $dockerHostUri = getenv('DOCKER_HOST') ?: 'tcp://127.0.0.1:2375'; + $parts = parse_url($dockerHostUri); + if ($parts === false || !isset($parts['scheme'])) { + return 'localhost'; + } + + $scheme = $parts['scheme']; + + switch ($scheme) { + case 'http': + case 'https': + case 'tcp': + return $parts['host'] ?? 'localhost'; + + case 'unix': + case 'npipe': + if ($this->isInContainer()) { + // If using podman, choose "podman" network; otherwise, use "bridge" + $networkName = (str_contains($dockerHostUri, 'podman.sock')) ? 'podman' : 'bridge'; + if ($gateway = $this->findGateway($networkName)) { + return $gateway; + } + if ($defaultGateway = $this->findDefaultGateway()) { + return $defaultGateway; + } + } + return 'localhost'; + + default: + throw new RuntimeException("Unsupported Docker host scheme: {$scheme}"); + } + } + + protected function allowUserOverrides(): bool + { + return true; + } + + /** + * Determines if the code is running inside a container. + */ + protected function isInContainer(): bool + { + return file_exists('/.dockerenv'); + } + + /** + * Inspects the given network and returns its gateway IP address if found. + * + * @param string $networkName + * @return string|null + */ + protected function findGateway(string $networkName): ?string + { + try { + /** @var Network|null $networkInspect */ + $networkInspect = $this->dockerClient?->networkInspect($networkName); + $ipamConfig = $networkInspect?->getIPAM()?->getConfig(); + if ($ipamConfig !== null) { + foreach ($ipamConfig as $config) { + if ($config->getGateway() !== null) { + return $config->getGateway(); + } + } + } + } catch (\Throwable) { + return null; + } + return null; + } + + /** + * Runs a temporary container to determine the default gateway. + */ + protected function findDefaultGateway(): ?string + { + $tmpContainer = null; + try { + // Create a temporary container using a lightweight Alpine image. + $tmpContainer = (new GenericContainer('alpine:3.14')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + $result = $tmpContainer->exec(['sh', '-c', "ip route | awk '/default/ { print $3 }'"]); + $tmpContainer->stop(); + return $result; + } catch (\Throwable) { + return null; + } finally { + if ($tmpContainer !== null) { + try { + $tmpContainer->stop(); + } catch (\Throwable) { + // + } + } + } + } +} diff --git a/src/Utils/PortGenerator/FixedPortGenerator.php b/src/Utils/PortGenerator/FixedPortGenerator.php new file mode 100644 index 0000000..9afe0b7 --- /dev/null +++ b/src/Utils/PortGenerator/FixedPortGenerator.php @@ -0,0 +1,25 @@ +ports[$this->portIndex])) { + throw new \RuntimeException('No more ports available in the fixed list.'); + } + + return $this->ports[$this->portIndex++]; + } +} diff --git a/src/Utils/PortGenerator/PortGenerator.php b/src/Utils/PortGenerator/PortGenerator.php new file mode 100644 index 0000000..df7da1a --- /dev/null +++ b/src/Utils/PortGenerator/PortGenerator.php @@ -0,0 +1,10 @@ +getRandomPort($this->randomBetweenInclusive(10000, 65535)); + } + + private function randomBetweenInclusive(int $min, int $max): int + { + return random_int($min, $max); + } + + private function getRandomPort(int $port): int + { + $connection = @fsockopen("localhost", $port); + + if (is_resource($connection)) { + fclose($connection); + throw new \RuntimeException("Port $port is already in use."); + } + + return $port; + } +} diff --git a/src/Utils/PortGenerator/RandomUniquePortGenerator.php b/src/Utils/PortGenerator/RandomUniquePortGenerator.php new file mode 100644 index 0000000..f405e25 --- /dev/null +++ b/src/Utils/PortGenerator/RandomUniquePortGenerator.php @@ -0,0 +1,26 @@ +portGenerator->generatePort(); + } while (in_array($port, self::$assignedPorts)); + + self::$assignedPorts[] = $port; + + return $port; + } +} diff --git a/src/Utils/PortNormalizer.php b/src/Utils/PortNormalizer.php new file mode 100644 index 0000000..d9cded5 --- /dev/null +++ b/src/Utils/PortNormalizer.php @@ -0,0 +1,32 @@ +toDockerNotation()}"; + } + + // Check if the port specification already includes a protocol + if (is_string($port) && !str_contains($port, '/')) { + return "{$port}/{$internetProtocol->toDockerNotation()}"; + } + + return $port; + } +} diff --git a/src/Utils/TarBuilder.php b/src/Utils/TarBuilder.php new file mode 100644 index 0000000..40c5e37 --- /dev/null +++ b/src/Utils/TarBuilder.php @@ -0,0 +1,300 @@ + + */ + private array $files = []; + + /** + * @var array + */ + private array $directories = []; + + /** + * @var array + */ + private array $contents = []; + + /** + * Add a single file from the local filesystem. + */ + public function addFile(string $source, string $target, ?int $mode = null): self + { + if (!is_file($source)) { + throw new InvalidArgumentException("Invalid file path: {$source}"); + } + if (empty($target)) { + throw new InvalidArgumentException("Target path cannot be empty."); + } + if ($mode !== null && ($mode < 0 || $mode > 0o777)) { + throw new InvalidArgumentException("Invalid mode for file: {$mode}"); + } + $this->files[] = [ + 'source' => $source, + 'target' => $target, + 'mode' => $mode, + ]; + return $this; + } + + /** + * Add a directory (recursively) from the local filesystem. + */ + public function addDirectory(string $source, string $target, ?int $mode = null): self + { + $this->directories[] = [ + 'source' => $source, + 'target' => $target, + 'mode' => $mode, + ]; + return $this; + } + + /** + * Add inline string content that should become a file in the tar. + */ + public function addContent(string $content, string $target, ?int $mode = null): self + { + $this->contents[] = [ + 'content' => $content, + 'target' => $target, + 'mode' => $mode, + ]; + return $this; + } + + /** + * Builds the .tar archive from everything that was added (files, directories, contents). + * + * Returns the full path to the created .tar file. + */ + public function buildTarArchive(): string + { + $tempDir = $this->createTempDir(); + + $this->copyFilesToLocalDir($tempDir, $this->files); + $this->copyDirectoriesToLocalDir($tempDir, $this->directories); + $this->createFilesFromContent($tempDir, $this->contents); + + $tarFilePath = $this->createTempTarPath(); + $this->runTarCommand($tarFilePath, $tempDir); + $this->removeDirectoryRecursively($tempDir); + + return $tarFilePath; + } + + public function clear(): void + { + $this->files = []; + $this->directories = []; + $this->contents = []; + } + + private function createTempDir(): string + { + $tmpDirName = tempnam(sys_get_temp_dir(), 'tc_files_'); + if ($tmpDirName === false) { + throw new RuntimeException("Failed to create a temp file for tar data"); + } + // tempnam() creates a file; remove it and create directory instead + unlink($tmpDirName); + + if (!mkdir($tmpDirName) && !is_dir($tmpDirName)) { + throw new RuntimeException("Failed to create temp directory: {$tmpDirName}"); + } + + return $tmpDirName; + } + + private function createTempTarPath(): string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'tc_tar_'); + + if ($tmpFile === false) { + throw new RuntimeException("Failed to create temp file for tar archive"); + } + + $tarFilePath = $tmpFile . '.tar'; + + if (!rename($tmpFile, $tarFilePath)) { + throw new RuntimeException("Failed renaming temp file to .tar"); + } + return $tarFilePath; + } + + private function runTarCommand(string $tarFilePath, string $sourceDir): void + { + if (PHP_OS_FAMILY === 'Darwin') { + $additionalFlags = ' --disable-copyfile --no-xattrs'; + } else { + $additionalFlags = ''; + } + + // without --disable-copyfile and --no-xattrs combination, tar will fail on macOS + $cmd = sprintf( + 'tar %s -cf %s -C %s . 2>&1', + $additionalFlags, + escapeshellarg($tarFilePath), + escapeshellarg($sourceDir) + ); + + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $errorText = implode("\n", $output); + throw new RuntimeException("Failed to create tar archive:\n{$errorText}"); + } + } + + private function removeDirectoryRecursively(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + $path = $item->getRealPath(); + if ($item->isDir()) { + rmdir($path); + } else { + unlink($path); + } + } + rmdir($dir); + } + + /** + * @param array $files + */ + private function copyFilesToLocalDir(string $tempDir, array $files): void + { + foreach ($files as $file) { + $source = $file['source']; + $target = $file['target']; + $mode = $file['mode'] ?? null; + + if (!is_file($source)) { + throw new InvalidArgumentException("File not found: $source"); + } + $destPath = $this->makeDestPath($tempDir, $target); + $this->ensureParentDir($destPath); + + if (!copy($source, $destPath)) { + throw new RuntimeException("Failed to copy file $source to $destPath"); + } + if ($mode !== null) { + chmod($destPath, $mode); + } + } + } + + /** + * @param array $directories + */ + private function copyDirectoriesToLocalDir(string $tempDir, array $directories): void + { + foreach ($directories as $dir) { + $source = $dir['source']; + $target = $dir['target']; + $mode = $dir['mode'] ?? null; + + if (!is_dir($source)) { + throw new InvalidArgumentException("Directory not found: $source"); + } + $destPath = $this->makeDestPath($tempDir, $target); + $this->copyDirectoryRecursively($source, $destPath); + + if ($mode !== null) { + chmod($destPath, $mode); + } + } + } + + /** + * @param array $contents + */ + private function createFilesFromContent(string $tempDir, array $contents): void + { + foreach ($contents as $content) { + $data = $content['content']; + $target = $content['target']; + $mode = $content['mode'] ?? null; + + $destPath = $this->makeDestPath($tempDir, $target); + $this->ensureParentDir($destPath); + + file_put_contents($destPath, $data); + if ($mode !== null) { + chmod($destPath, $mode); + } + } + } + + private function copyDirectoryRecursively(string $sourceDir, string $destDir): void + { + $this->ensureParentDir($destDir); + + $innerIterator = new RecursiveDirectoryIterator($sourceDir, \FilesystemIterator::SKIP_DOTS); + + /** @var RecursiveIteratorIterator $iterator */ + $iterator = new RecursiveIteratorIterator( + $innerIterator, + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + + /** @var RecursiveDirectoryIterator $innerIterator */ + $innerIterator = $iterator->getInnerIterator(); + $subPathName = $innerIterator->getSubPathName(); + $targetPath = $destDir . '/' . $subPathName; + + // Ensure the parent directory for the target path exists + $this->ensureParentDir($targetPath); + + if ($item->isDir()) { + if (!mkdir($targetPath, 0o777, true) && !is_dir($targetPath)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $targetPath)); + } + } else { + copy($item->getPathname(), $targetPath); + } + } + } + + private function makeDestPath(string $tempDir, string $target): string + { + return rtrim($tempDir, '/') . '/' . ltrim($target, '/'); + } + + private function ensureParentDir(string $path): void + { + $parent = dirname($path); + if (!is_dir($parent) && !mkdir($parent, 0o777, true) && !is_dir($parent)) { + throw new RuntimeException("Failed to create parent directory: $parent"); + } + } +} diff --git a/src/Wait/BaseWaitStrategy.php b/src/Wait/BaseWaitStrategy.php new file mode 100644 index 0000000..df4da26 --- /dev/null +++ b/src/Wait/BaseWaitStrategy.php @@ -0,0 +1,28 @@ +timeout = $timeout; + return $this; + } + + public function withPollInterval(int $pollInterval): static + { + $this->pollInterval = $pollInterval; + return $this; + } +} diff --git a/src/Wait/WaitForContainer.php b/src/Wait/WaitForContainer.php new file mode 100644 index 0000000..4fb713e --- /dev/null +++ b/src/Wait/WaitForContainer.php @@ -0,0 +1,40 @@ +getId(); + $startTime = microtime(true) * 1000; + + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; + + if ($elapsedTime > $this->timeout) { + throw new ContainerNotReadyException($id); + } + + /** @var ContainersIdJsonGetResponse200 | null $containerInspect */ + $containerInspect = $container->getClient()->containerInspect($id); + $containerStatus = $containerInspect?->getState()?->getStatus(); + + if ($containerStatus === 'running') { + return; + } + + usleep($this->pollInterval * 1000); + } + } +} diff --git a/src/Wait/WaitForExec.php b/src/Wait/WaitForExec.php index 0c5c5c9..ce7af35 100644 --- a/src/Wait/WaitForExec.php +++ b/src/Wait/WaitForExec.php @@ -5,31 +5,56 @@ declare(strict_types=1); namespace Testcontainers\Wait; use Closure; -use Symfony\Component\Process\Process; -use Testcontainers\Exception\ContainerNotReadyException; +use Docker\API\Model\ExecIdJsonGetResponse200; +use Testcontainers\Container\StartedTestContainer; +use Testcontainers\Exception\ContainerWaitingTimeoutException; -class WaitForExec implements WaitInterface +/** + * Uses $timout and $pollInterval in milliseconds to set the parameters for waiting. + */ +class WaitForExec extends BaseWaitStrategy { /** * @param array $command */ - public function __construct(private array $command, private ?Closure $checkFunction = null) - { + public function __construct( + protected array $command, + protected ?Closure $checkFunction = null, + int $timeout = 10000, + int $pollInterval = 500 + ) { + parent::__construct($timeout, $pollInterval); } - public function wait(string $id): void + public function wait(StartedTestContainer $container): void { - $process = new Process(['docker', 'exec', $id, ...$this->command]); + $startTime = microtime(true) * 1000; - try { - $process->mustRun(); - } catch (\Exception $e) { - throw new ContainerNotReadyException($id, $e); - } + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; - if ($this->checkFunction !== null) { - $func = $this->checkFunction; - $func($process); + if ($elapsedTime > $this->timeout) { + throw new ContainerWaitingTimeoutException($container->getId()); + } + + $contents = $container->exec($this->command); + + // Inspect the exec to check the exit code + /** @var ExecIdJsonGetResponse200 | null $execInspect */ + $execInspect = $container->getClient()->execInspect($container->getLastExecId() ?? ''); + $exitCode = $execInspect?->getExitCode(); + + // If a custom check function is provided, use it to validate the command output + if ($this->checkFunction !== null) { + $checkResult = ($this->checkFunction)($exitCode, $contents); + if ($checkResult) { + return; + } + } elseif ($exitCode === 0) { + return; // Command succeeded + } + + usleep($this->pollInterval * 1000); } } } diff --git a/src/Wait/WaitForHealthCheck.php b/src/Wait/WaitForHealthCheck.php index 2836346..289adaf 100644 --- a/src/Wait/WaitForHealthCheck.php +++ b/src/Wait/WaitForHealthCheck.php @@ -4,27 +4,70 @@ declare(strict_types=1); namespace Testcontainers\Wait; -use RuntimeException; -use Symfony\Component\Process\Process; -use Testcontainers\Exception\ContainerNotReadyException; +use Docker\API\Model\ContainersIdJsonGetResponse200; +use Testcontainers\Container\StartedTestContainer; +use Testcontainers\Exception\ContainerStateException; +use Testcontainers\Exception\ContainerWaitingTimeoutException; +use Testcontainers\Exception\HealthCheckFailedException; +use Testcontainers\Exception\HealthCheckNotConfiguredException; +use Testcontainers\Exception\UnknownHealthStatusException; -class WaitForHealthCheck implements WaitInterface +/** + * Wait strategy that waits until the container's health status is 'healthy'. + * + * Possible health statuses: + * - "none": No health check configured. + * - "starting": Health check is in progress. + * - "healthy": Container is healthy. + * - "unhealthy": Container is unhealthy. + */ +class WaitForHealthCheck extends BaseWaitStrategy { - public function wait(string $id): void + public function wait(StartedTestContainer $container): void { - $process = new Process(['docker', 'inspect', '--format', '{{json .State.Health.Status}}', $id]); - $process->mustRun(); + $startTime = microtime(true); - $status = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR); + while (true) { + $elapsedTime = (microtime(true) - $startTime) * 1000; - if (!is_string($status)) { - throw new ContainerNotReadyException($id, new RuntimeException('Invalid json output')); - } + if ($elapsedTime > $this->timeout) { + throw new ContainerWaitingTimeoutException($container->getId()); + } - $status = trim($status, '"'); + /** @var ContainersIdJsonGetResponse200|null $containerInspect */ + $containerInspect = $container->getClient()->containerInspect($container->getId()); - if ($status !== 'healthy') { - throw new ContainerNotReadyException($id); + $containerState = $containerInspect?->getState(); + + if ($containerState !== null) { + $health = $containerState->getHealth(); + + if ($health !== null) { + $status = $health->getStatus(); + + switch ($status) { + case 'healthy': + return; // Container is healthy + case 'starting': + // Health check is still in progress; continue waiting + break; + case 'unhealthy': + throw new HealthCheckFailedException($container->getId()); + case 'none': + throw new HealthCheckNotConfiguredException($container->getId()); + default: + throw new UnknownHealthStatusException($container->getId(), (string)$status); + } + } else { + // Health is null; treat as 'none' status + throw new HealthCheckNotConfiguredException($container->getId()); + } + } else { + // Container state is null + throw new ContainerStateException($container->getId()); + } + + usleep($this->pollInterval * 1000); } } } diff --git a/src/Wait/WaitForHostPort.php b/src/Wait/WaitForHostPort.php new file mode 100644 index 0000000..9a97388 --- /dev/null +++ b/src/Wait/WaitForHostPort.php @@ -0,0 +1,64 @@ + $this->timeout) { + throw new ContainerWaitingTimeoutException($container->getId()); + } + + if ($this->boundPortsOpened($container)) { + return; // Port is open, container is ready + } + + usleep($this->pollInterval * 1000); // Wait for the next polling interval + } + } + + /** + * @param StartedTestContainer $container + * @return bool + */ + private function boundPortsOpened(StartedTestContainer $container): bool + { + $boundPorts = $container->getBoundPorts(); + foreach ($boundPorts as $bindings) { + foreach ($bindings as $binding) { + $hostIp = trim($binding->getHostIp() ?? ''); + if ($hostIp === '' || $hostIp === '0.0.0.0') { + $hostIp = $container->getHost(); + } + $hostPort = (int)$binding->getHostPort(); + if (!$this->isPortOpen($hostIp, $hostPort)) { + return false; + } + } + } + return true; + } + + private function isPortOpen(string $ipAddress, int $port): bool + { + $connection = @fsockopen($ipAddress, $port, $errno, $errstr, 2); + + if ($connection !== false) { + fclose($connection); + return true; + } + + return false; + } +} diff --git a/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index d65ff04..b2dceeb 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -4,75 +4,139 @@ declare(strict_types=1); namespace Testcontainers\Wait; -use Testcontainers\Exception\ContainerNotReadyException; -use Testcontainers\Trait\DockerContainerAwareTrait; +use Testcontainers\Container\HttpMethod; +use Testcontainers\Container\StartedTestContainer; +use Testcontainers\Exception\ContainerWaitingTimeoutException; -class WaitForHttp implements WaitInterface +class WaitForHttp extends BaseWaitStrategy { - use DockerContainerAwareTrait; + protected HttpMethod $method = HttpMethod::GET; - 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'; + protected string $path = '/'; + protected string $protocol = 'http'; - private string $method = 'GET'; - private string $path = '/'; - private int $statusCode = 200; + protected int $expectedStatusCode = 200; - public function __construct(private int $port) - { - } + protected bool $allowInsecure = false; - public static function make(int $port): self - { - return new WaitForHttp($port); + /** + * @var array + */ + protected array $headers = []; + + /** + * @var int Timeout in milliseconds for reading the response + */ + protected int $readTimeout = 1000; + + public function __construct( + protected int $port, + int $timeout = 10000, + int $pollInterval = 500 + ) { + parent::__construct($timeout, $pollInterval); } /** - * @param WaitForHttp::METHOD_* $method + * @param HttpMethod|value-of $method */ - public function withMethod(string $method): self + public function withMethod(HttpMethod | string $method): self { + if (is_string($method)) { + $method = HttpMethod::fromString($method); + } $this->method = $method; - return $this; } public function withPath(string $path): self { $this->path = $path; - return $this; } - public function withStatusCode(int $statusCode): self + public function withExpectedStatusCode(int $statusCode): self { - $this->statusCode = $statusCode; - + $this->expectedStatusCode = $statusCode; return $this; } - public function wait(string $id): void + public function usingHttps(): self { - $containerAddress = self::dockerContainerAddress(containerId: $id); + $this->protocol = 'https'; + return $this; + } + public function allowInsecure(): self + { + $this->allowInsecure = true; + return $this; + } + + public function withReadTimeout(int $timeout): self + { + $this->readTimeout = $timeout; + return $this; + } + + /** + * @param array $headers + */ + public function withHeaders(array $headers): self + { + $this->headers = $headers; + return $this; + } + + public function wait(StartedTestContainer $container): void + { + $startTime = microtime(true) * 1000; + + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; + + if ($elapsedTime > $this->timeout) { + throw new ContainerWaitingTimeoutException($container->getId()); + } + + $containerAddress = $container->getHost(); + + $url = sprintf('%s://%s:%d%s', $this->protocol, $containerAddress, $this->port, $this->path); + $responseCode = $this->makeHttpRequest($url); + + if ($responseCode === $this->expectedStatusCode) { + return; // Container is ready + } + + usleep($this->pollInterval * 1000); + } + } + + private function makeHttpRequest(string $url): int + { $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d%s', $containerAddress, $this->port, $this->path)); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $this->method->value); 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_setopt($ch, CURLOPT_NOBODY, true); // No need for response body, just headers + curl_setopt($ch, CURLOPT_TIMEOUT_MS, $this->readTimeout); - curl_exec($ch); - - if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== $this->statusCode) { - throw new ContainerNotReadyException($id, new \RuntimeException('HTTP status code does not match')); + // Allow insecure connections if requested + if ($this->allowInsecure) { + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); } + // Add custom headers + if (!empty($this->headers)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, array_map(static fn ($k, $v) => "$k: $v", array_keys($this->headers), $this->headers)); + } + + curl_exec($ch); + $responseCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + + return $responseCode; } } diff --git a/src/Wait/WaitForLog.php b/src/Wait/WaitForLog.php index 63f5ace..a7ff614 100644 --- a/src/Wait/WaitForLog.php +++ b/src/Wait/WaitForLog.php @@ -4,30 +4,45 @@ declare(strict_types=1); namespace Testcontainers\Wait; -use Symfony\Component\Process\Process; -use Testcontainers\Exception\ContainerNotReadyException; +use Testcontainers\Container\StartedTestContainer; +use Testcontainers\Exception\ContainerWaitingTimeoutException; -class WaitForLog implements WaitInterface +/** + * Uses $timout and $pollInterval in milliseconds to set the parameters for waiting. + */ +class WaitForLog extends BaseWaitStrategy { - public function __construct(private string $message, private bool $enableRegex = false) - { + public function __construct( + protected string $message, + protected bool $enableRegex = false, + int $timeout = 10000, + int $pollInterval = 500 + ) { + parent::__construct($timeout, $pollInterval); } - public function wait(string $id): void + public function wait(StartedTestContainer $container): void { - $process = new Process(['docker', 'logs', $id]); - $process->mustRun(); + $startTime = microtime(true) * 1000; - $output = $process->getOutput() . PHP_EOL . $process->getErrorOutput(); + while (true) { + $elapsedTime = (microtime(true) * 1000) - $startTime; - if ($this->enableRegex) { - if (!preg_match($this->message, $output)) { - throw new ContainerNotReadyException($id, new \RuntimeException('Message not found in logs')); + if ($elapsedTime > $this->timeout) { + throw new ContainerWaitingTimeoutException($container->getId()); } - } else { - if (!str_contains($output, $this->message)) { - throw new ContainerNotReadyException($id, new \RuntimeException('Message not found in logs')); + + $output = $container->logs(); + + if ($this->enableRegex) { + if (preg_match($this->message, $output)) { + return; + } + } elseif (str_contains($output, $this->message)) { + return; } + + usleep($this->pollInterval * 1000); } } } diff --git a/src/Wait/WaitForNothing.php b/src/Wait/WaitForNothing.php deleted file mode 100644 index 741df17..0000000 --- a/src/Wait/WaitForNothing.php +++ /dev/null @@ -1,13 +0,0 @@ -network), $this->port) === false) { - throw new ContainerNotReadyException($id, new RuntimeException('Unable to connect to container TCP port')); - } - } -} diff --git a/src/Wait/WaitInterface.php b/src/Wait/WaitInterface.php deleted file mode 100644 index 4e75ce5..0000000 --- a/src/Wait/WaitInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -withMySQLDatabase('foo'); - $container->withMySQLUser('bar', 'baz'); - - $container->run(); - - $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', $container->getAddress()), - 'bar', - 'baz', - ); - - $query = $pdo->query('SHOW databases'); - - $this->assertInstanceOf(\PDOStatement::class, $query); - - $databases = $query->fetchAll(\PDO::FETCH_COLUMN); - - $this->assertContains('foo', $databases); - } - - public function testMariaDB(): void - { - $container = MariaDBContainer::make(); - $container->withMariaDBDatabase('foo'); - $container->withMariaDBUser('bar', 'baz'); - - $container->run(); - - $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', $container->getAddress()), - 'bar', - 'baz', - ); - - $query = $pdo->query('SHOW databases'); - - $this->assertInstanceOf(\PDOStatement::class, $query); - - $databases = $query->fetchAll(\PDO::FETCH_COLUMN); - - $this->assertContains('foo', $databases); - } - - public function testRedis(): void - { - $container = RedisContainer::make(); - - $container->run(); - - $redis = new Client([ - 'scheme' => 'tcp', - 'host' => $container->getAddress(), - 'port' => 6379, - ]); - - $redis->ping(); - - $this->assertTrue($redis->isConnected()); - } - - public function testOpenSearch(): void - { - $container = OpenSearchContainer::make(); - $container->disableSecurityPlugin(); - - $container->run(); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 9200)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $response = (string) curl_exec($ch); - - $this->assertNotEmpty($response); - - /** @var array{cluster_name: string} $data */ - $data = json_decode($response, true, JSON_THROW_ON_ERROR); - - $this->assertArrayHasKey('cluster_name', $data); - - $this->assertEquals('docker-cluster', $data['cluster_name']); - } - - public function testPostgreSQLContainer(): void - { - $container = PostgresContainer::make('latest', 'test') - ->withPostgresUser('test') - ->withPostgresDatabase('foo') - ->run(); - - - $pdo = new \PDO( - sprintf('pgsql:host=%s;port=5432;dbname=foo', $container->getAddress()), - 'test', - 'test', - ); - - $query = $pdo->query('SELECT datname FROM pg_database'); - - $this->assertInstanceOf(\PDOStatement::class, $query); - - $databases = $query->fetchAll(\PDO::FETCH_COLUMN); - - $this->assertContains('foo', $databases); - } -} diff --git a/tests/Integration/ContainerTestCase.php b/tests/Integration/ContainerTestCase.php new file mode 100644 index 0000000..9a3966f --- /dev/null +++ b/tests/Integration/ContainerTestCase.php @@ -0,0 +1,21 @@ +container)) { + $this->container->stop(); + } + parent::tearDown(); + } +} diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php new file mode 100644 index 0000000..7ed65bb --- /dev/null +++ b/tests/Integration/GenericContainerTest.php @@ -0,0 +1,282 @@ +withCommand(['tail', '-f', '/dev/null']) + ->start(); + $result = $container->exec(['echo', 'testcontainers']); + + self::assertSame('testcontainers', $result); + + $container->stop(); + } + + public function testShouldCopyContentToContainer(): void + { + $inlineContent = 'hello world'; + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyContentToContainer([[ + 'content' => $inlineContent, + 'target' => '/tmp/inline.txt', + ]]) + ->start(); + + $output = $container->exec(['cat', '/tmp/inline.txt']); + + self::assertSame($inlineContent, $output); + + $container->stop(); + } + + public function testShouldCopyDirectoryToContainer(): void + { + $testDir = sys_get_temp_dir() . '/copy-dir-test'; + if (!is_dir($testDir)) { + mkdir($testDir); + } + file_put_contents($testDir . '/file1.txt', 'file1 contents'); + file_put_contents($testDir . '/file2.txt', 'file2 contents'); + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyDirectoriesToContainer([[ + 'source' => $testDir, + 'target' => '/test-dir', + ]]) + ->start(); + + $output1 = $container->exec(['cat', '/test-dir/file1.txt']); + $output2 = $container->exec(['cat', '/test-dir/file2.txt']); + + self::assertSame('file1 contents', $output1); + self::assertSame('file2 contents', $output2); + + $container->stop(); + } + + public function testShouldCopyFileToContainer(): void + { + $localFilePath = sys_get_temp_dir() . '/copy-file-test.txt'; + file_put_contents($localFilePath, 'hello from file'); + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyFilesToContainer([[ + 'source' => $localFilePath, + 'target' => '/tmp/test-file.txt', + ]]) + ->start(); + + $output = $container->exec(['cat', '/tmp/test-file.txt']); + + self::assertSame('hello from file', $output); + + $container->stop(); + } + + public function testShouldCopyFileWithPermissions(): void + { + $localFilePath = sys_get_temp_dir() . '/copy-perms-test.txt'; + file_put_contents($localFilePath, 'check perms'); + + $mode = 0o777; + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyFilesToContainer([[ + 'source' => $localFilePath, + 'target' => '/tmp/perm-file.txt', + 'mode' => $mode, + ]]) + ->start(); + + $output = $container->exec(['stat', '-c', '%a', '/tmp/perm-file.txt']); + + self::assertSame('777', trim($output)); + + $container->stop(); + } + + public function testShouldReturnFirstMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->withWait(new WaitForHostPort()) + ->start(); + $firstMappedPort = $container->getFirstMappedPort(); + + self::assertSame($firstMappedPort, $container->getMappedPort(80)); + + $container->stop(); + } + + public function testShouldSetLabels(): void + { + $labels = [ + 'label-1' => 'value-1', + 'label-2' => 'value-2', + ]; + $container = (new GenericContainer('alpine')) + ->withLabels($labels) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $this->assertArrayHasKey('label-1', (array)$inspectResult?->getConfig()?->getLabels()); + $this->assertSame('value-1', ((array)$inspectResult?->getConfig()?->getLabels())['label-1']); + $this->assertArrayHasKey('label-2', (array)$inspectResult?->getConfig()?->getLabels()); + $this->assertSame('value-2', ((array)$inspectResult?->getConfig()?->getLabels())['label-2']); + + $container->stop(); + } + + public function testShouldSetName(): void + { + $name = 'test-container-name'; + $container = (new GenericContainer('alpine')) + ->withName($name) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $this->assertSame('/'.$name, $inspectResult?->getName()); + + $container->stop(); + } + + public function testShouldSetUser(): void + { + $container = (new GenericContainer('alpine')) + ->withUser('nobody') + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $output = $container->exec(['whoami']); + $this->assertStringContainsString('nobody', $output); + + $container->stop(); + } + + public function testShouldSetWorkingDir(): void + { + $container = (new GenericContainer('alpine')) + ->withWorkingDir('/tmp') + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $output = $container->exec(['pwd']); + $this->assertStringContainsString('/tmp', $output); + + $container->stop(); + } + + public function testShouldCaptureStderrWhenCommandFails(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + $result = $container->exec(['ls', '/nonexistent/path']); + + self::assertStringContainsString('No such file or directory', $result, 'Expected stderr in the output'); + + $container->stop(); + } + + public function testShouldSetEnvironmentVariables(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withEnvironment(['TEST_ENV' => 'testValue']) + ->start(); + $output = $container->exec(['env']); + + self::assertStringContainsString('TEST_ENV=testValue', $output); + + $container->stop(); + } + + public function testShouldSetHealthCheckCommand(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withHealthCheckCommand('echo "healthy" || exit 1') + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $healthConfig = $inspectResult?->getConfig()?->getHealthcheck(); + + $this->assertNotNull($healthConfig); + $this->assertEquals(['CMD-SHELL', 'echo "healthy" || exit 1'], $healthConfig->getTest()); + $this->assertSame(1000000000, $healthConfig->getInterval()); + $this->assertSame(3000000000, $healthConfig->getTimeout()); + $this->assertSame(3, $healthConfig->getRetries()); + + $container->stop(); + } + + public function testShouldSetEntrypoint(): void + { + $container = (new GenericContainer('cristianrgreco/testcontainer:1.1.14')) + ->withEntrypoint('node') + ->withCommand(['index.js']) + ->withExposedPorts(8080) + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $entrypoint = $inspectResult?->getConfig()?->getEntrypoint() ?? []; + + self::assertContains('node', $entrypoint); + + $container->stop(); + } + + public function testShouldSetMount(): void + { + $localPath = __DIR__ . '/../Fixtures/Docker'; + $containerPath = '/mnt/test-data'; + + $container = (new GenericContainer('alpine')) + ->withMount($localPath, $containerPath) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $result = $container->exec(["cat", $containerPath.'/test.txt']); + self::assertSame('hello world', $result); + + $container->stop(); + } + + public function testShouldSetPrivilegedMode(): void + { + $container = (new GenericContainer('alpine')) + ->withPrivilegedMode() + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $privileged = $inspectResult?->getHostConfig()?->getPrivileged(); + + self::assertTrue($privileged); + + $container->stop(); + } +} diff --git a/tests/Integration/MariaDBContainerTest.php b/tests/Integration/MariaDBContainerTest.php new file mode 100644 index 0000000..f88b359 --- /dev/null +++ b/tests/Integration/MariaDBContainerTest.php @@ -0,0 +1,39 @@ +container = (new MariaDBContainer()) + ->withMariaDBDatabase('foo') + ->withMariaDBUser('bar', 'baz') + ->start(); + } + + public function testMariaDBContainer(): void + { + $pdo = new \PDO( + sprintf( + 'mysql:host=%s;port=%d', + $this->container->getHost(), + $this->container->getFirstMappedPort() + ), + 'bar', + 'baz', + ); + + $query = $pdo->query('SHOW databases'); + + $this->assertInstanceOf(\PDOStatement::class, $query); + + $databases = $query->fetchAll(\PDO::FETCH_COLUMN); + + $this->assertContains('foo', $databases); + } +} diff --git a/tests/Integration/MySQLContainerTest.php b/tests/Integration/MySQLContainerTest.php new file mode 100644 index 0000000..8d3f867 --- /dev/null +++ b/tests/Integration/MySQLContainerTest.php @@ -0,0 +1,39 @@ +container = (new MySQLContainer()) + ->withMySQLDatabase('foo') + ->withMySQLUser('bar', 'baz') + ->start(); + } + + public function testMySQLContainer(): void + { + $pdo = new \PDO( + sprintf( + 'mysql:host=%s;port=%d', + $this->container->getHost(), + $this->container->getFirstMappedPort() + ), + 'bar', + 'baz', + ); + + $query = $pdo->query('SHOW databases'); + + $this->assertInstanceOf(\PDOStatement::class, $query); + + $databases = $query->fetchAll(\PDO::FETCH_COLUMN); + + $this->assertContains('foo', $databases); + } +} diff --git a/tests/Integration/OpenSearchContainerTest.php b/tests/Integration/OpenSearchContainerTest.php new file mode 100644 index 0000000..b1134a5 --- /dev/null +++ b/tests/Integration/OpenSearchContainerTest.php @@ -0,0 +1,42 @@ +container = (new OpenSearchContainer()) + ->withDisabledSecurityPlugin() + ->start(); + } + + /** + * @throws \JsonException + */ + public function testOpenSearch(): void + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, sprintf( + 'http://%s:%d', + $this->container->getHost(), + $this->container->getFirstMappedPort() + )); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = (string) curl_exec($ch); + + $this->assertNotEmpty($response); + + /** @var array{cluster_name: string} $data */ + $data = json_decode($response, true, JSON_THROW_ON_ERROR, JSON_THROW_ON_ERROR); + + $this->assertArrayHasKey('cluster_name', $data); + + $this->assertEquals('docker-cluster', $data['cluster_name']); + } +} diff --git a/tests/Integration/PostgreSQLContainerTest.php b/tests/Integration/PostgreSQLContainerTest.php new file mode 100644 index 0000000..31e23cf --- /dev/null +++ b/tests/Integration/PostgreSQLContainerTest.php @@ -0,0 +1,39 @@ +container = (new PostgresContainer()) + ->withPostgresUser('bar') + ->withPostgresDatabase('foo') + ->start(); + } + + public function testPostgreSQLContainer(): void + { + $pdo = new \PDO( + sprintf( + 'pgsql:host=%s;port=%d;dbname=foo', + $this->container->getHost(), + $this->container->getFirstMappedPort() + ), + 'bar', + 'test', + ); + + $query = $pdo->query('SELECT datname FROM pg_database'); + + $this->assertInstanceOf(\PDOStatement::class, $query); + + $databases = $query->fetchAll(\PDO::FETCH_COLUMN); + + $this->assertContains('foo', $databases); + } +} diff --git a/tests/Integration/RedisContainerTest.php b/tests/Integration/RedisContainerTest.php new file mode 100644 index 0000000..3e207d4 --- /dev/null +++ b/tests/Integration/RedisContainerTest.php @@ -0,0 +1,33 @@ +container = (new RedisContainer()) + ->start(); + } + + public function testRedisContainer(): void + { + $redisClient = new Client([ + 'host' => $this->container->getHost(), + 'port' => $this->container->getFirstMappedPort(), + ]); + + $redisClient->ping(); + + $this->assertTrue($redisClient->isConnected()); + + $redisClient->set('greetings', 'Hello, World!'); + + $this->assertEquals('Hello, World!', $redisClient->get('greetings')); + } +} diff --git a/tests/Integration/StartedGenericContainerTest.php b/tests/Integration/StartedGenericContainerTest.php new file mode 100644 index 0000000..a59976c --- /dev/null +++ b/tests/Integration/StartedGenericContainerTest.php @@ -0,0 +1,207 @@ +withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + self::assertNotEmpty($container->getId(), 'Container ID should not be empty'); + } + + public function testShouldReturnLastExecId(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $container->exec(['echo', 'Test Exec ID']); + + $lastExecId = $container->getLastExecId(); + + self::assertNotNull($lastExecId, 'Last exec ID should not be null'); + self::assertNotEmpty($lastExecId, 'Last exec ID should not be empty'); + self::assertMatchesRegularExpression('/^[0-9a-f]+$/', $lastExecId, 'Last exec ID should be a valid hexadecimal string'); + } + + public function testShouldExecuteCommandInContainer(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $output = $container->exec(['echo', 'Hello, Testcontainers!']); + self::assertSame('Hello, Testcontainers!', $output); + } + + public function testShouldStopContainer(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + self::assertNotEmpty($container->getId(), 'Container ID should not be empty'); + + $stoppedContainer = $container->stop(); + + self::assertNotNull($stoppedContainer, 'Stopped container should not be null'); + self::assertSame( + $container->getId(), + $stoppedContainer->getId(), + 'Stopped container ID should match the original container ID' + ); + + self::assertStringContainsString( + 'No such container', + $container->logs(), + 'Expected message indicating container does not exist' + ); + } + + public function testShouldRestartContainer(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $containerIdBeforeRestart = $container->getId(); + $container->restart(); + $containerIdAfterRestart = $container->getId(); + + self::assertSame( + $containerIdBeforeRestart, + $containerIdAfterRestart, + 'Container ID should remain the same after restart' + ); + } + + public function testShouldRetrieveLogs(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['sh', '-c', 'echo "Hello from logs!" && tail -f /dev/null']) + ->start(); + + $this->container = $container; + + $logs = $container->logs(); + self::assertStringContainsString('Hello from logs!', $logs); + } + + public function testShouldRetrieveHost(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $host = $container->getHost(); + self::assertSame('127.0.0.1', $host, 'Host should be 127.0.0.1'); + } + + public function testShouldRetrieveFirstMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $mappedPort = $container->getFirstMappedPort(); + self::assertGreaterThan(0, $mappedPort, 'Mapped port should be greater than 0'); + } + + public function testShouldRetrieveMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $mappedPort = $container->getMappedPort(80); + self::assertGreaterThan(0, $mappedPort, 'Mapped port for 80 should be greater than 0'); + } + + public function testShouldRetrieveContainerName(): void + { + $name = 'test-container-name'; + $container = (new GenericContainer('alpine')) + ->withName($name) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + self::assertSame($name, $container->getName(), 'Container name should match'); + } + + public function testShouldRetrieveLabels(): void + { + $labels = [ + 'label-1' => 'value-1', + 'label-2' => 'value-2', + ]; + + $container = (new GenericContainer('alpine')) + ->withLabels($labels) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $retrievedLabels = $container->getLabels(); + + self::assertArrayHasKey('label-1', $retrievedLabels); + self::assertSame('value-1', $retrievedLabels['label-1']); + self::assertArrayHasKey('label-2', $retrievedLabels); + self::assertSame('value-2', $retrievedLabels['label-2']); + } + + public function testShouldRetrieveNetworkNames(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $networks = $container->getNetworkNames(); + + self::assertNotEmpty($networks, 'Networks should not be empty'); + } + + public function testShouldRetrieveIpAddressFromNetwork(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $networks = $container->getNetworkNames(); + $networkName = $networks[0] ?? null; + + self::assertNotNull($networkName, 'Network name should not be null'); + + $ipAddress = $container->getIpAddress($networkName); + + self::assertNotEmpty($ipAddress, 'IP address should not be empty'); + } +} diff --git a/tests/Integration/WaitStrategyTest.php b/tests/Integration/WaitStrategyTest.php deleted file mode 100644 index 09abdf7..0000000 --- a/tests/Integration/WaitStrategyTest.php +++ /dev/null @@ -1,162 +0,0 @@ -withEnvironment('MYSQL_ROOT_PASSWORD', 'root') - ->withWait(new WaitForExec(['mysqladmin', 'ping', '-h', '127.0.0.1'], function (Process $process) use (&$called) { - $called = true; - })); - - $container->run(); - - $this->assertTrue($called, 'Wait function was not called'); - unset($called); - - $pdo = new \PDO( - sprintf('mysql:host=%s;port=3306', $container->getAddress()), - 'root', - 'root' - ); - - $query = $pdo->query('select version()'); - - $this->assertInstanceOf(\PDOStatement::class, $query); - - $version = $query->fetchColumn(); - - $this->assertNotEmpty($version); - } - - public function testWaitForLog(): void - { - $container = Container::make('redis:6.2.5') - ->withWait(new WaitForLog('Ready to accept connections')); - - $container->run(); - - $redis = new Client([ - 'scheme' => 'tcp', - 'host' => $container->getAddress(), - 'port' => 6379, - ]); - - $redis->set('foo', 'bar'); - - $this->assertEquals('bar', $redis->get('foo')); - - $container->stop(); - - $this->expectException(ConnectionException::class); - - $redis->get('foo'); - - $container->remove(); - } - - public function testWaitForHTTP(): void - { - $container = Container::make('nginx:alpine') - ->withWait(WaitForHttp::make(80)); - - $container->run(); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $response = (string) curl_exec($ch); - - curl_close($ch); - - $this->assertNotEmpty($response); - } - - /** - * @dataProvider provideWaitForTcpPortOpen - */ - public function testWaitForTcpPortOpen(bool $wait): void - { - $container = Container::make('nginx:alpine'); - - if ($wait) { - $container->withWait(WaitForTcpPortOpen::make(80)); - } - - $container->run(); - - if ($wait) { - static::assertIsResource(fsockopen($container->getAddress(), 80), 'Failed to connect to container'); - return; - } - - $containerId = $container->getId(); - - $this->expectExceptionObject(new ContainerNotReadyException($containerId)); - - (new WaitForTcpPortOpen(8080))->wait($containerId); - } - - /** - * @return array> - */ - public function provideWaitForTcpPortOpen(): array - { - return [ - 'Can connect to container' => [true], - 'Cannot connect to container' => [false], - ]; - } - - public function testWaitForHealthCheck(): void - { - $container = Container::make('nginx') - ->withHealthCheckCommand('curl --fail http://localhost') - ->withWait(new WaitForHealthCheck()); - - $container->run(); - - $ch = curl_init(); - - curl_setopt($ch, CURLOPT_URL, sprintf('http://%s:%d', $container->getAddress(), 80)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $response = curl_exec($ch); - - $this->assertNotEmpty($response); - $this->assertIsString($response); - - $this->assertStringContainsString('Welcome to nginx!', $response); - } -} diff --git a/tests/Unit/Utils/HostResolverTest.php b/tests/Unit/Utils/HostResolverTest.php new file mode 100644 index 0000000..f428b94 --- /dev/null +++ b/tests/Unit/Utils/HostResolverTest.php @@ -0,0 +1,253 @@ +createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + $host = $resolver->resolveHost(); + $this->assertEquals('tcp://another:2375', $host); + } + + public function testReturnsHostnameForTcpProtocols(): void + { + $protocols = ['tcp', 'http', 'https']; + foreach ($protocols as $protocol) { + putenv('DOCKER_HOST=' . $protocol . '://docker:2375'); + // Clear any override. + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $dummyClient = $this->createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + $host = $resolver->resolveHost(); + $this->assertEquals('docker', $host, "Protocol {$protocol} did not return expected hostname."); + } + } + + public function testDoesNotReturnOverrideWhenAllowUserOverridesIsFalse(): void + { + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function allowUserOverrides(): bool + { + return false; + } + }; + + putenv('TESTCONTAINERS_HOST_OVERRIDE=tcp://another:2375'); + putenv('DOCKER_HOST=tcp://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('docker', $host); + } + + public function testReturnsLocalhostForUnixAndNpipeProtocolsWhenNotInContainer(): void + { + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return false; + } + }; + + foreach (['unix://docker:2375', 'npipe://docker:2375'] as $uri) { + putenv('DOCKER_HOST=' . $uri); + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host, "URI {$uri} should return 'localhost' when not in a container."); + } + } + + public function testReturnsHostFromGatewayWhenRunningInContainer(): void + { + // For this test we simulate that we are in a container and the Docker client returns a gateway. + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + + // Build a fake network inspection response: + $fakeConfig = new class () { + public function getGateway(): ?string + { + return '172.0.0.1'; + } + }; + $fakeIPAM = new class ($fakeConfig) { + /** @var object[] */ + private array $config; + public function __construct(object $config) + { + $this->config = [$config]; + } + /** @return object[] */ + public function getConfig(): array + { + return $this->config; + } + }; + $fakeNetwork = new class ($fakeIPAM) { + private object $ipam; + public function __construct(object $ipam) + { + $this->ipam = $ipam; + } + public function getIPAM(): object + { + return $this->ipam; + } + }; + + // Expect that networkInspect will be called with "bridge" (since DOCKER_HOST does not contain "podman.sock") + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('bridge')) + ->willReturn($fakeNetwork); + + // Override isInContainer() to simulate being inside a container. + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $host = $resolver->resolveHost(); + $this->assertEquals('172.0.0.1', $host); + } + + public function testUsesBridgeNetworkAsGatewayForDockerProvider(): void + { + // For Docker provider (non-Podman) the network used should be "bridge". + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + // Expect networkInspect to be called with "bridge" + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('bridge')) + ->willReturn(null); // Simulate not finding a gateway + + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + // Since no gateway is found, fallback is "localhost" + $this->assertEquals('localhost', $host); + } + + public function testUsesPodmanNetworkAsGatewayForPodmanProvider(): void + { + // For Podman, DOCKER_HOST contains "podman.sock" so the network should be "podman". + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + // Expect networkInspect to be called with "podman" + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('podman')) + ->willReturn(null); // Simulate not finding a gateway + + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://podman.sock'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host); + } + + public function testReturnsHostFromDefaultGatewayWhenRunningInContainer(): void + { + // Override both findGateway() and findDefaultGateway() to simulate a missing network gateway and a default gateway result. + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + protected function findGateway(string $networkName): ?string + { + return null; + } + protected function findDefaultGateway(): ?string + { + return '172.0.0.2'; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('172.0.0.2', $host); + } + + public function testReturnsLocalhostIfUnableToFindGateway(): void + { + // Override to simulate that neither network inspection nor default gateway yield a result. + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + protected function findGateway(string $networkName): ?string + { + return null; + } + protected function findDefaultGateway(): ?string + { + return null; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host); + } + + public function testThrowsForUnsupportedProtocol(): void + { + putenv('DOCKER_HOST=invalid://unknown'); + $dummyClient = $this->createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Unsupported Docker host scheme: invalid"); + + $resolver->resolveHost(); + } +} diff --git a/tests/Unit/Utils/TarBuilderTest.php b/tests/Unit/Utils/TarBuilderTest.php new file mode 100644 index 0000000..965b9de --- /dev/null +++ b/tests/Unit/Utils/TarBuilderTest.php @@ -0,0 +1,218 @@ +tempDir = sys_get_temp_dir() . '/tarbuilder_test_' . uniqid('', true); + mkdir($this->tempDir); + } + + protected function tearDown(): void + { + $this->removeDirectoryRecursively($this->tempDir); + parent::tearDown(); + } + + public function testShouldAddSingleFile(): void + { + $sourceFile = $this->tempDir . '/file.txt'; + file_put_contents($sourceFile, self::TEST_CONTENT); + + $tarBuilder = new TarBuilder(); + $tarBuilder->addFile($sourceFile, 'mydir/file_in_tar.txt', 0o644); + + $tarPath = $tarBuilder->buildTarArchive(); + + $this->assertFileExists($tarPath, 'Tar file was not created'); + + $extractDir = $this->tempDir . '/extract'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $extractedFile = $extractDir . '/mydir/file_in_tar.txt'; + $this->assertFileExists($extractedFile); + $this->assertSame(self::TEST_CONTENT, file_get_contents($extractedFile)); + + $perms = substr(sprintf('%o', fileperms($extractedFile)), -3); + $this->assertSame('644', $perms, 'Expected file mode 0644'); + } + + public function testShouldAddDirectoryRecursively(): void + { + $localDir = $this->tempDir . '/localdir'; + mkdir($localDir); + file_put_contents($localDir . '/one.txt', 'file1'); + file_put_contents($localDir . '/two.txt', 'file2'); + + $tarBuilder = new TarBuilder(); + $tarBuilder->addDirectory($localDir, 'mydir', 0o755); + $tarPath = $tarBuilder->buildTarArchive(); + + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractdir'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $oneExtracted = $extractDir . '/mydir/one.txt'; + $twoExtracted = $extractDir . '/mydir/two.txt'; + $this->assertFileExists($oneExtracted); + $this->assertFileExists($twoExtracted); + + $this->assertSame('file1', file_get_contents($oneExtracted)); + $this->assertSame('file2', file_get_contents($twoExtracted)); + + $dirPerms = substr(sprintf('%o', fileperms($extractDir . '/mydir')), -3); + $this->assertSame('755', $dirPerms, 'Expected directory mode 0755'); + } + + public function testShouldAddInlineContent(): void + { + $content = "Inline content test\nLine2"; + + $tarBuilder = new TarBuilder(); + $tarBuilder->addContent($content, 'some/path/inline.txt', 0o777); + $tarPath = $tarBuilder->buildTarArchive(); + + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractContent'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $inlineExtracted = $extractDir . '/some/path/inline.txt'; + $this->assertFileExists($inlineExtracted); + $this->assertSame($content, file_get_contents($inlineExtracted)); + + $perms = substr(sprintf('%o', fileperms($inlineExtracted)), -3); + $this->assertSame('777', $perms, 'Expected file mode 0777'); + } + + public function testShouldFailOnInvalidFilePath(): void + { + $tarBuilder = new TarBuilder(); + $this->expectException(InvalidArgumentException::class); + $tarBuilder->addFile('/some/nonexistent/file', 'target.txt'); + } + + public function testShouldFailOnEmptyTarget(): void + { + $localFile = $this->tempDir . '/somefile.txt'; + file_put_contents($localFile, 'abc'); + + $tarBuilder = new TarBuilder(); + $this->expectException(InvalidArgumentException::class); + $tarBuilder->addFile($localFile, ''); + } + + public function testShouldFailOnInvalidMode(): void + { + $localFile = $this->tempDir . '/somefile.txt'; + file_put_contents($localFile, 'abc'); + + $tarBuilder = new TarBuilder(); + $this->expectException(InvalidArgumentException::class); + $tarBuilder->addFile($localFile, 'target.txt', 9999); + } + + public function testShouldCreateEmptyTarIfNoItemsAdded(): void + { + $tarBuilder = new TarBuilder(); + + $tarPath = $tarBuilder->buildTarArchive(); + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractEmpty'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $scanned = array_diff(scandir($extractDir) ?: [], ['.', '..']); + $this->assertCount(0, $scanned, 'Expected empty directory'); + } + + public function testShouldClearItems(): void + { + $tarBuilder = new TarBuilder(); + $localFile = $this->tempDir . '/somefile.txt'; + file_put_contents($localFile, 'abc'); + $tarBuilder->addFile($localFile, 'test.txt'); + + $tarBuilder->clear(); + + $tarPath = $tarBuilder->buildTarArchive(); + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractCleared'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $scanned = array_diff(scandir($extractDir) ?: [], ['.', '..']); + $this->assertCount(0, $scanned, 'Expected no files after clear()'); + } + + /** + * Helper function to extract a .tar for verification. + */ + private function extractTar(string $tarPath, string $destination): void + { + $cmd = sprintf( + 'tar -xpf %s -C %s 2>&1', + escapeshellarg($tarPath), + escapeshellarg($destination) + ); + + exec($cmd, $output, $exitCode); + if ($exitCode !== 0) { + $errorText = implode("\n", $output); + throw new RuntimeException("Failed to extract tar:\n{$errorText}"); + } + } + + /** + * Recursively remove directory. + */ + private function removeDirectoryRecursively(string $path): void + { + if (!is_dir($path)) { + return; + } + + /** @var RecursiveIteratorIterator $items */ + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($items as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + if ($item->isDir()) { + rmdir($item->getRealPath()); + } else { + unlink($item->getRealPath()); + } + } + rmdir($path); + } +}