From dfff7ef28296b451898d201675c1e4ff7df7c839 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Wed, 1 Jan 2025 23:29:57 +0100 Subject: [PATCH 1/3] test improvements --- composer.json | 5 +- tests/Fixtures/Docker/test.txt | 1 + tests/Integration/ContainerTestCase.php | 5 +- tests/Integration/GenericContainerTest.php | 110 ++++++++++++++++-- tests/Integration/MariaDBContainerTest.php | 8 +- tests/Integration/MySQLContainerTest.php | 8 +- tests/Integration/OldTests/ContainerTest.php | 1 + .../Integration/OldTests/WaitStrategyTest.php | 1 + tests/Integration/OpenSearchContainerTest.php | 8 +- tests/Integration/PostgreSQLContainerTest.php | 8 +- tests/Integration/RedisContainerTest.php | 8 +- 11 files changed, 129 insertions(+), 34 deletions(-) create mode 100644 tests/Fixtures/Docker/test.txt diff --git a/composer.json b/composer.json index 5474d25..773c728 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "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", @@ -41,7 +41,8 @@ } }, "scripts": { - "integration": "paratest tests/ --bootstrap vendor/autoload.php -f", + "integration": "paratest tests/ --exclude-group=legacy --bootstrap vendor/autoload.php -f", + "integration:old": "phpunit tests/Integration/OldTests --bootstrap vendor/autoload.php", "cs": "php-cs-fixer fix --dry-run", "cs:fix": "php-cs-fixer fix", "phpstan": "phpstan analyse" diff --git a/tests/Fixtures/Docker/test.txt b/tests/Fixtures/Docker/test.txt new file mode 100644 index 0000000..95d09f2 --- /dev/null +++ b/tests/Fixtures/Docker/test.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/tests/Integration/ContainerTestCase.php b/tests/Integration/ContainerTestCase.php index 0a30baf..bbe9d84 100644 --- a/tests/Integration/ContainerTestCase.php +++ b/tests/Integration/ContainerTestCase.php @@ -9,10 +9,11 @@ use Testcontainers\Container\StartedTestContainer; abstract class ContainerTestCase extends TestCase { - protected static StartedTestContainer $container; + protected StartedTestContainer $container; protected function tearDown(): void { - self::$container->stop(); + $this->container->stop(); + parent::tearDown(); } } diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 4fc644f..7590db3 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -4,20 +4,110 @@ declare(strict_types=1); namespace Testcontainers\Tests\Integration; +use Docker\API\Model\ContainersIdJsonGetResponse200; +use PHPUnit\Framework\TestCase; use Testcontainers\Container\GenericContainer; +use Testcontainers\Utils\PortGenerator\FixedPortGenerator; +use Testcontainers\Wait\WaitForHostPort; -class GenericContainerTest extends ContainerTestCase +class GenericContainerTest extends TestCase { - public static function setUpBeforeClass(): void - { - self::$container = (new GenericContainer('alpine')) - ->withCommand(['tail', '-f', '/dev/null']) - ->start(); - } - public function testExec(): void { - $actual = self::$container->exec(['echo', 'testcontainers']); - self::assertSame('testcontainers', $actual); + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + $result = $container->exec(['echo', 'testcontainers']); + + self::assertSame('testcontainers', $result); + + $container->stop(); + } + + /** + * @throws \JsonException + */ + public function testShouldReturnFirstMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withPortGenerator(new FixedPortGenerator([8080])) + ->withExposedPorts(80) + ->withWait(new WaitForHostPort(8080)) + ->start(); + $firstMappedPort = $container->getFirstMappedPort(); + + self::assertSame($firstMappedPort, 8080, 'First mapped port does not match 8080'); + + $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 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); + } + + 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); } } diff --git a/tests/Integration/MariaDBContainerTest.php b/tests/Integration/MariaDBContainerTest.php index b2ad631..f88b359 100644 --- a/tests/Integration/MariaDBContainerTest.php +++ b/tests/Integration/MariaDBContainerTest.php @@ -8,9 +8,9 @@ use Testcontainers\Modules\MariaDBContainer; class MariaDBContainerTest extends ContainerTestCase { - public static function setUpBeforeClass(): void + public function setUp(): void { - self::$container = (new MariaDBContainer()) + $this->container = (new MariaDBContainer()) ->withMariaDBDatabase('foo') ->withMariaDBUser('bar', 'baz') ->start(); @@ -21,8 +21,8 @@ class MariaDBContainerTest extends ContainerTestCase $pdo = new \PDO( sprintf( 'mysql:host=%s;port=%d', - self::$container->getHost(), - self::$container->getFirstMappedPort() + $this->container->getHost(), + $this->container->getFirstMappedPort() ), 'bar', 'baz', diff --git a/tests/Integration/MySQLContainerTest.php b/tests/Integration/MySQLContainerTest.php index c88f911..8d3f867 100644 --- a/tests/Integration/MySQLContainerTest.php +++ b/tests/Integration/MySQLContainerTest.php @@ -8,9 +8,9 @@ use Testcontainers\Modules\MySQLContainer; class MySQLContainerTest extends ContainerTestCase { - public static function setUpBeforeClass(): void + public function setUp(): void { - self::$container = (new MySQLContainer()) + $this->container = (new MySQLContainer()) ->withMySQLDatabase('foo') ->withMySQLUser('bar', 'baz') ->start(); @@ -21,8 +21,8 @@ class MySQLContainerTest extends ContainerTestCase $pdo = new \PDO( sprintf( 'mysql:host=%s;port=%d', - self::$container->getHost(), - self::$container->getFirstMappedPort() + $this->container->getHost(), + $this->container->getFirstMappedPort() ), 'bar', 'baz', diff --git a/tests/Integration/OldTests/ContainerTest.php b/tests/Integration/OldTests/ContainerTest.php index 0f36535..74c16d1 100644 --- a/tests/Integration/OldTests/ContainerTest.php +++ b/tests/Integration/OldTests/ContainerTest.php @@ -13,6 +13,7 @@ use Testcontainers\Container\PostgresContainer; use Testcontainers\Container\RedisContainer; /** + * @group legacy * Old test classes kept to check backward compatibility */ class ContainerTest extends TestCase diff --git a/tests/Integration/OldTests/WaitStrategyTest.php b/tests/Integration/OldTests/WaitStrategyTest.php index 25ff3ec..a7c63cb 100644 --- a/tests/Integration/OldTests/WaitStrategyTest.php +++ b/tests/Integration/OldTests/WaitStrategyTest.php @@ -17,6 +17,7 @@ use Testcontainers\Wait\WaitForLog; use Testcontainers\Wait\WaitForTcpPortOpen; /** + * @group legacy * Old test classes kept to check backward compatibility */ class WaitStrategyTest extends TestCase diff --git a/tests/Integration/OpenSearchContainerTest.php b/tests/Integration/OpenSearchContainerTest.php index 19c34aa..b1134a5 100644 --- a/tests/Integration/OpenSearchContainerTest.php +++ b/tests/Integration/OpenSearchContainerTest.php @@ -8,9 +8,9 @@ use Testcontainers\Modules\OpenSearchContainer; class OpenSearchContainerTest extends ContainerTestCase { - public static function setUpBeforeClass(): void + public function setUp(): void { - self::$container = (new OpenSearchContainer()) + $this->container = (new OpenSearchContainer()) ->withDisabledSecurityPlugin() ->start(); } @@ -23,8 +23,8 @@ class OpenSearchContainerTest extends ContainerTestCase $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, sprintf( 'http://%s:%d', - self::$container->getHost(), - self::$container->getFirstMappedPort() + $this->container->getHost(), + $this->container->getFirstMappedPort() )); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); diff --git a/tests/Integration/PostgreSQLContainerTest.php b/tests/Integration/PostgreSQLContainerTest.php index e1f81c5..31e23cf 100644 --- a/tests/Integration/PostgreSQLContainerTest.php +++ b/tests/Integration/PostgreSQLContainerTest.php @@ -8,9 +8,9 @@ use Testcontainers\Modules\PostgresContainer; class PostgreSQLContainerTest extends ContainerTestCase { - public static function setUpBeforeClass(): void + public function setUp(): void { - self::$container = (new PostgresContainer()) + $this->container = (new PostgresContainer()) ->withPostgresUser('bar') ->withPostgresDatabase('foo') ->start(); @@ -21,8 +21,8 @@ class PostgreSQLContainerTest extends ContainerTestCase $pdo = new \PDO( sprintf( 'pgsql:host=%s;port=%d;dbname=foo', - self::$container->getHost(), - self::$container->getFirstMappedPort() + $this->container->getHost(), + $this->container->getFirstMappedPort() ), 'bar', 'test', diff --git a/tests/Integration/RedisContainerTest.php b/tests/Integration/RedisContainerTest.php index 0379dce..3e207d4 100644 --- a/tests/Integration/RedisContainerTest.php +++ b/tests/Integration/RedisContainerTest.php @@ -9,17 +9,17 @@ use Testcontainers\Modules\RedisContainer; class RedisContainerTest extends ContainerTestCase { - public static function setUpBeforeClass(): void + public function setUp(): void { - self::$container = (new RedisContainer()) + $this->container = (new RedisContainer()) ->start(); } public function testRedisContainer(): void { $redisClient = new Client([ - 'host' => self::$container->getHost(), - 'port' => self::$container->getFirstMappedPort(), + 'host' => $this->container->getHost(), + 'port' => $this->container->getFirstMappedPort(), ]); $redisClient->ping(); From 53c67a15ed325eeca2e5047aaa94a47fa371b5de Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Sun, 19 Jan 2025 23:12:19 +0100 Subject: [PATCH 2/3] added withCopy* functionality to copy data into containers. Additional tests and improvements --- src/Container/GenericContainer.php | 145 +++++++++- src/Utils/TarBuilder.php | 293 +++++++++++++++++++++ tests/Integration/GenericContainerTest.php | 175 ++++++++++++ tests/Unit/Utils/TarBuilderTest.php | 218 +++++++++++++++ 4 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 src/Utils/TarBuilder.php create mode 100644 tests/Unit/Utils/TarBuilderTest.php diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index ea85e7d..8afa2b1 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -16,10 +16,12 @@ use Docker\API\Model\PortBinding; use Docker\Docker; use Docker\Stream\CreateImageStream; use InvalidArgumentException; +use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; use Testcontainers\Utils\PortGenerator\PortGenerator; use Testcontainers\Utils\PortGenerator\RandomUniquePortGenerator; use Testcontainers\Utils\PortNormalizer; +use Testcontainers\Utils\TarBuilder; use Testcontainers\Wait\WaitForContainer; use Testcontainers\Wait\WaitStrategy; @@ -58,8 +60,28 @@ class GenericContainer implements TestContainer 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; @@ -94,6 +116,39 @@ class GenericContainer implements TestContainer return $this; } + /** + * @param array $files + */ + public function withCopyFilesToContainer(array $files): static + { + foreach ($files as $file) { + $this->filesToCopy[] = $file; + } + return $this; + } + + /** + * @param array $directories + */ + public function withCopyDirectoriesToContainer(array $directories): static + { + foreach ($directories as $directory) { + $this->directoriesToCopy[] = $directory; + } + return $this; + } + + /** + * @param array $contents + */ + public function withCopyContentToContainer(array $contents): static + { + foreach ($contents as $content) { + $this->contentsToCopy[] = $content; + } + return $this; + } + public function withEntryPoint(string $entryPoint): static { $this->entryPoint = $entryPoint; @@ -230,6 +285,20 @@ class GenericContainer implements TestContainer 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++; @@ -244,7 +313,7 @@ class GenericContainer implements TestContainer $this->id = $containerCreateResponse?->getId() ?? ''; } catch (ContainerCreateNotFoundException) { if ($this->startAttempts >= self::MAX_START_ATTEMPTS) { - throw new \RuntimeException("Failed to start container after pulling image."); + 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 @@ -254,12 +323,84 @@ class GenericContainer implements TestContainer $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(); @@ -267,6 +408,8 @@ class GenericContainer implements TestContainer $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); diff --git a/src/Utils/TarBuilder.php b/src/Utils/TarBuilder.php new file mode 100644 index 0000000..ba615e7 --- /dev/null +++ b/src/Utils/TarBuilder.php @@ -0,0 +1,293 @@ + + */ + 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 + { + // without --disable-copyfile and --no-xattrs combination, tar will fail on macOS + $cmd = sprintf( + 'tar --no-xattrs --disable-copyfile -cf %s -C %s . 2>&1', + 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/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 7590db3..2a7c9e9 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -6,6 +6,7 @@ namespace Testcontainers\Tests\Integration; use Docker\API\Model\ContainersIdJsonGetResponse200; use PHPUnit\Framework\TestCase; +use RuntimeException; use Testcontainers\Container\GenericContainer; use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForHostPort; @@ -24,6 +25,94 @@ class GenericContainerTest extends TestCase $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(); + } + /** * @throws \JsonException */ @@ -41,6 +130,68 @@ class GenericContainerTest extends TestCase $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')) @@ -66,6 +217,26 @@ class GenericContainerTest extends TestCase $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')) @@ -95,6 +266,8 @@ class GenericContainerTest extends TestCase $result = $container->exec(["cat", $containerPath.'/test.txt']); self::assertSame('hello world', $result); + + $container->stop(); } public function testShouldSetPrivilegedMode(): void @@ -109,5 +282,7 @@ class GenericContainerTest extends TestCase $privileged = $inspectResult?->getHostConfig()?->getPrivileged(); self::assertTrue($privileged); + + $container->stop(); } } diff --git a/tests/Unit/Utils/TarBuilderTest.php b/tests/Unit/Utils/TarBuilderTest.php new file mode 100644 index 0000000..b2f326f --- /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); + } +} From 9d83a05bb1c60139d701edd3fa86100e3bdaf299 Mon Sep 17 00:00:00 2001 From: Sergei Shitikov Date: Tue, 21 Jan 2025 19:27:32 +0100 Subject: [PATCH 3/3] use array_merge instead of foreach on copy methods --- src/Container/GenericContainer.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 8afa2b1..65555f7 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -121,9 +121,8 @@ class GenericContainer implements TestContainer */ public function withCopyFilesToContainer(array $files): static { - foreach ($files as $file) { - $this->filesToCopy[] = $file; - } + $this->filesToCopy = array_merge($this->filesToCopy, $files); + return $this; } @@ -132,9 +131,8 @@ class GenericContainer implements TestContainer */ public function withCopyDirectoriesToContainer(array $directories): static { - foreach ($directories as $directory) { - $this->directoriesToCopy[] = $directory; - } + $this->directoriesToCopy = array_merge($this->directoriesToCopy, $directories); + return $this; } @@ -143,9 +141,8 @@ class GenericContainer implements TestContainer */ public function withCopyContentToContainer(array $contents): static { - foreach ($contents as $content) { - $this->contentsToCopy[] = $content; - } + $this->contentsToCopy = array_merge($this->contentsToCopy, $contents); + return $this; }