mirror of
https://github.com/stan220/testcontainers-php.git
synced 2026-11-07 16:02:38 +00:00
added withCopy* functionality to copy data into containers. Additional tests and improvements
This commit is contained in:
@@ -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<array{source: string, target: string, mode?: int}>
|
||||
*/
|
||||
protected array $filesToCopy = [];
|
||||
|
||||
/**
|
||||
* @var array<array{source: string, target: string, mode?: int}>
|
||||
*/
|
||||
protected array $directoriesToCopy = [];
|
||||
|
||||
/**
|
||||
* @var array<array{content: string, target: string, mode?: int}>
|
||||
*/
|
||||
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<array{source: string, target: string, mode?: int}> $files
|
||||
*/
|
||||
public function withCopyFilesToContainer(array $files): static
|
||||
{
|
||||
foreach ($files as $file) {
|
||||
$this->filesToCopy[] = $file;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array{source: string, target: string, mode?: int}> $directories
|
||||
*/
|
||||
public function withCopyDirectoriesToContainer(array $directories): static
|
||||
{
|
||||
foreach ($directories as $directory) {
|
||||
$this->directoriesToCopy[] = $directory;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array{content: string, target: string, mode?: int}> $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);
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Testcontainers\Utils;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
use SplFileInfo;
|
||||
|
||||
class TarBuilder
|
||||
{
|
||||
/**
|
||||
* @var array<array{source: string, target: string, mode: int|null}>
|
||||
*/
|
||||
private array $files = [];
|
||||
|
||||
/**
|
||||
* @var array<array{source: string, target: string, mode: int|null}>
|
||||
*/
|
||||
private array $directories = [];
|
||||
|
||||
/**
|
||||
* @var array<array{content: string, target: string, mode: int|null}>
|
||||
*/
|
||||
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<array{source: string, target: string, mode: int|null}> $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<array{source: string, target: string, mode: int|null}> $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<array{content: string, target: string, mode: int|null}> $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<RecursiveDirectoryIterator> $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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user