Merge pull request #27 from rw4lll/feat/started-generic-container

Feat/started generic container
This commit is contained in:
Shyim
2025-02-06 20:01:09 +01:00
committed by GitHub
8 changed files with 699 additions and 51 deletions
+1 -1
View File
@@ -45,7 +45,7 @@
"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"
"phpstan": "phpstan analyse --memory-limit=256M"
},
"config": {
"allow-plugins": {
+89 -47
View File
@@ -6,21 +6,27 @@ namespace Testcontainers\Container;
use Docker\API\Client;
use Docker\API\Model\ContainersIdExecPostBody;
use Docker\API\Model\ContainersIdJsonGetResponse200;
use Docker\API\Model\EndpointSettings;
use Docker\API\Model\IdResponse;
use Docker\API\Model\PortBinding;
use Docker\API\Runtime\Client\Client as DockerRuntimeClient;
use Docker\Docker;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
use Testcontainers\ContainerClient\DockerContainerClient;
use Testcontainers\Utils\HostResolver;
class StartedGenericContainer implements StartedTestContainer
{
protected Docker $dockerClient;
protected ?ContainersIdJsonGetResponse200 $inspectResponse = null;
protected ?string $lastExecId = null;
public function __construct(protected readonly string $id)
public function __construct(protected readonly string $id, ?Docker $dockerClient = null)
{
$this->dockerClient = DockerContainerClient::getDockerClient();
$this->dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient();
}
public function getId(): string
@@ -53,7 +59,7 @@ class StartedGenericContainer implements StartedTestContainer
$exec = $this->dockerClient->containerExec($this->id, $execConfig);
if ($exec === null || $exec->getId() === null) {
throw new \RuntimeException('Failed to create exec command');
throw new RuntimeException('Failed to create exec command');
}
$this->lastExecId = $exec->getId();
@@ -63,7 +69,7 @@ class StartedGenericContainer implements StartedTestContainer
?->getBody()
->getContents() ?? '';
return preg_replace('/[\x00-\x1F\x7F]/u', '', $contents) ?? '';
return $this->sanitizeOutput($contents);
}
public function stop(): StoppedTestContainer
@@ -92,81 +98,117 @@ class StartedGenericContainer implements StartedTestContainer
?->getBody()
->getContents() ?? '';
return preg_replace('/[\x00-\x1F\x7F]/u', '', mb_convert_encoding($output, 'UTF-8', 'UTF-8')) ?? '';
return $this->sanitizeOutput(mb_convert_encoding($output, 'UTF-8', 'UTF-8'));
}
//TODO: replace with the proper implementation
public function getHost(): string
{
return '127.0.0.1';
return (new HostResolver($this->dockerClient))->resolveHost();
}
//TODO: not ready yet
public function getMappedPort(int $port): int
{
return $this->inspect()->ports[$port];
$ports = (array) $this->ports();
/** @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");
}
/**
* @throws \JsonException
*/
public function getFirstMappedPort(): int
{
//For some reason, containerInspect can crash when using FETCH_OBJECT option (e.g. with OpenSearch)
//should be checked within beluga-php/docker-php client library
/** @var ResponseInterface | null $containerInspectResponse */
$containerInspectResponse = $this->dockerClient->containerInspect($this->id, [], Docker::FETCH_RESPONSE);
if ($containerInspectResponse === null) {
throw new \RuntimeException('Failed to inspect container');
}
$containerInspectResponseAsArray = json_decode(
$containerInspectResponse->getBody()->getContents(),
true,
512,
JSON_THROW_ON_ERROR
);
/** @var array<string, array<array<string, string>>> $ports */
$ports = $containerInspectResponseAsArray['NetworkSettings']['Ports'] ?? [];
if ($ports === []) {
throw new \RuntimeException('Failed to get ports from container');
}
$ports = (array) $this->ports();
$port = array_key_first($ports);
/** @var PortBinding | null $firstPortBinding */
$firstPortBinding = $ports[$port][0] ?? null;
$firstMappedPort = $firstPortBinding?->getHostPort();
if ($firstMappedPort !== null) {
return (int) $firstMappedPort;
}
return (int) $ports[$port][0]['HostPort'];
throw new RuntimeException('Failed to get first mapped port for container');
}
public function getName(): string
{
// TODO: Implement getName() method.
return '';
return trim($this->inspect()?->getName() ?? '', '/ ');
}
/**
* @return array<string, string>
*/
public function getLabels(): array
{
// TODO: Implement getLabels() method.
return [];
return (array) $this->inspect()?->getConfig()?->getLabels();
}
/**
* @return string[]
*/
public function getNetworkNames(): array
{
// TODO: Implement getNetworkNames() method.
return [];
$networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks();
return array_keys($networks);
}
public function getNetworkId(string $networkName): string
{
// TODO: Implement getNetworkId() method.
return '';
$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
{
// TODO: Implement getIpAddress() method.
return '';
$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 array<string, array<PortBinding>>
* @throws RuntimeException
*/
protected function ports(): 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) ?? '';
}
}
+6
View File
@@ -23,6 +23,9 @@ interface StartedTestContainer
public function getIpAddress(string $networkName): string;
/**
* @return array<string, string>
*/
public function getLabels(): array;
public function logs(): string;
@@ -35,6 +38,9 @@ interface StartedTestContainer
public function getNetworkId(string $networkName): string;
/**
* @return string[]
*/
public function getNetworkNames(): array;
public function restart(): self;
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Utils;
use Docker\API\Model\Network;
use Docker\Docker;
use RuntimeException;
use Testcontainers\Container\GenericContainer;
use Testcontainers\ContainerClient\DockerContainerClient;
class HostResolver
{
public function __construct(protected ?Docker $dockerClient = null)
{
$this->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) {
//
}
}
}
}
}
+3 -1
View File
@@ -13,7 +13,9 @@ abstract class ContainerTestCase extends TestCase
protected function tearDown(): void
{
$this->container->stop();
if (isset($this->container)) {
$this->container->stop();
}
parent::tearDown();
}
}
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Integration;
use Testcontainers\Container\GenericContainer;
class StartedGenericContainerTest extends ContainerTestCase
{
public function testShouldReturnContainerId(): void
{
$container = (new GenericContainer('alpine'))
->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');
}
}
+253
View File
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace Testcontainers\Tests\Unit\Utils;
use Docker\Docker;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Testcontainers\Utils\HostResolver;
class HostResolverTest extends TestCase
{
protected function setUp(): void
{
putenv('TESTCONTAINERS_HOST_OVERRIDE');
putenv('DOCKER_HOST');
}
protected function tearDown(): void
{
putenv('TESTCONTAINERS_HOST_OVERRIDE');
putenv('DOCKER_HOST');
}
public function testReturnsTestcontainersHostOverrideFromEnvironment(): void
{
// When the override is set, it should be returned.
putenv('TESTCONTAINERS_HOST_OVERRIDE=tcp://another:2375');
putenv('DOCKER_HOST=tcp://docker:2375');
$dummyClient = $this->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();
}
}
+2 -2
View File
@@ -146,7 +146,7 @@ class TarBuilderTest extends TestCase
mkdir($extractDir);
$this->extractTar($tarPath, $extractDir);
$scanned = array_diff(scandir($extractDir), ['.', '..']);
$scanned = array_diff(scandir($extractDir) ?: [], ['.', '..']);
$this->assertCount(0, $scanned, 'Expected empty directory');
}
@@ -166,7 +166,7 @@ class TarBuilderTest extends TestCase
mkdir($extractDir);
$this->extractTar($tarPath, $extractDir);
$scanned = array_diff(scandir($extractDir), ['.', '..']);
$scanned = array_diff(scandir($extractDir) ?: [], ['.', '..']);
$this->assertCount(0, $scanned, 'Expected no files after clear()');
}