From 162bc28b9f9412fc20102af7bdbb98b20fa46166 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Sat, 27 Dec 2025 12:07:38 +0100 Subject: [PATCH 1/6] feat: integrate sdk --- .github/workflows/php.yml | 10 +- composer.json | 8 +- src/Container/GenericContainer.php | 41 +-- src/Container/StartedGenericContainer.php | 42 ++- src/Container/StartedTestContainer.php | 6 +- src/ContainerClient/DockerContainerClient.php | 60 +---- src/Docker/Client/ClientInterface.php | 27 ++ src/Docker/Client/CurlClient.php | 167 ++++++++++++ src/Docker/DockerClient.php | 251 ++++++++++++++++++ src/Docker/DockerHostConfig.php | 130 +++++++++ src/Docker/DockerResponse.php | 55 ++++ src/Docker/DockerResponseBody.php | 17 ++ .../ContainerCreateNotFoundException.php | 11 + .../Exception/DockerRequestException.php | 32 +++ src/Docker/Model/ContainerConfig.php | 58 ++++ src/Docker/Model/ContainerCreateResponse.php | 24 ++ src/Docker/Model/ContainerHealth.php | 25 ++ src/Docker/Model/ContainerState.php | 35 +++ src/Docker/Model/ContainersCreatePostBody.php | 180 +++++++++++++ src/Docker/Model/ContainersIdExecPostBody.php | 49 ++++ .../Model/ContainersIdJsonGetResponse200.php | 65 +++++ src/Docker/Model/EndpointSettings.php | 82 ++++++ src/Docker/Model/ExecIdJsonGetResponse200.php | 25 ++ src/Docker/Model/HealthConfig.php | 128 +++++++++ src/Docker/Model/HostConfig.php | 182 +++++++++++++ src/Docker/Model/IdResponse.php | 24 ++ src/Docker/Model/Mount.php | 82 ++++++ src/Docker/Model/Network.php | 29 ++ src/Docker/Model/NetworkIPAM.php | 40 +++ src/Docker/Model/NetworkIPAMConfig.php | 25 ++ src/Docker/Model/NetworkSettings.php | 81 ++++++ src/Docker/Model/NetworkingConfig.php | 37 +++ src/Docker/Model/PortBinding.php | 63 +++++ src/Docker/Stream/CreateImageStream.php | 22 ++ src/Utils/HostResolver.php | 6 +- src/Wait/WaitForContainer.php | 2 +- src/Wait/WaitForExec.php | 2 +- src/Wait/WaitForHealthCheck.php | 2 +- tests/Integration/GenericContainerTest.php | 2 +- .../DockerContainerClientTest.php | 171 ++---------- tests/Unit/Docker/DockerClientTest.php | 45 ++++ tests/Unit/Utils/HostResolverTest.php | 61 ++--- 42 files changed, 2105 insertions(+), 299 deletions(-) create mode 100644 src/Docker/Client/ClientInterface.php create mode 100644 src/Docker/Client/CurlClient.php create mode 100644 src/Docker/DockerClient.php create mode 100644 src/Docker/DockerHostConfig.php create mode 100644 src/Docker/DockerResponse.php create mode 100644 src/Docker/DockerResponseBody.php create mode 100644 src/Docker/Exception/ContainerCreateNotFoundException.php create mode 100644 src/Docker/Exception/DockerRequestException.php create mode 100644 src/Docker/Model/ContainerConfig.php create mode 100644 src/Docker/Model/ContainerCreateResponse.php create mode 100644 src/Docker/Model/ContainerHealth.php create mode 100644 src/Docker/Model/ContainerState.php create mode 100644 src/Docker/Model/ContainersCreatePostBody.php create mode 100644 src/Docker/Model/ContainersIdExecPostBody.php create mode 100644 src/Docker/Model/ContainersIdJsonGetResponse200.php create mode 100644 src/Docker/Model/EndpointSettings.php create mode 100644 src/Docker/Model/ExecIdJsonGetResponse200.php create mode 100644 src/Docker/Model/HealthConfig.php create mode 100644 src/Docker/Model/HostConfig.php create mode 100644 src/Docker/Model/IdResponse.php create mode 100644 src/Docker/Model/Mount.php create mode 100644 src/Docker/Model/Network.php create mode 100644 src/Docker/Model/NetworkIPAM.php create mode 100644 src/Docker/Model/NetworkIPAMConfig.php create mode 100644 src/Docker/Model/NetworkSettings.php create mode 100644 src/Docker/Model/NetworkingConfig.php create mode 100644 src/Docker/Model/PortBinding.php create mode 100644 src/Docker/Stream/CreateImageStream.php create mode 100644 tests/Unit/Docker/DockerClientTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 1659e7d..4667ab6 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -47,8 +47,12 @@ jobs: run: composer run phpstan phpunit: - runs-on: ubuntu-latest - name: Integration Tests + runs-on: ${{ matrix.os }} + name: Integration Tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 @@ -57,7 +61,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.1' - extensions: redis, pgsql, mongodb-mongodb/mongo-php-driver@1.15.0 + extensions: curl, pdo, pdo_mysql, pdo_pgsql, redis - name: Install dependencies run: composer install --prefer-dist --no-progress diff --git a/composer.json b/composer.json index 7e12d8b..3f6b401 100644 --- a/composer.json +++ b/composer.json @@ -15,10 +15,7 @@ ], "require": { "ext-curl": "*", - "php": ">= 8.1", - "beluga-php/docker-php": "^1.45.7", - "beluga-php/docker-php-api": "^7.1.45.5", - "php-http/client-common": "^2.7" + "php": ">= 8.1" }, "require-dev": { "ext-pdo": "*", @@ -59,8 +56,7 @@ }, "config": { "allow-plugins": { - "phpstan/extension-installer": true, - "php-http/discovery": false + "phpstan/extension-installer": true } } } diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 34c6408..b46bf49 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -4,17 +4,17 @@ namespace Testcontainers\Container; -use Docker\API\Exception\ContainerCreateNotFoundException; -use Docker\API\Model\ContainerCreateResponse; -use Docker\API\Model\ContainersCreatePostBody; -use Docker\API\Model\EndpointSettings; -use Docker\API\Model\HealthConfig; -use Docker\API\Model\HostConfig; -use Docker\API\Model\Mount; -use Docker\API\Model\NetworkingConfig; -use Docker\API\Model\PortBinding; -use Docker\Docker; -use Docker\Stream\CreateImageStream; +use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\Exception\ContainerCreateNotFoundException; +use Testcontainers\Docker\Model\ContainerCreateResponse; +use Testcontainers\Docker\Model\ContainersCreatePostBody; +use Testcontainers\Docker\Model\EndpointSettings; +use Testcontainers\Docker\Model\HealthConfig; +use Testcontainers\Docker\Model\HostConfig; +use Testcontainers\Docker\Model\Mount; +use Testcontainers\Docker\Model\NetworkingConfig; +use Testcontainers\Docker\Model\PortBinding; +use Testcontainers\Docker\Stream\CreateImageStream; use InvalidArgumentException; use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; @@ -28,7 +28,7 @@ class GenericContainer implements TestContainer { - protected Docker $dockerClient; + protected DockerClient $dockerClient; protected string $image; @@ -402,10 +402,7 @@ protected function copyToContainer( $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. - * */ + // Ensure the request body is fully sent and response is consumed. $result = $this->dockerClient->putContainerArchive( $this->id, $handle, @@ -434,6 +431,14 @@ protected function createContainerConfig(): ContainersCreatePostBody $hostConfig = $this->createHostConfig(); $containerCreatePostBody->setHostConfig($hostConfig); + if ($this->exposedPorts !== []) { + $exposed = []; + foreach ($this->exposedPorts as $port) { + $exposed[$port] = new \stdClass(); + } + $containerCreatePostBody->setExposedPorts($exposed); + } + if ($this->entryPoint !== null) { $containerCreatePostBody->setEntrypoint([$this->entryPoint]); } @@ -517,7 +522,7 @@ protected function pullImage(): void { [$fromImage, $tag] = explode(':', $this->image) + [1 => 'latest']; - // Build headers for the request + // Build headers for the request (curl-style list format) $headers = []; // Try to get authentication for the registry @@ -530,7 +535,7 @@ protected function pullImage(): void 'username' => $credentials['username'], 'password' => $credentials['password'], ]; - $headers['X-Registry-Auth'] = base64_encode(json_encode($authData, JSON_THROW_ON_ERROR)); + $headers[] = 'X-Registry-Auth: ' . base64_encode(json_encode($authData, JSON_THROW_ON_ERROR)); } /** @var CreateImageStream $imageCreateResponse */ diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index d79eed0..2b0064e 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -4,28 +4,25 @@ 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\ExecIdStartPostBody; -use Docker\API\Model\IdResponse; -use Docker\API\Model\PortBinding; -use Docker\API\Runtime\Client\Client as DockerRuntimeClient; -use Docker\Docker; +use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\Model\ContainersIdExecPostBody; +use Testcontainers\Docker\Model\ContainersIdJsonGetResponse200; +use Testcontainers\Docker\Model\EndpointSettings; +use Testcontainers\Docker\Model\IdResponse; +use Testcontainers\Docker\Model\PortBinding; use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; use Testcontainers\Utils\HostResolver; class StartedGenericContainer implements StartedTestContainer { - protected Docker $dockerClient; + protected DockerClient $dockerClient; protected ?ContainersIdJsonGetResponse200 $inspectResponse = null; protected ?string $lastExecId = null; - public function __construct(protected readonly string $id, ?Docker $dockerClient = null) + public function __construct(protected readonly string $id, ?DockerClient $dockerClient = null) { $this->dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); } @@ -40,7 +37,7 @@ public function getLastExecId(): ?string return $this->lastExecId; } - public function getClient(): Docker + public function getClient(): DockerClient { return $this->dockerClient; } @@ -65,13 +62,10 @@ public function exec(array $command): string $this->lastExecId = $exec->getId(); - $startConfig = new ExecIdStartPostBody(); - $startConfig->setDetach(false); - $contents = $this->dockerClient - ->execStart($this->lastExecId, $startConfig, Client::FETCH_RESPONSE) - ->getBody() - ->getContents(); + ->execStart($this->lastExecId, null, DockerClient::FETCH_RESPONSE) + ?->getBody() + ->getContents() ?? ''; return $this->sanitizeOutput($contents); } @@ -97,10 +91,10 @@ public function logs(): string ->containerLogs( $this->id, ['stdout' => true, 'stderr' => true], - DockerRuntimeClient::FETCH_RESPONSE + DockerClient::FETCH_RESPONSE ) - ->getBody() - ->getContents(); + ?->getBody() + ->getContents() ?? ''; /** * @var string|false $converted @@ -213,10 +207,12 @@ public function getBoundPorts(): iterable * For some reason, in the latest Docker releases, at this moment, the container might not be fully started. * This can lead to issues when trying to retrieve the ports. * TODO: find a better strategy to ensure the container is fully started or run in a loop until it is ready. - * For the loop $this->inspect() shouldn't be cached. */ usleep(300 * 1000); - $ports = $this->inspect()?->getNetworkSettings()?->getPorts(); + /** @var ContainersIdJsonGetResponse200 | null $inspectResponse */ + $inspectResponse = $this->dockerClient->containerInspect($this->id); + $this->inspectResponse = $inspectResponse; + $ports = $inspectResponse?->getNetworkSettings()?->getPorts(); if ($ports === null) { throw new RuntimeException('Failed to get ports from container'); diff --git a/src/Container/StartedTestContainer.php b/src/Container/StartedTestContainer.php index 551d9be..3ec228c 100644 --- a/src/Container/StartedTestContainer.php +++ b/src/Container/StartedTestContainer.php @@ -4,8 +4,8 @@ namespace Testcontainers\Container; -use Docker\API\Model\PortBinding; -use Docker\Docker; +use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\Model\PortBinding; interface StartedTestContainer { @@ -19,7 +19,7 @@ public function exec(array $command): string; */ public function getBoundPorts(): iterable; - public function getClient(): Docker; + public function getClient(): DockerClient; public function getFirstMappedPort(): int; diff --git a/src/ContainerClient/DockerContainerClient.php b/src/ContainerClient/DockerContainerClient.php index e308a68..096b514 100644 --- a/src/ContainerClient/DockerContainerClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -5,11 +5,7 @@ namespace Testcontainers\ContainerClient; use Composer\InstalledVersions; -use Docker\Docker as DockerClient; -use Docker\DockerClientFactory; -use Http\Client\Common\Plugin\HeaderDefaultsPlugin; -use Http\Client\Common\PluginClient; -use Psr\Http\Client\ClientInterface; +use Testcontainers\Docker\DockerClient; class DockerContainerClient { @@ -19,13 +15,7 @@ class DockerContainerClient private static ?DockerClient $dockerClient = null; /** - * @var (callable(): ClientInterface)|null Factory for the base HTTP client. - * When null, DockerClientFactory::createFromEnv() is used. - */ - private static $httpClientFactory = null; - - /** - * @var (callable(ClientInterface): DockerClient)|null Factory for the Docker client. + * @var (callable(string): DockerClient)|null Factory for the Docker client, receiving the User-Agent string. * When null, DockerClient::create() is used. */ private static $dockerClientFactory = null; @@ -43,16 +33,11 @@ private function __construct() public static function getDockerClient(): DockerClient { if (self::$dockerClient === null) { - $version = static::resolveVersion(); - - $baseHttpClient = self::createHttpClient(); + $userAgent = 'tc-php/' . static::resolveVersion(); - $httpClient = new PluginClient( - $baseHttpClient, - [new HeaderDefaultsPlugin(['User-Agent' => 'tc-php/' . $version])] - ); - - self::$dockerClient = self::createDockerClient($httpClient); + self::$dockerClient = self::$dockerClientFactory !== null + ? (self::$dockerClientFactory)($userAgent) + : DockerClient::create($userAgent); } return self::$dockerClient; @@ -79,26 +64,6 @@ protected static function resolveVersion(string $package = 'testcontainers/testc return str_replace('+no-version-set', '', $version); } - /** - * Returns the base HTTP client, using the injected factory if set. - */ - private static function createHttpClient(): ClientInterface - { - return self::$httpClientFactory !== null - ? (self::$httpClientFactory)() - : DockerClientFactory::createFromEnv(); - } - - /** - * Builds the DockerClient from the given HTTP client, using the injected factory if set. - */ - private static function createDockerClient(ClientInterface $httpClient): DockerClient - { - return self::$dockerClientFactory !== null - ? (self::$dockerClientFactory)($httpClient) - : DockerClient::create($httpClient); - } - /** * Injects a DockerClient instance for testing or special use cases. * Note: clients injected via this method will not have the tc-php User-Agent header applied automatically. @@ -111,25 +76,22 @@ public static function setDockerClient(DockerClient $client): void } /** - * Resets the injectable factories to their defaults. + * Injects a factory used to build the DockerClient from the User-Agent string. * For use in tests only — do not call in production code. * - * @param (callable(): ClientInterface)|null $httpClientFactory - * @param (callable(ClientInterface): DockerClient)|null $dockerClientFactory + * @param (callable(string): DockerClient)|null $dockerClientFactory */ - public static function setFactories(?callable $httpClientFactory, ?callable $dockerClientFactory): void + public static function setDockerClientFactory(?callable $dockerClientFactory): void { - self::$httpClientFactory = $httpClientFactory; self::$dockerClientFactory = $dockerClientFactory; } /** - * Resets the injectable factories to their defaults (both null). + * Resets the injectable factory to its default (null). * For use in tests only — do not call in production code. */ - public static function resetFactories(): void + public static function resetDockerClientFactory(): void { - self::$httpClientFactory = null; self::$dockerClientFactory = null; } } diff --git a/src/Docker/Client/ClientInterface.php b/src/Docker/Client/ClientInterface.php new file mode 100644 index 0000000..d01ac83 --- /dev/null +++ b/src/Docker/Client/ClientInterface.php @@ -0,0 +1,27 @@ + $query + * @param list $headers + */ + public function request(string $method, string $path, array $query = [], ?string $body = null, array $headers = []): DockerResponse; + + /** + * @param non-empty-string $method + * @param non-empty-string $path + * @param resource $handle + * @param array $query + * @param list $headers + */ + public function requestStream(string $method, string $path, $handle, array $query = [], array $headers = []): DockerResponse; +} diff --git a/src/Docker/Client/CurlClient.php b/src/Docker/Client/CurlClient.php new file mode 100644 index 0000000..1b9f2ab --- /dev/null +++ b/src/Docker/Client/CurlClient.php @@ -0,0 +1,167 @@ + $query + * @param list $headers + */ + public function request(string $method, string $path, array $query = [], ?string $body = null, array $headers = []): DockerResponse + { + $url = $this->buildUrl($path, $query); + $ch = $this->initCurl($url); + + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + + if ($body !== null) { + $headers[] = 'Content-Type: application/json'; + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + if ($headers !== []) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + } + + return $this->executeRequest($ch); + } + + /** + * @param non-empty-string $method + * @param non-empty-string $path + * @param resource $handle + * @param array $query + * @param list $headers + */ + public function requestStream(string $method, string $path, $handle, array $query = [], array $headers = []): DockerResponse + { + $url = $this->buildUrl($path, $query); + $ch = $this->initCurl($url); + + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_UPLOAD, true); + curl_setopt($ch, CURLOPT_INFILE, $handle); + + $stats = fstat($handle); + if (is_array($stats)) { + curl_setopt($ch, CURLOPT_INFILESIZE, $stats['size']); + } + + if ($headers !== []) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + } + + return $this->executeRequest($ch); + } + + /** + * @param non-empty-string $url + */ + private function initCurl(string $url): \CurlHandle + { + $ch = curl_init(); + if ($ch === false) { + throw new RuntimeException('Failed to initialize curl'); + } + + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HEADER, false); + + if ($this->userAgent !== null) { + curl_setopt($ch, CURLOPT_USERAGENT, $this->userAgent); + } + + if ($this->unixSocketPath !== null) { + curl_setopt($ch, CURLOPT_UNIX_SOCKET_PATH, $this->unixSocketPath); + } + + if ($this->tlsEnabled) { + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $this->tlsVerify); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $this->tlsVerify ? 2 : 0); + + if ($this->certPath !== null) { + $certFile = rtrim($this->certPath, '/') . '/cert.pem'; + $keyFile = rtrim($this->certPath, '/') . '/key.pem'; + $caFile = rtrim($this->certPath, '/') . '/ca.pem'; + + if (is_file($certFile)) { + curl_setopt($ch, CURLOPT_SSLCERT, $certFile); + } + if (is_file($keyFile)) { + curl_setopt($ch, CURLOPT_SSLKEY, $keyFile); + } + if (is_file($caFile)) { + curl_setopt($ch, CURLOPT_CAINFO, $caFile); + } + } + } + + return $ch; + } + + private function executeRequest(\CurlHandle $ch): DockerResponse + { + $body = curl_exec($ch); + if ($body === false) { + $error = curl_error($ch); + $this->closeCurl($ch); + throw new RuntimeException($error); + } + + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $this->closeCurl($ch); + + return new DockerResponse($status, (string) $body); + } + + private function closeCurl(\CurlHandle $ch): void + { + if (PHP_VERSION_ID < 80500) { + curl_close($ch); + } + } + + /** + * @param non-empty-string $path + * @param array $query + * @return non-empty-string + */ + private function buildUrl(string $path, array $query = []): string + { + $versionedPath = $this->apiVersion !== null + ? '/v' . $this->apiVersion . $path + : $path; + + $url = $this->baseUri . $versionedPath; + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + return $url; + } +} diff --git a/src/Docker/DockerClient.php b/src/Docker/DockerClient.php new file mode 100644 index 0000000..358967b --- /dev/null +++ b/src/Docker/DockerClient.php @@ -0,0 +1,251 @@ +baseUri, + $config->unixSocketPath, + $config->tlsEnabled, + $config->tlsVerify, + $config->certPath, + $config->apiVersion, + $userAgent + )); + } + + /** + * @param array $query + */ + public function containerCreate(ContainersCreatePostBody $body, array $query = []): ?ContainerCreateResponse + { + $response = $this->requestJson('POST', '/containers/create', $query, $body->toArray()); + + if ($response->getStatusCode() === 404) { + throw new ContainerCreateNotFoundException('Docker image not found'); + } + + $this->assertSuccess($response, 'Create container'); + + return new ContainerCreateResponse($this->decodeResponse($response)); + } + + public function containerStart(string $id): void + { + $response = $this->request('POST', "/containers/{$id}/start"); + $this->assertStatus($response, 'Start container', [204, 304]); + } + + public function containerStop(string $id): void + { + $response = $this->request('POST', "/containers/{$id}/stop"); + $this->assertStatus($response, 'Stop container', [204, 304]); + } + + public function containerDelete(string $id): void + { + $response = $this->request('DELETE', "/containers/{$id}"); + $this->assertStatus($response, 'Delete container', [204, 404]); + } + + public function containerRestart(string $id): void + { + $response = $this->request('POST', "/containers/{$id}/restart"); + $this->assertSuccess($response, 'Restart container'); + } + + public function containerExec(string $id, ContainersIdExecPostBody $body): ?IdResponse + { + $response = $this->requestJson('POST', "/containers/{$id}/exec", [], $body->toArray()); + $this->assertSuccess($response, 'Create exec'); + + return new IdResponse($this->decodeResponse($response)); + } + + /** + * @param array|null $config + */ + public function execStart(string $id, ?array $config = null, int $fetch = self::FETCH_RESPONSE): ?DockerResponse + { + $payload = $config ?? [ + 'Detach' => false, + 'Tty' => false, + ]; + + $response = $this->requestJson('POST', "/exec/{$id}/start", [], $payload); + $this->assertSuccess($response, 'Start exec'); + + return $fetch === self::FETCH_RESPONSE ? $response : null; + } + + public function execInspect(string $id): ?ExecIdJsonGetResponse200 + { + $response = $this->request('GET', "/exec/{$id}/json"); + $this->assertSuccess($response, 'Inspect exec'); + + return new ExecIdJsonGetResponse200($this->decodeResponse($response)); + } + + /** + * @param array $params + */ + public function containerLogs(string $id, array $params = [], int $fetch = self::FETCH_RESPONSE): ?DockerResponse + { + $response = $this->request('GET', "/containers/{$id}/logs", $params); + $this->assertStatus($response, 'Container logs', [200, 404]); + + return $fetch === self::FETCH_RESPONSE ? $response : null; + } + + public function containerInspect(string $id): ?ContainersIdJsonGetResponse200 + { + $response = $this->request('GET', "/containers/{$id}/json"); + $this->assertSuccess($response, 'Inspect container'); + + return new ContainersIdJsonGetResponse200($this->decodeResponse($response)); + } + + /** + * @param resource $handle + * @param array $query + */ + public function putContainerArchive(string $id, $handle, array $query = [], int $fetch = self::FETCH_RESPONSE): ?DockerResponse + { + $headers = [ + 'Content-Type: application/x-tar', + ]; + $response = $this->requestStream('PUT', "/containers/{$id}/archive", $handle, $query, $headers); + $this->assertSuccess($response, 'Upload container archive'); + + return $fetch === self::FETCH_RESPONSE ? $response : null; + } + + /** + * @param array $query + * @param list $headers + */ + public function imageCreate(?string $name, array $query = [], array $headers = []): CreateImageStream + { + $response = $this->request('POST', '/images/create', $query, null, $headers); + $this->assertSuccess($response, 'Create image'); + + return new CreateImageStream($response->getBodyContents()); + } + + public function networkInspect(string $name): ?Network + { + $response = $this->request('GET', "/networks/{$name}"); + $this->assertSuccess($response, 'Inspect network'); + + return new Network($this->decodeResponse($response)); + } + + /** + * @param non-empty-string $method + * @param non-empty-string $path + * @param array $query + * @param list $headers + */ + private function request(string $method, string $path, array $query = [], ?string $body = null, array $headers = []): DockerResponse + { + return $this->client->request($method, $path, $query, $body, $headers); + } + + /** + * @param non-empty-string $method + * @param non-empty-string $path + * @param array $query + * @param array $payload + */ + private function requestJson(string $method, string $path, array $query = [], array $payload = []): DockerResponse + { + $body = $payload === [] ? '' : json_encode($payload, JSON_THROW_ON_ERROR); + + return $this->request($method, $path, $query, $body); + } + + /** + * @param non-empty-string $method + * @param non-empty-string $path + * @param array $query + * @param list $headers + * @param resource $handle + */ + private function requestStream(string $method, string $path, $handle, array $query = [], array $headers = []): DockerResponse + { + return $this->client->requestStream($method, $path, $handle, $query, $headers); + } + + /** + * @return array + */ + private function decodeResponse(DockerResponse $response): array + { + return $response->getJson(); + } + + private function assertSuccess(DockerResponse $response, string $action): void + { + $status = $response->getStatusCode(); + if ($status >= 200 && $status < 300) { + return; + } + + throw new DockerRequestException( + $action . ' failed', + $status, + $response->getBodyContents() + ); + } + + /** + * @param array $allowedStatuses + */ + private function assertStatus(DockerResponse $response, string $action, array $allowedStatuses): void + { + $status = $response->getStatusCode(); + if (in_array($status, $allowedStatuses, true)) { + return; + } + + if ($status >= 200 && $status < 300) { + return; + } + + throw new DockerRequestException( + $action . ' failed', + $status, + $response->getBodyContents() + ); + } +} diff --git a/src/Docker/DockerHostConfig.php b/src/Docker/DockerHostConfig.php new file mode 100644 index 0000000..937ba71 --- /dev/null +++ b/src/Docker/DockerHostConfig.php @@ -0,0 +1,130 @@ +statusCode; + } + + /** + * Returns the raw response body as a string. + */ + public function getBodyContents(): string + { + return $this->body; + } + + /** + * Decodes the response body as JSON and returns the resulting array. + * + * @return array + * @throws RuntimeException If the body is not valid JSON + */ + public function getJson(): array + { + if ($this->body === '') { + return []; + } + + $data = json_decode($this->body, true); + if (!is_array($data)) { + throw new RuntimeException('Invalid JSON response from Docker'); + } + + return $data; + } + + /** + * @deprecated Use getBodyContents() instead. Will be removed in a future version. + */ + public function getBody(): DockerResponseBody + { + return new DockerResponseBody($this->body); + } +} diff --git a/src/Docker/DockerResponseBody.php b/src/Docker/DockerResponseBody.php new file mode 100644 index 0000000..c549227 --- /dev/null +++ b/src/Docker/DockerResponseBody.php @@ -0,0 +1,17 @@ +contents; + } +} diff --git a/src/Docker/Exception/ContainerCreateNotFoundException.php b/src/Docker/Exception/ContainerCreateNotFoundException.php new file mode 100644 index 0000000..f9b866b --- /dev/null +++ b/src/Docker/Exception/ContainerCreateNotFoundException.php @@ -0,0 +1,11 @@ +statusCode; + } + + public function getResponseBody(): string + { + return $this->responseBody; + } +} diff --git a/src/Docker/Model/ContainerConfig.php b/src/Docker/Model/ContainerConfig.php new file mode 100644 index 0000000..c65d8f7 --- /dev/null +++ b/src/Docker/Model/ContainerConfig.php @@ -0,0 +1,58 @@ + */ + private array $data; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->data = $data; + } + + /** + * @return array|null + */ + public function getLabels(): ?array + { + $labels = $this->data['Labels'] ?? null; + if (!is_array($labels)) { + return null; + } + + $result = []; + foreach ($labels as $key => $value) { + if (is_string($key) && is_string($value)) { + $result[$key] = $value; + } + } + + return $result; + } + + public function getHealthcheck(): ?HealthConfig + { + $health = $this->data['Healthcheck'] ?? null; + if (!is_array($health)) { + return null; + } + + return new HealthConfig($health); + } + + /** + * @return array|null + */ + public function getEntrypoint(): ?array + { + $entrypoint = $this->data['Entrypoint'] ?? null; + return is_array($entrypoint) ? array_values(array_filter($entrypoint, 'is_string')) : null; + } +} diff --git a/src/Docker/Model/ContainerCreateResponse.php b/src/Docker/Model/ContainerCreateResponse.php new file mode 100644 index 0000000..1de97ad --- /dev/null +++ b/src/Docker/Model/ContainerCreateResponse.php @@ -0,0 +1,24 @@ + $data + */ + public function __construct(array $data = []) + { + $id = $data['Id'] ?? $data['id'] ?? null; + $this->id = is_string($id) ? $id : null; + } + + public function getId(): ?string + { + return $this->id; + } +} diff --git a/src/Docker/Model/ContainerHealth.php b/src/Docker/Model/ContainerHealth.php new file mode 100644 index 0000000..31028df --- /dev/null +++ b/src/Docker/Model/ContainerHealth.php @@ -0,0 +1,25 @@ + */ + private array $data; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->data = $data; + } + + public function getStatus(): ?string + { + $status = $this->data['Status'] ?? null; + return is_string($status) ? $status : null; + } +} diff --git a/src/Docker/Model/ContainerState.php b/src/Docker/Model/ContainerState.php new file mode 100644 index 0000000..e4d6d23 --- /dev/null +++ b/src/Docker/Model/ContainerState.php @@ -0,0 +1,35 @@ + */ + private array $data; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->data = $data; + } + + public function getStatus(): ?string + { + $status = $this->data['Status'] ?? null; + return is_string($status) ? $status : null; + } + + public function getHealth(): ?ContainerHealth + { + $health = $this->data['Health'] ?? null; + if (!is_array($health)) { + return null; + } + + return new ContainerHealth($health); + } +} diff --git a/src/Docker/Model/ContainersCreatePostBody.php b/src/Docker/Model/ContainersCreatePostBody.php new file mode 100644 index 0000000..a2b7631 --- /dev/null +++ b/src/Docker/Model/ContainersCreatePostBody.php @@ -0,0 +1,180 @@ + */ + private array $cmd = []; + + /** @var array|null */ + private ?array $labels = null; + + private ?string $hostname = null; + private ?string $workingDir = null; + private ?string $user = null; + + /** @var array */ + private array $env = []; + + /** @var array|null */ + private ?array $exposedPorts = null; + + private ?HostConfig $hostConfig = null; + + /** @var array|null */ + private ?array $entrypoint = null; + + private ?HealthConfig $healthcheck = null; + private ?NetworkingConfig $networkingConfig = null; + + public function setImage(string $image): self + { + $this->image = $image; + + return $this; + } + + /** + * @param array $cmd + */ + public function setCmd(array $cmd): self + { + $this->cmd = $cmd; + + return $this; + } + + /** + * @param array|null $labels + */ + public function setLabels(?array $labels): self + { + $this->labels = $labels; + + return $this; + } + + public function setHostname(?string $hostname): self + { + $this->hostname = $hostname; + + return $this; + } + + public function setWorkingDir(?string $workingDir): self + { + $this->workingDir = $workingDir; + + return $this; + } + + public function setUser(?string $user): self + { + $this->user = $user; + + return $this; + } + + /** + * @param array $env + */ + public function setEnv(array $env): self + { + $this->env = $env; + + return $this; + } + + /** + * @param array $exposedPorts + */ + public function setExposedPorts(array $exposedPorts): self + { + $this->exposedPorts = $exposedPorts; + + return $this; + } + + public function setHostConfig(?HostConfig $hostConfig): self + { + $this->hostConfig = $hostConfig; + + return $this; + } + + /** + * @param array $entrypoint + */ + public function setEntrypoint(array $entrypoint): self + { + $this->entrypoint = $entrypoint; + + return $this; + } + + public function setHealthcheck(HealthConfig $healthcheck): self + { + $this->healthcheck = $healthcheck; + + return $this; + } + + public function setNetworkingConfig(NetworkingConfig $networkingConfig): self + { + $this->networkingConfig = $networkingConfig; + + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + $payload = []; + + if ($this->image !== null) { + $payload['Image'] = $this->image; + } + if ($this->cmd !== []) { + $payload['Cmd'] = $this->cmd; + } + if ($this->labels !== null) { + $payload['Labels'] = $this->labels; + } + if ($this->hostname !== null) { + $payload['Hostname'] = $this->hostname; + } + if ($this->workingDir !== null) { + $payload['WorkingDir'] = $this->workingDir; + } + if ($this->user !== null) { + $payload['User'] = $this->user; + } + if ($this->env !== []) { + $payload['Env'] = $this->env; + } + if ($this->exposedPorts !== null) { + $payload['ExposedPorts'] = $this->exposedPorts; + } + if ($this->hostConfig !== null) { + $payload['HostConfig'] = $this->hostConfig->toArray(); + } + if ($this->entrypoint !== null) { + $payload['Entrypoint'] = $this->entrypoint; + } + if ($this->healthcheck !== null) { + $payload['Healthcheck'] = $this->healthcheck->toArray(); + } + if ($this->networkingConfig !== null) { + $payload['NetworkingConfig'] = $this->networkingConfig->toArray(); + } + + return $payload; + } +} diff --git a/src/Docker/Model/ContainersIdExecPostBody.php b/src/Docker/Model/ContainersIdExecPostBody.php new file mode 100644 index 0000000..7c3c98a --- /dev/null +++ b/src/Docker/Model/ContainersIdExecPostBody.php @@ -0,0 +1,49 @@ + */ + private array $cmd = []; + private bool $attachStdout = false; + private bool $attachStderr = false; + + /** + * @param array $cmd + */ + public function setCmd(array $cmd): self + { + $this->cmd = $cmd; + + return $this; + } + + public function setAttachStdout(bool $attachStdout): self + { + $this->attachStdout = $attachStdout; + + return $this; + } + + public function setAttachStderr(bool $attachStderr): self + { + $this->attachStderr = $attachStderr; + + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'Cmd' => $this->cmd, + 'AttachStdout' => $this->attachStdout, + 'AttachStderr' => $this->attachStderr, + ]; + } +} diff --git a/src/Docker/Model/ContainersIdJsonGetResponse200.php b/src/Docker/Model/ContainersIdJsonGetResponse200.php new file mode 100644 index 0000000..84707e4 --- /dev/null +++ b/src/Docker/Model/ContainersIdJsonGetResponse200.php @@ -0,0 +1,65 @@ + */ + private array $data; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->data = $data; + } + + public function getName(): ?string + { + $name = $this->data['Name'] ?? null; + return is_string($name) ? $name : null; + } + + public function getConfig(): ?ContainerConfig + { + $config = $this->data['Config'] ?? null; + if (!is_array($config)) { + return null; + } + + return new ContainerConfig($config); + } + + public function getHostConfig(): ?HostConfig + { + $hostConfig = $this->data['HostConfig'] ?? null; + if (!is_array($hostConfig)) { + return null; + } + + return new HostConfig($hostConfig); + } + + public function getNetworkSettings(): ?NetworkSettings + { + $networkSettings = $this->data['NetworkSettings'] ?? null; + if (!is_array($networkSettings)) { + return null; + } + + return new NetworkSettings($networkSettings); + } + + public function getState(): ?ContainerState + { + $state = $this->data['State'] ?? null; + if (!is_array($state)) { + return null; + } + + return new ContainerState($state); + } +} diff --git a/src/Docker/Model/EndpointSettings.php b/src/Docker/Model/EndpointSettings.php new file mode 100644 index 0000000..0e73f60 --- /dev/null +++ b/src/Docker/Model/EndpointSettings.php @@ -0,0 +1,82 @@ +|null */ + private ?array $aliases = null; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $networkID = $data['networkID'] ?? $data['NetworkID'] ?? null; + $this->networkID = is_string($networkID) ? $networkID : null; + + $ipAddress = $data['IPAddress'] ?? null; + $this->ipAddress = is_string($ipAddress) ? $ipAddress : null; + + $aliases = $data['aliases'] ?? $data['Aliases'] ?? null; + if (is_array($aliases)) { + $this->aliases = array_values(array_filter($aliases, 'is_string')); + } + } + + public function setNetworkID(string $networkID): self + { + $this->networkID = $networkID; + + return $this; + } + + /** + * @param array $aliases + */ + public function setAliases(array $aliases): self + { + $this->aliases = $aliases; + + return $this; + } + + public function getNetworkID(): ?string + { + return $this->networkID; + } + + public function getIPAddress(): ?string + { + return $this->ipAddress; + } + + /** + * @return array|null + */ + public function getAliases(): ?array + { + return $this->aliases; + } + + /** + * @return array + */ + public function toArray(): array + { + $payload = []; + if ($this->networkID !== null) { + $payload['NetworkID'] = $this->networkID; + } + if ($this->aliases !== null) { + $payload['Aliases'] = $this->aliases; + } + + return $payload; + } +} diff --git a/src/Docker/Model/ExecIdJsonGetResponse200.php b/src/Docker/Model/ExecIdJsonGetResponse200.php new file mode 100644 index 0000000..9bbfefc --- /dev/null +++ b/src/Docker/Model/ExecIdJsonGetResponse200.php @@ -0,0 +1,25 @@ + $data + */ + public function __construct(array $data = []) + { + if (array_key_exists('ExitCode', $data) && (is_int($data['ExitCode']) || is_null($data['ExitCode']))) { + $this->exitCode = $data['ExitCode']; + } + } + + public function getExitCode(): ?int + { + return $this->exitCode; + } +} diff --git a/src/Docker/Model/HealthConfig.php b/src/Docker/Model/HealthConfig.php new file mode 100644 index 0000000..a06069f --- /dev/null +++ b/src/Docker/Model/HealthConfig.php @@ -0,0 +1,128 @@ +|null */ + private ?array $test = null; + private ?int $interval = null; + private ?int $timeout = null; + private ?int $retries = null; + private ?int $startPeriod = null; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + if (isset($data['Test']) && is_array($data['Test'])) { + $this->test = array_values(array_filter($data['Test'], 'is_string')); + } + if (isset($data['Interval']) && (is_int($data['Interval']) || is_float($data['Interval']))) { + $this->interval = (int) $data['Interval']; + } + if (isset($data['Timeout']) && (is_int($data['Timeout']) || is_float($data['Timeout']))) { + $this->timeout = (int) $data['Timeout']; + } + if (isset($data['Retries']) && (is_int($data['Retries']) || is_float($data['Retries']))) { + $this->retries = (int) $data['Retries']; + } + if (isset($data['StartPeriod']) && (is_int($data['StartPeriod']) || is_float($data['StartPeriod']))) { + $this->startPeriod = (int) $data['StartPeriod']; + } + } + + /** + * @param array $test + */ + public function setTest(array $test): self + { + $this->test = $test; + + return $this; + } + + public function setInterval(int $interval): self + { + $this->interval = $interval; + + return $this; + } + + public function setTimeout(int $timeout): self + { + $this->timeout = $timeout; + + return $this; + } + + public function setRetries(int $retries): self + { + $this->retries = $retries; + + return $this; + } + + public function setStartPeriod(int $startPeriod): self + { + $this->startPeriod = $startPeriod; + + return $this; + } + + /** + * @return array|null + */ + public function getTest(): ?array + { + return $this->test; + } + + public function getInterval(): ?int + { + return $this->interval; + } + + public function getTimeout(): ?int + { + return $this->timeout; + } + + public function getRetries(): ?int + { + return $this->retries; + } + + public function getStartPeriod(): ?int + { + return $this->startPeriod; + } + + /** + * @return array + */ + public function toArray(): array + { + $payload = []; + if ($this->test !== null) { + $payload['Test'] = $this->test; + } + if ($this->interval !== null) { + $payload['Interval'] = $this->interval; + } + if ($this->timeout !== null) { + $payload['Timeout'] = $this->timeout; + } + if ($this->retries !== null) { + $payload['Retries'] = $this->retries; + } + if ($this->startPeriod !== null) { + $payload['StartPeriod'] = $this->startPeriod; + } + + return $payload; + } +} diff --git a/src/Docker/Model/HostConfig.php b/src/Docker/Model/HostConfig.php new file mode 100644 index 0000000..85e8570 --- /dev/null +++ b/src/Docker/Model/HostConfig.php @@ -0,0 +1,182 @@ +>|null */ + private ?array $portBindings = null; + private ?bool $privileged = null; + private ?bool $autoRemove = null; + + /** @var array|null */ + private ?array $mounts = null; + + /** @var array|null */ + private ?array $tmpfs = null; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + if (array_key_exists('Privileged', $data)) { + $this->privileged = (bool) $data['Privileged']; + } + if (array_key_exists('AutoRemove', $data)) { + $this->autoRemove = (bool) $data['AutoRemove']; + } + if (isset($data['Tmpfs']) && is_array($data['Tmpfs'])) { + $this->tmpfs = []; + foreach ($data['Tmpfs'] as $path => $options) { + if (is_string($path) && is_string($options)) { + $this->tmpfs[$path] = $options; + } + } + } + if (isset($data['PortBindings']) && is_array($data['PortBindings'])) { + $this->portBindings = $this->hydratePortBindings($data['PortBindings']); + } + if (isset($data['Mounts']) && is_array($data['Mounts'])) { + $this->mounts = []; + foreach ($data['Mounts'] as $mount) { + if (is_array($mount)) { + $this->mounts[] = new Mount($mount); + } + } + } + } + + /** + * @param array> $portBindings + */ + public function setPortBindings(array $portBindings): self + { + $this->portBindings = $portBindings; + + return $this; + } + + public function setPrivileged(bool $privileged): self + { + $this->privileged = $privileged; + + return $this; + } + + public function setAutoRemove(bool $autoRemove): self + { + $this->autoRemove = $autoRemove; + + return $this; + } + + /** + * @param array $tmpfs + */ + public function setTmpfs(array $tmpfs): self + { + $this->tmpfs = $tmpfs; + + return $this; + } + + /** + * @param array $mounts + */ + public function setMounts(array $mounts): self + { + $this->mounts = $mounts; + + return $this; + } + + public function getPrivileged(): ?bool + { + return $this->privileged; + } + + public function getAutoRemove(): ?bool + { + return $this->autoRemove; + } + + /** + * @return array|null + */ + public function getTmpfs(): ?array + { + return $this->tmpfs; + } + + /** + * @return array + */ + public function toArray(): array + { + $payload = []; + + if ($this->portBindings !== null) { + $payload['PortBindings'] = []; + foreach ($this->portBindings as $port => $bindings) { + $payload['PortBindings'][$port] = array_map( + static fn (PortBinding $binding) => $binding->toArray(), + $bindings + ); + } + } + + if ($this->privileged !== null) { + $payload['Privileged'] = $this->privileged; + } + + if ($this->autoRemove !== null) { + $payload['AutoRemove'] = $this->autoRemove; + } + + if ($this->tmpfs !== null) { + $payload['Tmpfs'] = $this->tmpfs; + } + + if ($this->mounts !== null) { + $payload['Mounts'] = array_map( + static fn (Mount $mount) => $mount->toArray(), + $this->mounts + ); + } + + return $payload; + } + + /** + * @param array $bindings + * @return array> + */ + private function hydratePortBindings(array $bindings): array + { + $result = []; + foreach ($bindings as $port => $items) { + if (!is_string($port)) { + continue; + } + if (!is_array($items)) { + $result[$port] = []; + continue; + } + $result[$port] = array_values(array_filter(array_map( + static function ($item): ?PortBinding { + if (!is_array($item)) { + return null; + } + + return new PortBinding($item); + }, + $items + ))); + } + + return $result; + } +} diff --git a/src/Docker/Model/IdResponse.php b/src/Docker/Model/IdResponse.php new file mode 100644 index 0000000..ef66baf --- /dev/null +++ b/src/Docker/Model/IdResponse.php @@ -0,0 +1,24 @@ + $data + */ + public function __construct(array $data = []) + { + $id = $data['Id'] ?? $data['id'] ?? null; + $this->id = is_string($id) ? $id : null; + } + + public function getId(): ?string + { + return $this->id; + } +} diff --git a/src/Docker/Model/Mount.php b/src/Docker/Model/Mount.php new file mode 100644 index 0000000..91c183d --- /dev/null +++ b/src/Docker/Model/Mount.php @@ -0,0 +1,82 @@ + $data + */ + public function __construct(array $data = []) + { + $type = $data['type'] ?? $data['Type'] ?? null; + $this->type = is_string($type) ? $type : null; + + $source = $data['source'] ?? $data['Source'] ?? null; + $this->source = is_string($source) ? $source : null; + + $target = $data['target'] ?? $data['Target'] ?? null; + $this->target = is_string($target) ? $target : null; + } + + public function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + public function setSource(string $source): self + { + $this->source = $source; + + return $this; + } + + public function setTarget(string $target): self + { + $this->target = $target; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function getSource(): ?string + { + return $this->source; + } + + public function getTarget(): ?string + { + return $this->target; + } + + /** + * @return array + */ + public function toArray(): array + { + $payload = []; + if ($this->type !== null) { + $payload['Type'] = $this->type; + } + if ($this->source !== null) { + $payload['Source'] = $this->source; + } + if ($this->target !== null) { + $payload['Target'] = $this->target; + } + + return $payload; + } +} diff --git a/src/Docker/Model/Network.php b/src/Docker/Model/Network.php new file mode 100644 index 0000000..cbd18d2 --- /dev/null +++ b/src/Docker/Model/Network.php @@ -0,0 +1,29 @@ + */ + private array $data; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->data = $data; + } + + public function getIPAM(): ?NetworkIPAM + { + $ipam = $this->data['IPAM'] ?? null; + if (!is_array($ipam)) { + return null; + } + + return new NetworkIPAM($ipam); + } +} diff --git a/src/Docker/Model/NetworkIPAM.php b/src/Docker/Model/NetworkIPAM.php new file mode 100644 index 0000000..7d99379 --- /dev/null +++ b/src/Docker/Model/NetworkIPAM.php @@ -0,0 +1,40 @@ + */ + private array $data; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->data = $data; + } + + /** + * @return array + */ + public function getConfig(): array + { + $configs = $this->data['Config'] ?? []; + if (!is_array($configs)) { + return []; + } + + $result = []; + foreach ($configs as $config) { + if (!is_array($config)) { + continue; + } + $result[] = new NetworkIPAMConfig($config); + } + + return $result; + } +} diff --git a/src/Docker/Model/NetworkIPAMConfig.php b/src/Docker/Model/NetworkIPAMConfig.php new file mode 100644 index 0000000..0640dd9 --- /dev/null +++ b/src/Docker/Model/NetworkIPAMConfig.php @@ -0,0 +1,25 @@ + */ + private array $data; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->data = $data; + } + + public function getGateway(): ?string + { + $gateway = $this->data['Gateway'] ?? null; + return is_string($gateway) ? $gateway : null; + } +} diff --git a/src/Docker/Model/NetworkSettings.php b/src/Docker/Model/NetworkSettings.php new file mode 100644 index 0000000..e5d8e38 --- /dev/null +++ b/src/Docker/Model/NetworkSettings.php @@ -0,0 +1,81 @@ + */ + private array $data; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->data = $data; + } + + /** + * @return array>|null + */ + public function getPorts(): ?array + { + if (!array_key_exists('Ports', $this->data)) { + return null; + } + + $ports = $this->data['Ports']; + if ($ports === null) { + return null; + } + if (!is_array($ports)) { + return null; + } + + $result = []; + foreach ($ports as $port => $bindings) { + if (!is_string($port)) { + continue; + } + if (!is_array($bindings)) { + $result[$port] = []; + continue; + } + $result[$port] = array_values(array_filter(array_map( + static function ($binding): ?PortBinding { + if (!is_array($binding)) { + return null; + } + + return new PortBinding($binding); + }, + $bindings + ))); + } + + return $result; + } + + /** + * @return array|null + */ + public function getNetworks(): ?array + { + $networks = $this->data['Networks'] ?? null; + if (!is_array($networks)) { + return null; + } + + $result = []; + foreach ($networks as $name => $settings) { + if (!is_string($name) || !is_array($settings)) { + continue; + } + $result[$name] = new EndpointSettings($settings); + } + + return $result; + } +} diff --git a/src/Docker/Model/NetworkingConfig.php b/src/Docker/Model/NetworkingConfig.php new file mode 100644 index 0000000..4d20585 --- /dev/null +++ b/src/Docker/Model/NetworkingConfig.php @@ -0,0 +1,37 @@ + */ + private array $endpointsConfig = []; + + /** + * @param array $endpointsConfig + */ + public function setEndpointsConfig(array $endpointsConfig): self + { + $this->endpointsConfig = $endpointsConfig; + + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + $payload = []; + if ($this->endpointsConfig !== []) { + $payload['EndpointsConfig'] = []; + foreach ($this->endpointsConfig as $name => $settings) { + $payload['EndpointsConfig'][$name] = $settings->toArray(); + } + } + + return $payload; + } +} diff --git a/src/Docker/Model/PortBinding.php b/src/Docker/Model/PortBinding.php new file mode 100644 index 0000000..8ad306f --- /dev/null +++ b/src/Docker/Model/PortBinding.php @@ -0,0 +1,63 @@ + $data + */ + public function __construct(array $data = []) + { + $hostPort = $data['HostPort'] ?? $data['hostPort'] ?? null; + $this->hostPort = is_string($hostPort) ? $hostPort : null; + + $hostIp = $data['HostIp'] ?? $data['hostIp'] ?? null; + $this->hostIp = is_string($hostIp) ? $hostIp : null; + } + + public function setHostPort(string $hostPort): self + { + $this->hostPort = $hostPort; + + return $this; + } + + public function setHostIp(string $hostIp): self + { + $this->hostIp = $hostIp; + + return $this; + } + + public function getHostPort(): ?string + { + return $this->hostPort; + } + + public function getHostIp(): ?string + { + return $this->hostIp; + } + + /** + * @return array + */ + public function toArray(): array + { + $payload = []; + if ($this->hostPort !== null) { + $payload['HostPort'] = $this->hostPort; + } + if ($this->hostIp !== null) { + $payload['HostIp'] = $this->hostIp; + } + + return $payload; + } +} diff --git a/src/Docker/Stream/CreateImageStream.php b/src/Docker/Stream/CreateImageStream.php new file mode 100644 index 0000000..8893316 --- /dev/null +++ b/src/Docker/Stream/CreateImageStream.php @@ -0,0 +1,22 @@ +payload; + } +} diff --git a/src/Utils/HostResolver.php b/src/Utils/HostResolver.php index 6c74dc2..7180c41 100644 --- a/src/Utils/HostResolver.php +++ b/src/Utils/HostResolver.php @@ -4,15 +4,15 @@ namespace Testcontainers\Utils; -use Docker\API\Model\Network; -use Docker\Docker; +use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\Model\Network; use RuntimeException; use Testcontainers\Container\GenericContainer; use Testcontainers\ContainerClient\DockerContainerClient; class HostResolver { - public function __construct(protected ?Docker $dockerClient = null) + public function __construct(protected ?DockerClient $dockerClient = null) { $this->dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); } diff --git a/src/Wait/WaitForContainer.php b/src/Wait/WaitForContainer.php index 4fb713e..86675e3 100644 --- a/src/Wait/WaitForContainer.php +++ b/src/Wait/WaitForContainer.php @@ -4,7 +4,7 @@ namespace Testcontainers\Wait; -use Docker\API\Model\ContainersIdJsonGetResponse200; +use Testcontainers\Docker\Model\ContainersIdJsonGetResponse200; use Testcontainers\Container\StartedTestContainer; use Testcontainers\Exception\ContainerNotReadyException; diff --git a/src/Wait/WaitForExec.php b/src/Wait/WaitForExec.php index 99a233b..1214479 100644 --- a/src/Wait/WaitForExec.php +++ b/src/Wait/WaitForExec.php @@ -5,7 +5,7 @@ namespace Testcontainers\Wait; use Closure; -use Docker\API\Model\ExecIdJsonGetResponse200; +use Testcontainers\Docker\Model\ExecIdJsonGetResponse200; use Testcontainers\Container\StartedTestContainer; use Testcontainers\Exception\ContainerWaitingTimeoutException; diff --git a/src/Wait/WaitForHealthCheck.php b/src/Wait/WaitForHealthCheck.php index 289adaf..ca34ba7 100644 --- a/src/Wait/WaitForHealthCheck.php +++ b/src/Wait/WaitForHealthCheck.php @@ -4,7 +4,7 @@ namespace Testcontainers\Wait; -use Docker\API\Model\ContainersIdJsonGetResponse200; +use Testcontainers\Docker\Model\ContainersIdJsonGetResponse200; use Testcontainers\Container\StartedTestContainer; use Testcontainers\Exception\ContainerStateException; use Testcontainers\Exception\ContainerWaitingTimeoutException; diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 78afe6e..4285888 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -4,7 +4,7 @@ namespace Testcontainers\Tests\Integration; -use Docker\API\Model\ContainersIdJsonGetResponse200; +use Testcontainers\Docker\Model\ContainersIdJsonGetResponse200; use PHPUnit\Framework\TestCase; use Testcontainers\Container\GenericContainer; use Testcontainers\Wait\WaitForHostPort; diff --git a/tests/Unit/ContainerClient/DockerContainerClientTest.php b/tests/Unit/ContainerClient/DockerContainerClientTest.php index be9a3c5..8ae373c 100644 --- a/tests/Unit/ContainerClient/DockerContainerClientTest.php +++ b/tests/Unit/ContainerClient/DockerContainerClientTest.php @@ -4,12 +4,10 @@ namespace Testcontainers\Tests\Unit\ContainerClient; -use Docker\Docker as DockerClient; -use Http\Client\Common\Plugin\HeaderDefaultsPlugin; -use Http\Client\Common\PluginClient; use PHPUnit\Framework\TestCase; -use Psr\Http\Client\ClientInterface; use Testcontainers\ContainerClient\DockerContainerClient; +use Testcontainers\Docker\Client\ClientInterface; +use Testcontainers\Docker\DockerClient; /** * Test subclass used by testUserAgentHeaderContainsUnknownWhenVersionResolutionFails. @@ -23,10 +21,6 @@ class DockerContainerClientWithBrokenVersion extends DockerContainerClient { protected static function resolveVersion(string $package = 'testcontainers/testcontainers'): string { - // Delegates to the real production resolveVersion() with an unknown package. - // The production catch(\OutOfBoundsException) block must catch the exception - // and return 'unknown'. If that catch block is removed, an unhandled - // OutOfBoundsException propagates and the test fails. return parent::resolveVersion('testcontainers/this-package-does-not-exist'); } } @@ -54,86 +48,25 @@ private function resetState(): void $property = $reflection->getProperty('dockerClient'); $property->setValue(null, null); - DockerContainerClient::resetFactories(); + DockerContainerClient::resetDockerClientFactory(); } - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - /** - * Injects factory stubs so getDockerClient() runs its full production body - * (version resolution → PluginClient wrapping → docker-client factory) without - * opening a Docker socket. The injected $dockerClientFactory receives the real - * PluginClient that production code constructs, providing a handle to inspect it. - * - * @param PluginClient|null $capturedHttpClient Out-param set to the PluginClient passed to the docker factory. + * Injects a factory so getDockerClient() runs its full production body + * (version resolution → User-Agent construction) without opening a Docker socket. + * The factory captures the User-Agent string production code passes to it. */ - private function injectNoSocketFactories(?PluginClient &$capturedHttpClient = null): void + private function injectCapturingFactory(?string &$capturedUserAgent): void { - $mockPsrClient = $this->createMock(ClientInterface::class); - - DockerContainerClient::setFactories( - static function () use ($mockPsrClient): ClientInterface { - return $mockPsrClient; - }, - static function (ClientInterface $httpClient) use (&$capturedHttpClient): DockerClient { - // $httpClient here is the PluginClient wrapping the UA plugin — capture it. - if (!$httpClient instanceof PluginClient) { - throw new \UnexpectedValueException( - 'Expected PluginClient, got ' . get_debug_type($httpClient) - ); - } - - $capturedHttpClient = $httpClient; - - return (new \ReflectionClass(DockerClient::class))->newInstanceWithoutConstructor(); - } - ); - } + $httpClient = $this->createMock(ClientInterface::class); - /** - * Returns the private $plugins array from a PluginClient via reflection. - * - * @return \Http\Client\Common\Plugin[] - */ - private function getPlugins(PluginClient $client): array - { - $prop = (new \ReflectionClass(PluginClient::class))->getProperty('plugins'); + DockerContainerClient::setDockerClientFactory( + static function (string $userAgent) use (&$capturedUserAgent, $httpClient): DockerClient { + $capturedUserAgent = $userAgent; - /** @var \Http\Client\Common\Plugin[] $plugins */ - $plugins = $prop->getValue($client); - - return $plugins; - } - - /** - * Finds the first HeaderDefaultsPlugin in a PluginClient's plugin stack, or null. - */ - private function findHeaderDefaultsPlugin(PluginClient $client): ?HeaderDefaultsPlugin - { - foreach ($this->getPlugins($client) as $plugin) { - if ($plugin instanceof HeaderDefaultsPlugin) { - return $plugin; + return new DockerClient($httpClient); } - } - - return null; - } - - /** - * Returns the $headers array stored inside a HeaderDefaultsPlugin via reflection. - * - * @return array - */ - private function getHeadersFromPlugin(HeaderDefaultsPlugin $plugin): array - { - $prop = (new \ReflectionClass(HeaderDefaultsPlugin::class))->getProperty('headers'); - - /** @var array $headers */ - $headers = $prop->getValue($plugin); - - return $headers; + ); } // ------------------------------------------------------------------------- @@ -186,11 +119,6 @@ public function testUserAgentVersionDoesNotContainBuildMetadataSuffix(): void public function testOutOfBoundsExceptionFallsBackToUnknown(): void { - // Call the real production resolveVersion() with a package name that is not - // installed. InstalledVersions::getPrettyVersion() throws OutOfBoundsException; - // the production catch block converts that to 'unknown'. - // If the catch block is removed from production code, this call throws an - // unhandled OutOfBoundsException and the test fails — it is not self-testing. $method = (new \ReflectionClass(DockerContainerClient::class)) ->getMethod('resolveVersion'); @@ -205,87 +133,34 @@ public function testOutOfBoundsExceptionFallsBackToUnknown(): void } // ------------------------------------------------------------------------- - // User-Agent HeaderDefaultsPlugin in the PluginClient stack + // User-Agent passed to the Docker client // ------------------------------------------------------------------------- - public function testHeaderDefaultsPluginIsConfiguredWithUserAgentHeader(): void + public function testDockerClientIsCreatedWithUserAgentHeader(): void { - // Inject no-socket factories so the real production getDockerClient() body runs: - // 1. static::resolveVersion() — real production code, no change - // 2. new PluginClient(..., [new HeaderDefaultsPlugin([...])]) — real production code - // 3. self::createDockerClient($httpClient) — calls our injected factory, which - // captures the PluginClient and returns a no-constructor stub - // - // Removing HeaderDefaultsPlugin from getDockerClient() causes this test to fail - // because $capturedHttpClient would then have no HeaderDefaultsPlugin in its stack. - $capturedHttpClient = null; - $this->injectNoSocketFactories($capturedHttpClient); + $capturedUserAgent = null; + $this->injectCapturingFactory($capturedUserAgent); DockerContainerClient::getDockerClient(); - $this->assertInstanceOf( - PluginClient::class, - $capturedHttpClient, - 'The docker client factory must receive a PluginClient' - ); - - $headerPlugin = $this->findHeaderDefaultsPlugin($capturedHttpClient); - - $this->assertInstanceOf( - HeaderDefaultsPlugin::class, - $headerPlugin, - 'HeaderDefaultsPlugin must be present in the PluginClient plugin stack' - ); - - $headers = $this->getHeadersFromPlugin($headerPlugin); - - $this->assertArrayHasKey('User-Agent', $headers); + $this->assertIsString($capturedUserAgent); $this->assertMatchesRegularExpression( '/^tc-php\/.+$/', - $headers['User-Agent'], - 'HeaderDefaultsPlugin must set User-Agent to tc-php/' + $capturedUserAgent, + 'DockerClient must be created with User-Agent tc-php/' ); } public function testUserAgentHeaderContainsUnknownWhenVersionResolutionFails(): void { - // Injects no-socket factories (same pattern as above), then calls - // DockerContainerClientWithBrokenVersion::getDockerClient() which inherits the - // production getDockerClient() body unchanged but overrides resolveVersion() to - // pass a non-existent package to parent::resolveVersion(), triggering the real - // OutOfBoundsException catch branch. The captured PluginClient must carry - // User-Agent: tc-php/unknown. - $capturedHttpClient = null; - $mockPsrClient = $this->createMock(ClientInterface::class); - - DockerContainerClient::setFactories( - static function () use ($mockPsrClient): ClientInterface { - return $mockPsrClient; - }, - static function (ClientInterface $httpClient) use (&$capturedHttpClient): DockerClient { - if (!$httpClient instanceof PluginClient) { - throw new \UnexpectedValueException( - 'Expected PluginClient, got ' . get_debug_type($httpClient) - ); - } - - $capturedHttpClient = $httpClient; - - return (new \ReflectionClass(DockerClient::class))->newInstanceWithoutConstructor(); - } - ); + $capturedUserAgent = null; + $this->injectCapturingFactory($capturedUserAgent); DockerContainerClientWithBrokenVersion::getDockerClient(); - $this->assertInstanceOf(PluginClient::class, $capturedHttpClient); - - $headerPlugin = $this->findHeaderDefaultsPlugin($capturedHttpClient); - $this->assertInstanceOf(HeaderDefaultsPlugin::class, $headerPlugin); - - $headers = $this->getHeadersFromPlugin($headerPlugin); $this->assertSame( 'tc-php/unknown', - $headers['User-Agent'], + $capturedUserAgent, 'When version resolution falls back to unknown, User-Agent must be tc-php/unknown' ); } diff --git a/tests/Unit/Docker/DockerClientTest.php b/tests/Unit/Docker/DockerClientTest.php new file mode 100644 index 0000000..0a3faf7 --- /dev/null +++ b/tests/Unit/Docker/DockerClientTest.php @@ -0,0 +1,45 @@ +createMock(ClientInterface::class); + $client + ->expects($this->once()) + ->method('request') + ->with('POST', '/containers/create', [], json_encode(['Image' => 'alpine:latest'], JSON_THROW_ON_ERROR)) + ->willReturn(new DockerResponse(201, json_encode(['Id' => '123'], JSON_THROW_ON_ERROR))); + + $dockerClient = new DockerClient($client); + $postBody = new ContainersCreatePostBody(); + $postBody->setImage('alpine:latest'); + $response = $dockerClient->containerCreate($postBody); + + $this->assertNotNull($response); + $this->assertSame('123', $response->getId()); + } + + public function testContainerStart(): void + { + $client = $this->createMock(ClientInterface::class); + $client + ->expects($this->once()) + ->method('request') + ->with('POST', '/containers/123/start') + ->willReturn(new DockerResponse(204, '')); + + $dockerClient = new DockerClient($client); + $dockerClient->containerStart('123'); + } +} diff --git a/tests/Unit/Utils/HostResolverTest.php b/tests/Unit/Utils/HostResolverTest.php index 25e6966..8b343a6 100644 --- a/tests/Unit/Utils/HostResolverTest.php +++ b/tests/Unit/Utils/HostResolverTest.php @@ -4,7 +4,8 @@ namespace Testcontainers\Tests\Unit\Utils; -use Docker\Docker; +use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\Model\Network; use PHPUnit\Framework\TestCase; use RuntimeException; use Testcontainers\Utils\HostResolver; @@ -29,7 +30,7 @@ public function testReturnsTestcontainersHostOverrideFromEnvironment(): void putenv('TESTCONTAINERS_HOST_OVERRIDE=tcp://another:2375'); putenv('DOCKER_HOST=tcp://docker:2375'); - $dummyClient = $this->createMock(Docker::class); + $dummyClient = $this->createMock(DockerClient::class); $resolver = new HostResolver($dummyClient); $host = $resolver->resolveHost(); $this->assertEquals('tcp://another:2375', $host); @@ -42,7 +43,7 @@ public function testReturnsHostnameForTcpProtocols(): void putenv('DOCKER_HOST=' . $protocol . '://docker:2375'); // Clear any override. putenv('TESTCONTAINERS_HOST_OVERRIDE'); - $dummyClient = $this->createMock(Docker::class); + $dummyClient = $this->createMock(DockerClient::class); $resolver = new HostResolver($dummyClient); $host = $resolver->resolveHost(); $this->assertEquals('docker', $host, "Protocol {$protocol} did not return expected hostname."); @@ -51,7 +52,7 @@ public function testReturnsHostnameForTcpProtocols(): void public function testDoesNotReturnOverrideWhenAllowUserOverridesIsFalse(): void { - $dummyClient = $this->createMock(Docker::class); + $dummyClient = $this->createMock(DockerClient::class); $resolver = new class ($dummyClient) extends HostResolver { protected function allowUserOverrides(): bool { @@ -67,7 +68,7 @@ protected function allowUserOverrides(): bool public function testReturnsLocalhostForUnixAndNpipeProtocolsWhenNotInContainer(): void { - $dummyClient = $this->createMock(Docker::class); + $dummyClient = $this->createMock(DockerClient::class); $resolver = new class ($dummyClient) extends HostResolver { protected function isInContainer(): bool { @@ -86,41 +87,17 @@ protected function isInContainer(): bool 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) + $dockerClient = $this->getMockBuilder(DockerClient::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; - } - }; + $fakeNetwork = new Network([ + 'IPAM' => [ + 'Config' => [ + ['Gateway' => '172.0.0.1'], + ], + ], + ]); // Expect that networkInspect will be called with "bridge" (since DOCKER_HOST does not contain "podman.sock") $dockerClient->expects($this->once()) @@ -145,7 +122,7 @@ protected function isInContainer(): bool public function testUsesBridgeNetworkAsGatewayForDockerProvider(): void { // For Docker provider (non-Podman) the network used should be "bridge". - $dockerClient = $this->getMockBuilder(Docker::class) + $dockerClient = $this->getMockBuilder(DockerClient::class) ->disableOriginalConstructor() ->getMock(); // Expect networkInspect to be called with "bridge" @@ -175,7 +152,7 @@ protected function findDefaultGateway(): ?string public function testUsesPodmanNetworkAsGatewayForPodmanProvider(): void { // For Podman, DOCKER_HOST contains "podman.sock" so the network should be "podman". - $dockerClient = $this->getMockBuilder(Docker::class) + $dockerClient = $this->getMockBuilder(DockerClient::class) ->disableOriginalConstructor() ->getMock(); // Expect networkInspect to be called with "podman" @@ -204,7 +181,7 @@ protected function findDefaultGateway(): ?string 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); + $dummyClient = $this->createMock(DockerClient::class); $resolver = new class ($dummyClient) extends HostResolver { protected function isInContainer(): bool { @@ -228,7 +205,7 @@ protected function findDefaultGateway(): string public function testReturnsLocalhostIfUnableToFindGateway(): void { // Override to simulate that neither network inspection nor default gateway yield a result. - $dummyClient = $this->createMock(Docker::class); + $dummyClient = $this->createMock(DockerClient::class); $resolver = new class ($dummyClient) extends HostResolver { protected function isInContainer(): bool { @@ -252,7 +229,7 @@ protected function findDefaultGateway(): ?string public function testThrowsForUnsupportedProtocol(): void { putenv('DOCKER_HOST=invalid://unknown'); - $dummyClient = $this->createMock(Docker::class); + $dummyClient = $this->createMock(DockerClient::class); $resolver = new HostResolver($dummyClient); $this->expectException(RuntimeException::class); From 2a2cfc9993643a9c89b85c3f5a22cea7e291398f Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Sun, 28 Dec 2025 10:56:42 +0100 Subject: [PATCH 2/6] fix: try to set tcp for windows --- .github/workflows/php.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 4667ab6..d2d378a 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -53,10 +53,26 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] + include: + - os: ubuntu-latest + docker_host: unix:///var/run/docker.sock + - os: windows-latest + docker_host: tcp://localhost:2375 steps: - uses: actions/checkout@v4 - + + - name: Configure Docker to use TCP (Windows) + if: matrix.os == 'windows-latest' + run: | + # Configure Docker daemon to listen on TCP + $dockerConfig = @{ hosts = @("tcp://0.0.0.0:2375", "npipe:////./pipe/docker_engine") } + $dockerConfig | ConvertTo-Json | Set-Content -Path "C:\ProgramData\docker\config\daemon.json" + Restart-Service docker + # Wait for Docker to be ready + Start-Sleep -Seconds 5 + shell: pwsh + - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -68,3 +84,5 @@ jobs: - name: Run test suite run: composer run integration + env: + DOCKER_HOST: ${{ matrix.docker_host }} From b57850ba4654e82cc4e9c6f3d5400c6ac0cac525 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 14 Sep 2026 15:13:07 +0200 Subject: [PATCH 3/6] ci: run only unit tests on Windows and drop DOCKER_HOST override on Ubuntu Linux images cannot start on the windows-latest Docker daemon, so the integration suite can never pass there. Setting DOCKER_HOST to the unix socket on Ubuntu made HostResolver return 'localhost', which breaks the MySQL/MariaDB PDO connections and the host assertion. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/php.yml | 47 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index d2d378a..903eaf2 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -47,32 +47,12 @@ jobs: run: composer run phpstan phpunit: - runs-on: ${{ matrix.os }} - name: Integration Tests (${{ matrix.os }}) - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - include: - - os: ubuntu-latest - docker_host: unix:///var/run/docker.sock - - os: windows-latest - docker_host: tcp://localhost:2375 + runs-on: ubuntu-latest + name: Integration Tests steps: - uses: actions/checkout@v4 - - name: Configure Docker to use TCP (Windows) - if: matrix.os == 'windows-latest' - run: | - # Configure Docker daemon to listen on TCP - $dockerConfig = @{ hosts = @("tcp://0.0.0.0:2375", "npipe:////./pipe/docker_engine") } - $dockerConfig | ConvertTo-Json | Set-Content -Path "C:\ProgramData\docker\config\daemon.json" - Restart-Service docker - # Wait for Docker to be ready - Start-Sleep -Seconds 5 - shell: pwsh - - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -84,5 +64,24 @@ jobs: - name: Run test suite run: composer run integration - env: - DOCKER_HOST: ${{ matrix.docker_host }} + + phpunit-windows: + runs-on: windows-latest + name: Unit Tests (windows-latest) + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: curl + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + # Linux images cannot run on the Windows runner's Docker daemon, so only the + # unit suite (including the npipe/tcp host parsing tests) is executed here. + - name: Run unit test suite + run: vendor/bin/phpunit tests/Unit From 89dc7ca805e0ae8e6be2fc01886d4d2ce27f5322 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 14 Sep 2026 15:16:13 +0200 Subject: [PATCH 4/6] ci: install PDO extensions required by composer on Windows Co-Authored-By: Claude Fable 5.1 --- .github/workflows/php.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 903eaf2..7b8749d 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -76,7 +76,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.1' - extensions: curl + extensions: curl, pdo, pdo_mysql, pdo_pgsql - name: Install dependencies run: composer install --prefer-dist --no-progress From 8f9b3c9b2743fc9bba5bb35183eea03e37dc8af1 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 14 Sep 2026 15:19:38 +0200 Subject: [PATCH 5/6] test: skip Unix mode assertions in TarBuilderTest on Windows NTFS cannot store Unix permission bits, so chmod() and fileperms() only reflect the read-only flag there. Content assertions still run on all platforms. Co-Authored-By: Claude Fable 5.1 --- tests/Unit/Utils/TarBuilderTest.php | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/Unit/Utils/TarBuilderTest.php b/tests/Unit/Utils/TarBuilderTest.php index 965b9de..9f66143 100644 --- a/tests/Unit/Utils/TarBuilderTest.php +++ b/tests/Unit/Utils/TarBuilderTest.php @@ -53,8 +53,7 @@ public function testShouldAddSingleFile(): void $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'); + $this->assertPosixMode('644', $extractedFile, 'Expected file mode 0644'); } public function testShouldAddDirectoryRecursively(): void @@ -82,8 +81,7 @@ public function testShouldAddDirectoryRecursively(): void $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'); + $this->assertPosixMode('755', $extractDir . '/mydir', 'Expected directory mode 0755'); } public function testShouldAddInlineContent(): void @@ -104,8 +102,7 @@ public function testShouldAddInlineContent(): void $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'); + $this->assertPosixMode('777', $inlineExtracted, 'Expected file mode 0777'); } public function testShouldFailOnInvalidFilePath(): void @@ -170,6 +167,22 @@ public function testShouldClearItems(): void $this->assertCount(0, $scanned, 'Expected no files after clear()'); } + /** + * Asserts the Unix permission bits of an extracted path. + * + * Windows filesystems cannot store Unix modes, so chmod() and fileperms() + * only reflect the read-only flag there; the check is skipped on Windows. + */ + private function assertPosixMode(string $expected, string $path, string $message): void + { + if (PHP_OS_FAMILY === 'Windows') { + return; + } + + $perms = substr(sprintf('%o', fileperms($path)), -3); + $this->assertSame($expected, $perms, $message); + } + /** * Helper function to extract a .tar for verification. */ From 179346dce012c1a51f0bba618bccf3ef91aaae9c Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Tue, 15 Sep 2026 13:06:17 +0200 Subject: [PATCH 6/6] feat: exchangeable runtime adapters with a CLI client for docker/podman Extract DockerClientInterface from the socket-based DockerClient and add CliDockerClient, which drives a Docker-compatible binary (docker, podman, nerdctl, ...) via subprocesses. The adapter is selected with TESTCONTAINERS_CLIENT=api|cli and the binary with TESTCONTAINERS_CLI_BINARY, or injected through DockerContainerClient::setDockerClient(). Containers, wait strategies and the host resolver now type against the interface. The OpenSearch wait regex no longer relies on the mangled multiplex frame byte of the HTTP log stream, so it matches for both adapters. CI runs the integration suite once per adapter. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/php.yml | 8 +- docs/configuration.md | 57 +- docs/troubleshooting.md | 7 + src/Container/GenericContainer.php | 4 +- src/Container/StartedGenericContainer.php | 12 +- src/Container/StartedTestContainer.php | 4 +- src/ContainerClient/DockerContainerClient.php | 52 +- src/Docker/Cli/CliDockerClient.php | 521 ++++++++++++++++++ src/Docker/Cli/CommandResult.php | 20 + src/Docker/Cli/CommandRunnerInterface.php | 16 + src/Docker/Cli/ProcessCommandRunner.php | 92 ++++ src/Docker/DockerClient.php | 4 +- src/Docker/DockerClientInterface.php | 69 +++ .../Exception/DockerCommandException.php | 44 ++ src/Modules/OpenSearchContainer.php | 2 +- src/Utils/HostResolver.php | 4 +- .../DockerContainerClientTest.php | 27 + tests/Unit/Docker/Cli/CliDockerClientTest.php | 277 ++++++++++ 18 files changed, 1187 insertions(+), 33 deletions(-) create mode 100644 src/Docker/Cli/CliDockerClient.php create mode 100644 src/Docker/Cli/CommandResult.php create mode 100644 src/Docker/Cli/CommandRunnerInterface.php create mode 100644 src/Docker/Cli/ProcessCommandRunner.php create mode 100644 src/Docker/DockerClientInterface.php create mode 100644 src/Docker/Exception/DockerCommandException.php create mode 100644 tests/Unit/Docker/Cli/CliDockerClientTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 7b8749d..18eb12f 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -48,7 +48,11 @@ jobs: phpunit: runs-on: ubuntu-latest - name: Integration Tests + name: Integration Tests (${{ matrix.adapter }}) + strategy: + fail-fast: false + matrix: + adapter: [api, cli] steps: - uses: actions/checkout@v4 @@ -64,6 +68,8 @@ jobs: - name: Run test suite run: composer run integration + env: + TESTCONTAINERS_CLIENT: ${{ matrix.adapter }} phpunit-windows: runs-on: windows-latest diff --git a/docs/configuration.md b/docs/configuration.md index aa1143e..e5a30b9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,16 +2,65 @@ ## Container runtime access -Testcontainers for PHP uses Docker APIs under the hood, so your test process must be able to reach a Docker-compatible daemon. +Testcontainers for PHP talks to a Docker-compatible runtime through one of two exchangeable adapters: -- Local socket: `unix:///var/run/docker.sock` -- Remote daemon: configure `DOCKER_HOST` +| Adapter | `TESTCONTAINERS_CLIENT` | How it works | Use when | +|---------|-------------------------|--------------|----------| +| API (default) | `api` | Docker Engine HTTP API over the socket or TCP endpoint from `DOCKER_HOST` | Docker Desktop, Docker Engine, OrbStack, Colima, Podman with its API service enabled | +| CLI | `cli` | Runs a Docker-compatible binary (`docker`, `podman`, `nerdctl`, ...) as a subprocess | The daemon socket is not reachable from PHP, e.g. rootless Podman without `podman system service`, remote Docker contexts, or SSH-based setups | + +Both adapters implement `Testcontainers\Docker\DockerClientInterface`, so containers, modules and wait strategies behave the same regardless of the adapter. + +### Using Podman + +Podman ships a Docker-compatible API socket. Either point the default adapter at it: + +```bash +systemctl --user enable --now podman.socket +export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock +``` + +or skip the socket entirely and drive the `podman` binary: + +```bash +export TESTCONTAINERS_CLIENT=cli +export TESTCONTAINERS_CLI_BINARY=podman +``` + +### Selecting an adapter in code + +The adapter can also be set programmatically before the first container is started: + +```php +use Testcontainers\ContainerClient\DockerContainerClient; +use Testcontainers\Docker\Cli\CliDockerClient; + +DockerContainerClient::setDockerClient(new CliDockerClient('podman')); +``` + +Any class implementing `DockerClientInterface` can be injected this way, which allows custom adapters. ## Environment variables +### `TESTCONTAINERS_CLIENT` + +Selects the runtime adapter: `api` (default) or `cli`. + +```bash +export TESTCONTAINERS_CLIENT=cli +``` + +### `TESTCONTAINERS_CLI_BINARY` + +Binary used by the `cli` adapter. Defaults to `docker`. The subprocess inherits your environment, so `DOCKER_CONTEXT`, `DOCKER_HOST` or Podman's `CONTAINER_HOST` apply as usual. + +```bash +export TESTCONTAINERS_CLI_BINARY=podman +``` + ### `DOCKER_HOST` -Defines where the Docker API is available. +Defines where the Docker API is available for the `api` adapter. Examples: ```bash diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b2cf85c..bd5174f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -25,6 +25,13 @@ docker info export DOCKER_HOST=unix:///var/run/docker.sock ``` +- If the socket cannot be reached from PHP but the `docker` or `podman` command works in your shell, switch to the CLI adapter: + +```bash +export TESTCONTAINERS_CLIENT=cli +export TESTCONTAINERS_CLI_BINARY=podman # or docker, nerdctl, ... +``` + ## Container starts but app is not ready `GenericContainer` uses a running-state wait strategy by default. diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index b46bf49..bab4319 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -4,7 +4,7 @@ namespace Testcontainers\Container; -use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\DockerClientInterface; use Testcontainers\Docker\Exception\ContainerCreateNotFoundException; use Testcontainers\Docker\Model\ContainerCreateResponse; use Testcontainers\Docker\Model\ContainersCreatePostBody; @@ -28,7 +28,7 @@ class GenericContainer implements TestContainer { - protected DockerClient $dockerClient; + protected DockerClientInterface $dockerClient; protected string $image; diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index 2b0064e..0d480ea 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -4,7 +4,7 @@ namespace Testcontainers\Container; -use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\DockerClientInterface; use Testcontainers\Docker\Model\ContainersIdExecPostBody; use Testcontainers\Docker\Model\ContainersIdJsonGetResponse200; use Testcontainers\Docker\Model\EndpointSettings; @@ -16,13 +16,13 @@ class StartedGenericContainer implements StartedTestContainer { - protected DockerClient $dockerClient; + protected DockerClientInterface $dockerClient; protected ?ContainersIdJsonGetResponse200 $inspectResponse = null; protected ?string $lastExecId = null; - public function __construct(protected readonly string $id, ?DockerClient $dockerClient = null) + public function __construct(protected readonly string $id, ?DockerClientInterface $dockerClient = null) { $this->dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); } @@ -37,7 +37,7 @@ public function getLastExecId(): ?string return $this->lastExecId; } - public function getClient(): DockerClient + public function getClient(): DockerClientInterface { return $this->dockerClient; } @@ -63,7 +63,7 @@ public function exec(array $command): string $this->lastExecId = $exec->getId(); $contents = $this->dockerClient - ->execStart($this->lastExecId, null, DockerClient::FETCH_RESPONSE) + ->execStart($this->lastExecId, null, DockerClientInterface::FETCH_RESPONSE) ?->getBody() ->getContents() ?? ''; @@ -91,7 +91,7 @@ public function logs(): string ->containerLogs( $this->id, ['stdout' => true, 'stderr' => true], - DockerClient::FETCH_RESPONSE + DockerClientInterface::FETCH_RESPONSE ) ?->getBody() ->getContents() ?? ''; diff --git a/src/Container/StartedTestContainer.php b/src/Container/StartedTestContainer.php index 3ec228c..a6e73f4 100644 --- a/src/Container/StartedTestContainer.php +++ b/src/Container/StartedTestContainer.php @@ -4,7 +4,7 @@ namespace Testcontainers\Container; -use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\DockerClientInterface; use Testcontainers\Docker\Model\PortBinding; interface StartedTestContainer @@ -19,7 +19,7 @@ public function exec(array $command): string; */ public function getBoundPorts(): iterable; - public function getClient(): DockerClient; + public function getClient(): DockerClientInterface; public function getFirstMappedPort(): int; diff --git a/src/ContainerClient/DockerContainerClient.php b/src/ContainerClient/DockerContainerClient.php index 096b514..0c29951 100644 --- a/src/ContainerClient/DockerContainerClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -5,18 +5,24 @@ namespace Testcontainers\ContainerClient; use Composer\InstalledVersions; +use RuntimeException; +use Testcontainers\Docker\Cli\CliDockerClient; use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\DockerClientInterface; class DockerContainerClient { + public const ADAPTER_API = 'api'; + public const ADAPTER_CLI = 'cli'; + /** - * @var DockerClient|null Singleton instance of DockerClient + * @var DockerClientInterface|null Singleton instance of the Docker client */ - private static ?DockerClient $dockerClient = null; + private static ?DockerClientInterface $dockerClient = null; /** - * @var (callable(string): DockerClient)|null Factory for the Docker client, receiving the User-Agent string. - * When null, DockerClient::create() is used. + * @var (callable(string): DockerClientInterface)|null Factory for the Docker client, receiving the User-Agent string. + * When null, the adapter named in TESTCONTAINERS_CLIENT is created. */ private static $dockerClientFactory = null; @@ -25,24 +31,46 @@ private function __construct() } /** - * Returns the singleton DockerClient instance. + * Returns the singleton Docker client instance. + * + * The adapter is chosen by the TESTCONTAINERS_CLIENT environment variable: + * - "api" (default): Docker Engine HTTP API over the socket/TCP endpoint from DOCKER_HOST + * - "cli": a Docker-compatible command line binary, see TESTCONTAINERS_CLI_BINARY (default "docker") * - * @return DockerClient The singleton DockerClient instance. - * @throws \RuntimeException If the DockerClient instance could not be created. + * @throws RuntimeException If the client could not be created. */ - public static function getDockerClient(): DockerClient + public static function getDockerClient(): DockerClientInterface { if (self::$dockerClient === null) { $userAgent = 'tc-php/' . static::resolveVersion(); self::$dockerClient = self::$dockerClientFactory !== null ? (self::$dockerClientFactory)($userAgent) - : DockerClient::create($userAgent); + : self::createFromEnvironment($userAgent); } return self::$dockerClient; } + /** + * @param non-empty-string $userAgent + */ + private static function createFromEnvironment(string $userAgent): DockerClientInterface + { + $adapter = getenv('TESTCONTAINERS_CLIENT') ?: self::ADAPTER_API; + + return match (strtolower($adapter)) { + self::ADAPTER_API => DockerClient::create($userAgent), + self::ADAPTER_CLI => CliDockerClient::create(), + default => throw new RuntimeException(sprintf( + 'Unsupported TESTCONTAINERS_CLIENT value "%s"; expected "%s" or "%s"', + $adapter, + self::ADAPTER_API, + self::ADAPTER_CLI + )), + }; + } + /** * Resolves the package version string used in the User-Agent header. * @@ -68,9 +96,9 @@ protected static function resolveVersion(string $package = 'testcontainers/testc * Injects a DockerClient instance for testing or special use cases. * Note: clients injected via this method will not have the tc-php User-Agent header applied automatically. * - * @param DockerClient $client The DockerClient instance to set. + * @param DockerClientInterface $client The client instance to set. */ - public static function setDockerClient(DockerClient $client): void + public static function setDockerClient(DockerClientInterface $client): void { self::$dockerClient = $client; } @@ -79,7 +107,7 @@ public static function setDockerClient(DockerClient $client): void * Injects a factory used to build the DockerClient from the User-Agent string. * For use in tests only — do not call in production code. * - * @param (callable(string): DockerClient)|null $dockerClientFactory + * @param (callable(string): DockerClientInterface)|null $dockerClientFactory */ public static function setDockerClientFactory(?callable $dockerClientFactory): void { diff --git a/src/Docker/Cli/CliDockerClient.php b/src/Docker/Cli/CliDockerClient.php new file mode 100644 index 0000000..fb82609 --- /dev/null +++ b/src/Docker/Cli/CliDockerClient.php @@ -0,0 +1,521 @@ +, exitCode: int|null}> */ + private array $execs = []; + + /** + * @param non-empty-string $binary + */ + public function __construct( + private string $binary = self::DEFAULT_BINARY, + ?CommandRunnerInterface $runner = null + ) { + $this->runner = $runner ?? new ProcessCommandRunner(); + } + + /** + * Creates a client using the binary named in TESTCONTAINERS_CLI_BINARY (default: docker). + */ + public static function create(?string $binary = null): self + { + $binary ??= getenv('TESTCONTAINERS_CLI_BINARY') ?: self::DEFAULT_BINARY; + if ($binary === '') { + $binary = self::DEFAULT_BINARY; + } + + return new self($binary); + } + + public function getBinary(): string + { + return $this->binary; + } + + public function containerCreate(ContainersCreatePostBody $body, array $query = []): ContainerCreateResponse + { + $args = ['create']; + + $name = $query['name'] ?? null; + if (is_string($name) && $name !== '') { + $args[] = '--name'; + $args[] = $name; + } + + $args = [...$args, ...$this->createArguments($body->toArray())]; + + $result = $this->runner->run($this->command($args)); + if (!$result->isSuccessful()) { + if ($this->looksLikeMissingImage($result->stderr)) { + throw new ContainerCreateNotFoundException('Docker image not found'); + } + throw $this->commandFailed('Create container', $args, $result); + } + + return new ContainerCreateResponse(['Id' => trim($result->stdout)]); + } + + public function containerStart(string $id): void + { + $this->runOrFail('Start container', ['start', $id]); + } + + public function containerStop(string $id): void + { + $this->runOrFail('Stop container', ['stop', $id]); + } + + public function containerDelete(string $id): void + { + $args = ['rm', $id]; + $result = $this->runner->run($this->command($args)); + if (!$result->isSuccessful() && !$this->looksLikeMissingContainer($result->stderr)) { + throw $this->commandFailed('Delete container', $args, $result); + } + } + + public function containerRestart(string $id): void + { + $this->runOrFail('Restart container', ['restart', $id]); + } + + public function containerExec(string $id, ContainersIdExecPostBody $body): IdResponse + { + $payload = $body->toArray(); + $cmd = []; + if (isset($payload['Cmd']) && is_array($payload['Cmd'])) { + $cmd = array_values(array_filter($payload['Cmd'], 'is_string')); + } + + $execId = bin2hex(random_bytes(32)); + $this->execs[$execId] = [ + 'container' => $id, + 'cmd' => $cmd, + 'exitCode' => null, + ]; + + return new IdResponse(['Id' => $execId]); + } + + public function execStart(string $id, ?array $config = null, int $fetch = self::FETCH_RESPONSE): ?DockerResponse + { + if (!isset($this->execs[$id])) { + throw new RuntimeException("Unknown exec id '{$id}'; call containerExec() first"); + } + + $exec = $this->execs[$id]; + $args = ['exec', $exec['container'], ...$exec['cmd']]; + + $result = $this->runner->run($this->command($args)); + $this->execs[$id]['exitCode'] = $result->exitCode; + + if ($this->looksLikeMissingContainer($result->stderr)) { + throw $this->commandFailed('Start exec', $args, $result); + } + + // The API returns the multiplexed stdout/stderr stream regardless of the command's exit code. + return $fetch === self::FETCH_RESPONSE + ? new DockerResponse(200, $result->stdout . $result->stderr) + : null; + } + + public function execInspect(string $id): ExecIdJsonGetResponse200 + { + if (!isset($this->execs[$id])) { + throw new RuntimeException("Unknown exec id '{$id}'"); + } + + return new ExecIdJsonGetResponse200(['ExitCode' => $this->execs[$id]['exitCode']]); + } + + public function containerLogs(string $id, array $params = [], int $fetch = self::FETCH_RESPONSE): ?DockerResponse + { + $args = ['logs', $id]; + $result = $this->runner->run($this->command($args)); + + if (!$result->isSuccessful()) { + if ($this->looksLikeMissingContainer($result->stderr)) { + return $fetch === self::FETCH_RESPONSE ? new DockerResponse(404, trim($result->stderr)) : null; + } + throw $this->commandFailed('Container logs', $args, $result); + } + + return $fetch === self::FETCH_RESPONSE + ? new DockerResponse(200, $result->stdout . $result->stderr) + : null; + } + + public function containerInspect(string $id): ContainersIdJsonGetResponse200 + { + $result = $this->runOrFail('Inspect container', ['inspect', '--type', 'container', '--format', '{{json .}}', $id]); + + return new ContainersIdJsonGetResponse200($this->decodeJson($result->stdout)); + } + + public function putContainerArchive(string $id, $handle, array $query = [], int $fetch = self::FETCH_RESPONSE): ?DockerResponse + { + $path = $query['path'] ?? '/'; + if (!is_string($path) || $path === '') { + $path = '/'; + } + + $args = ['cp', '-', $id . ':' . $path]; + $result = $this->runner->run($this->command($args), $handle); + if (!$result->isSuccessful()) { + throw $this->commandFailed('Upload container archive', $args, $result); + } + + return $fetch === self::FETCH_RESPONSE ? new DockerResponse(200, $result->stdout) : null; + } + + public function imageCreate(?string $name, array $query = [], array $headers = []): CreateImageStream + { + $image = $query['fromImage'] ?? $name; + if (!is_string($image) || $image === '') { + throw new RuntimeException('imageCreate() requires an image name'); + } + + $tag = $query['tag'] ?? null; + if (is_string($tag) && $tag !== '' && !str_contains($image, ':') && !str_contains($image, '@')) { + $image .= ':' . $tag; + } + + // Registry credentials are handled by the CLI's own login state; X-Registry-Auth headers are ignored. + $result = $this->runOrFail('Create image', ['pull', $image]); + + return new CreateImageStream($result->stdout . $result->stderr); + } + + public function networkInspect(string $name): Network + { + $result = $this->runOrFail('Inspect network', ['network', 'inspect', '--format', '{{json .}}', $name]); + + return new Network($this->decodeJson($result->stdout)); + } + + /** + * Translates a /containers/create payload into `docker create` arguments. + * + * @param array $payload + * @return list + */ + private function createArguments(array $payload): array + { + $args = []; + + foreach ($this->stringMap($payload['Labels'] ?? null) as $key => $value) { + $args[] = '--label'; + $args[] = $key . '=' . $value; + } + + foreach (['Hostname' => '--hostname', 'WorkingDir' => '--workdir', 'User' => '--user'] as $field => $flag) { + $value = $payload[$field] ?? null; + if (is_string($value) && $value !== '') { + $args[] = $flag; + $args[] = $value; + } + } + + foreach ($this->stringList($payload['Env'] ?? null) as $env) { + $args[] = '--env'; + $args[] = $env; + } + + $hostConfig = $payload['HostConfig'] ?? []; + if (!is_array($hostConfig)) { + $hostConfig = []; + } + + $args = [...$args, ...$this->portArguments($payload['ExposedPorts'] ?? null, $hostConfig['PortBindings'] ?? null)]; + + if (($hostConfig['Privileged'] ?? false) === true) { + $args[] = '--privileged'; + } + if (($hostConfig['AutoRemove'] ?? false) === true) { + $args[] = '--rm'; + } + + $mounts = $hostConfig['Mounts'] ?? null; + if (is_array($mounts)) { + foreach ($mounts as $mount) { + if (!is_array($mount)) { + continue; + } + $spec = []; + foreach (['Type' => 'type', 'Source' => 'source', 'Target' => 'target'] as $field => $option) { + if (isset($mount[$field]) && is_string($mount[$field])) { + $spec[] = $option . '=' . $mount[$field]; + } + } + if ($spec !== []) { + $args[] = '--mount'; + $args[] = implode(',', $spec); + } + } + } + + foreach ($this->stringMap($hostConfig['Tmpfs'] ?? null) as $path => $options) { + $args[] = '--tmpfs'; + $args[] = $options === '' ? $path : $path . ':' . $options; + } + + $args = [...$args, ...$this->healthArguments($payload['Healthcheck'] ?? null)]; + $args = [...$args, ...$this->networkArguments($payload['NetworkingConfig'] ?? null)]; + + $entrypoint = $this->stringList($payload['Entrypoint'] ?? null); + $cmd = $this->stringList($payload['Cmd'] ?? null); + if ($entrypoint !== []) { + // The CLI flag takes a single executable; remaining entrypoint parts become leading command args. + $args[] = '--entrypoint'; + $args[] = array_shift($entrypoint); + $cmd = [...$entrypoint, ...$cmd]; + } + + $image = $payload['Image'] ?? null; + if (!is_string($image) || $image === '') { + throw new RuntimeException('Container create request has no image'); + } + $args[] = $image; + + return [...$args, ...$cmd]; + } + + /** + * @return list + */ + private function portArguments(mixed $exposedPorts, mixed $portBindings): array + { + $args = []; + $bound = []; + + if (is_array($portBindings)) { + foreach ($portBindings as $containerPort => $bindings) { + if (!is_string($containerPort) || !is_array($bindings)) { + continue; + } + foreach ($bindings as $binding) { + if (!is_array($binding)) { + continue; + } + $hostPort = $binding['HostPort'] ?? ''; + $hostIp = $binding['HostIp'] ?? ''; + $spec = is_string($hostPort) ? $hostPort : ''; + if (is_string($hostIp) && $hostIp !== '') { + $spec = $hostIp . ':' . $spec; + } + $args[] = '--publish'; + $args[] = $spec === '' ? $containerPort : $spec . ':' . $containerPort; + $bound[$containerPort] = true; + } + } + } + + if (is_array($exposedPorts)) { + foreach (array_keys($exposedPorts) as $containerPort) { + if (is_string($containerPort) && !isset($bound[$containerPort])) { + $args[] = '--expose'; + $args[] = $containerPort; + } + } + } + + return $args; + } + + /** + * @return list + */ + private function healthArguments(mixed $healthcheck): array + { + if (!is_array($healthcheck)) { + return []; + } + + $args = []; + $test = $this->stringList($healthcheck['Test'] ?? null); + if ($test !== []) { + $kind = array_shift($test); + if ($kind === 'NONE') { + return ['--no-healthcheck']; + } + if ($kind === 'CMD-SHELL') { + $args[] = '--health-cmd'; + $args[] = implode(' ', $test); + } elseif ($kind === 'CMD') { + $args[] = '--health-cmd'; + $args[] = implode(' ', array_map('escapeshellarg', $test)); + } + } + + foreach (['Interval' => '--health-interval', 'Timeout' => '--health-timeout', 'StartPeriod' => '--health-start-period'] as $field => $flag) { + $nanoseconds = $healthcheck[$field] ?? null; + if (is_int($nanoseconds) && $nanoseconds > 0) { + $args[] = $flag; + $args[] = $nanoseconds . 'ns'; + } + } + + $retries = $healthcheck['Retries'] ?? null; + if (is_int($retries) && $retries > 0) { + $args[] = '--health-retries'; + $args[] = (string) $retries; + } + + return $args; + } + + /** + * @return list + */ + private function networkArguments(mixed $networkingConfig): array + { + if (!is_array($networkingConfig)) { + return []; + } + $endpoints = $networkingConfig['EndpointsConfig'] ?? null; + if (!is_array($endpoints)) { + return []; + } + + $args = []; + foreach ($endpoints as $network => $settings) { + if (!is_string($network)) { + continue; + } + $args[] = '--network'; + $args[] = $network; + + if (is_array($settings)) { + foreach ($this->stringList($settings['Aliases'] ?? null) as $alias) { + $args[] = '--network-alias'; + $args[] = $alias; + } + } + // The CLI only accepts one network at create time; more must be connected after creation. + break; + } + + return $args; + } + + /** + * @return list + */ + private function stringList(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + return array_values(array_filter($value, 'is_string')); + } + + /** + * @return array + */ + private function stringMap(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $result = []; + foreach ($value as $key => $item) { + if (is_string($key) && is_string($item)) { + $result[$key] = $item; + } + } + + return $result; + } + + /** + * @param list $args + * @return non-empty-list + */ + private function command(array $args): array + { + return [$this->binary, ...$args]; + } + + /** + * @param list $args + */ + private function runOrFail(string $action, array $args): CommandResult + { + $result = $this->runner->run($this->command($args)); + if (!$result->isSuccessful()) { + throw $this->commandFailed($action, $args, $result); + } + + return $result; + } + + /** + * @param list $args + */ + private function commandFailed(string $action, array $args, CommandResult $result): DockerCommandException + { + return new DockerCommandException($action . ' failed', $this->command($args), $result->exitCode, $result->stderr); + } + + /** + * @return array + */ + private function decodeJson(string $json): array + { + $data = json_decode(trim($json), true); + if (!is_array($data)) { + throw new RuntimeException('Invalid JSON output from ' . $this->binary); + } + + // `inspect` without --format yields a one-element array; unwrap it for robustness. + if (array_is_list($data) && count($data) === 1 && is_array($data[0])) { + return $data[0]; + } + + return $data; + } + + private function looksLikeMissingImage(string $stderr): bool + { + return (bool) preg_match('/manifest unknown|not found|pull access denied|unable to find image|no such image|does not exist/i', $stderr); + } + + private function looksLikeMissingContainer(string $stderr): bool + { + return (bool) preg_match('/no such container|no container with name or ID/i', $stderr); + } +} diff --git a/src/Docker/Cli/CommandResult.php b/src/Docker/Cli/CommandResult.php new file mode 100644 index 0000000..35f6d35 --- /dev/null +++ b/src/Docker/Cli/CommandResult.php @@ -0,0 +1,20 @@ +exitCode === 0; + } +} diff --git a/src/Docker/Cli/CommandRunnerInterface.php b/src/Docker/Cli/CommandRunnerInterface.php new file mode 100644 index 0000000..1536562 --- /dev/null +++ b/src/Docker/Cli/CommandRunnerInterface.php @@ -0,0 +1,16 @@ + $command Executable followed by its arguments + * @param resource|string|null $stdin Data to feed to the process' standard input + */ + public function run(array $command, $stdin = null): CommandResult; +} diff --git a/src/Docker/Cli/ProcessCommandRunner.php b/src/Docker/Cli/ProcessCommandRunner.php new file mode 100644 index 0000000..e656ae9 --- /dev/null +++ b/src/Docker/Cli/ProcessCommandRunner.php @@ -0,0 +1,92 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptors, $pipes); + if (!is_resource($process)) { + throw new RuntimeException('Failed to start process: ' . implode(' ', $command)); + } + + if (is_resource($stdin)) { + stream_copy_to_stream($stdin, $pipes[0]); + } elseif (is_string($stdin) && $stdin !== '') { + fwrite($pipes[0], $stdin); + } + fclose($pipes[0]); + + [$stdout, $stderr] = $this->drain($pipes[1], $pipes[2]); + + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + return new CommandResult($exitCode, $stdout, $stderr); + } + + /** + * Reads both output pipes concurrently so that neither can fill up and block the process. + * + * @param resource $stdoutPipe + * @param resource $stderrPipe + * @return array{string, string} + */ + private function drain($stdoutPipe, $stderrPipe): array + { + $stdout = ''; + $stderr = ''; + + stream_set_blocking($stdoutPipe, false); + stream_set_blocking($stderrPipe, false); + + $open = [1 => $stdoutPipe, 2 => $stderrPipe]; + + while ($open !== []) { + $read = array_values($open); + $write = null; + $except = null; + + $ready = @stream_select($read, $write, $except, 1); + if ($ready === false) { + // stream_select is not available for pipes on every platform; fall back to sequential reads. + $stdout .= (string) stream_get_contents($stdoutPipe); + $stderr .= (string) stream_get_contents($stderrPipe); + break; + } + + foreach ($open as $index => $pipe) { + $chunk = fread($pipe, 65536); + if ($chunk !== false && $chunk !== '') { + if ($index === 1) { + $stdout .= $chunk; + } else { + $stderr .= $chunk; + } + } + if (feof($pipe)) { + unset($open[$index]); + } + } + } + + return [$stdout, $stderr]; + } +} diff --git a/src/Docker/DockerClient.php b/src/Docker/DockerClient.php index 358967b..7b3cd60 100644 --- a/src/Docker/DockerClient.php +++ b/src/Docker/DockerClient.php @@ -17,10 +17,8 @@ use Testcontainers\Docker\Model\Network; use Testcontainers\Docker\Stream\CreateImageStream; -class DockerClient +class DockerClient implements DockerClientInterface { - public const FETCH_RESPONSE = 1; - public function __construct( private ClientInterface $client ) { diff --git a/src/Docker/DockerClientInterface.php b/src/Docker/DockerClientInterface.php new file mode 100644 index 0000000..872e9e9 --- /dev/null +++ b/src/Docker/DockerClientInterface.php @@ -0,0 +1,69 @@ + $query + */ + public function containerCreate(ContainersCreatePostBody $body, array $query = []): ?ContainerCreateResponse; + + public function containerStart(string $id): void; + + public function containerStop(string $id): void; + + public function containerDelete(string $id): void; + + public function containerRestart(string $id): void; + + public function containerExec(string $id, ContainersIdExecPostBody $body): ?IdResponse; + + /** + * @param array|null $config + */ + public function execStart(string $id, ?array $config = null, int $fetch = self::FETCH_RESPONSE): ?DockerResponse; + + public function execInspect(string $id): ?ExecIdJsonGetResponse200; + + /** + * @param array $params + */ + public function containerLogs(string $id, array $params = [], int $fetch = self::FETCH_RESPONSE): ?DockerResponse; + + public function containerInspect(string $id): ?ContainersIdJsonGetResponse200; + + /** + * @param resource $handle + * @param array $query + */ + public function putContainerArchive(string $id, $handle, array $query = [], int $fetch = self::FETCH_RESPONSE): ?DockerResponse; + + /** + * @param array $query + * @param list $headers + */ + public function imageCreate(?string $name, array $query = [], array $headers = []): CreateImageStream; + + public function networkInspect(string $name): ?Network; +} diff --git a/src/Docker/Exception/DockerCommandException.php b/src/Docker/Exception/DockerCommandException.php new file mode 100644 index 0000000..41bf50f --- /dev/null +++ b/src/Docker/Exception/DockerCommandException.php @@ -0,0 +1,44 @@ + $command + */ + public function __construct( + string $message, + private array $command, + private int $exitCode, + private string $stderr + ) { + $fullMessage = sprintf('%s (exit code %d): %s', $message, $exitCode, implode(' ', $command)); + if (trim($stderr) !== '') { + $fullMessage .= "\n" . trim($stderr); + } + parent::__construct($fullMessage, $exitCode); + } + + /** + * @return list + */ + public function getCommand(): array + { + return $this->command; + } + + public function getExitCode(): int + { + return $this->exitCode; + } + + public function getStderr(): string + { + return $this->stderr; + } +} diff --git a/src/Modules/OpenSearchContainer.php b/src/Modules/OpenSearchContainer.php index 57b1f94..a1aab2b 100644 --- a/src/Modules/OpenSearchContainer.php +++ b/src/Modules/OpenSearchContainer.php @@ -19,7 +19,7 @@ public function __construct(string $version = 'latest') ]); $this->withWait(new WaitForLog( - '/\]\s+started\?\[/', + '/\]\s+started\b/', true, 30000 )); diff --git a/src/Utils/HostResolver.php b/src/Utils/HostResolver.php index 7180c41..976bac4 100644 --- a/src/Utils/HostResolver.php +++ b/src/Utils/HostResolver.php @@ -4,7 +4,7 @@ namespace Testcontainers\Utils; -use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\DockerClientInterface; use Testcontainers\Docker\Model\Network; use RuntimeException; use Testcontainers\Container\GenericContainer; @@ -12,7 +12,7 @@ class HostResolver { - public function __construct(protected ?DockerClient $dockerClient = null) + public function __construct(protected ?DockerClientInterface $dockerClient = null) { $this->dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); } diff --git a/tests/Unit/ContainerClient/DockerContainerClientTest.php b/tests/Unit/ContainerClient/DockerContainerClientTest.php index 8ae373c..4e8688d 100644 --- a/tests/Unit/ContainerClient/DockerContainerClientTest.php +++ b/tests/Unit/ContainerClient/DockerContainerClientTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Testcontainers\ContainerClient\DockerContainerClient; +use Testcontainers\Docker\Cli\CliDockerClient; use Testcontainers\Docker\Client\ClientInterface; use Testcontainers\Docker\DockerClient; @@ -49,6 +50,8 @@ private function resetState(): void $property->setValue(null, null); DockerContainerClient::resetDockerClientFactory(); + putenv('TESTCONTAINERS_CLIENT'); + putenv('TESTCONTAINERS_CLI_BINARY'); } /** @@ -164,4 +167,28 @@ public function testUserAgentHeaderContainsUnknownWhenVersionResolutionFails(): 'When version resolution falls back to unknown, User-Agent must be tc-php/unknown' ); } + + // ------------------------------------------------------------------------- + // Adapter selection via TESTCONTAINERS_CLIENT + // ------------------------------------------------------------------------- + + public function testCliAdapterIsSelectedFromEnvironment(): void + { + putenv('TESTCONTAINERS_CLIENT=cli'); + putenv('TESTCONTAINERS_CLI_BINARY=podman'); + + $client = DockerContainerClient::getDockerClient(); + + $this->assertInstanceOf(CliDockerClient::class, $client); + $this->assertSame('podman', $client->getBinary()); + } + + public function testUnknownAdapterIsRejected(): void + { + putenv('TESTCONTAINERS_CLIENT=carrier-pigeon'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('carrier-pigeon'); + DockerContainerClient::getDockerClient(); + } } diff --git a/tests/Unit/Docker/Cli/CliDockerClientTest.php b/tests/Unit/Docker/Cli/CliDockerClientTest.php new file mode 100644 index 0000000..b9942d3 --- /dev/null +++ b/tests/Unit/Docker/Cli/CliDockerClientTest.php @@ -0,0 +1,277 @@ +, stdin: string|null}> */ + public array $calls = []; + + /** @var list */ + private array $results; + + public function __construct(CommandResult ...$results) + { + $this->results = array_values($results); + } + + public function run(array $command, $stdin = null): CommandResult + { + $input = null; + if (is_resource($stdin)) { + $input = (string) stream_get_contents($stdin); + } elseif (is_string($stdin)) { + $input = $stdin; + } + + $this->calls[] = ['command' => $command, 'stdin' => $input]; + + return array_shift($this->results) ?? new CommandResult(0, '', ''); + } +} + +/** + * @covers \Testcontainers\Docker\Cli\CliDockerClient + */ +class CliDockerClientTest extends TestCase +{ + public function testUsesConfiguredBinary(): void + { + $runner = new FakeCommandRunner(); + $client = new CliDockerClient('podman', $runner); + + $client->containerStart('abc'); + + $this->assertSame([['podman', 'start', 'abc']], array_column($runner->calls, 'command')); + } + + public function testCreateReadsBinaryFromEnvironment(): void + { + putenv('TESTCONTAINERS_CLI_BINARY=nerdctl'); + try { + $this->assertSame('nerdctl', CliDockerClient::create()->getBinary()); + } finally { + putenv('TESTCONTAINERS_CLI_BINARY'); + } + + $this->assertSame('docker', CliDockerClient::create()->getBinary()); + } + + public function testContainerCreateTranslatesTheApiPayloadIntoCliArguments(): void + { + $runner = new FakeCommandRunner(new CommandResult(0, "abc123\n", '')); + $client = new CliDockerClient('docker', $runner); + + $hostConfig = (new HostConfig()) + ->setPrivileged(true) + ->setAutoRemove(true) + ->setPortBindings(['8080/tcp' => [(new PortBinding())->setHostIp('0.0.0.0')->setHostPort('49152')]]) + ->setMounts([(new Mount())->setType('bind')->setSource('/host')->setTarget('/data')]) + ->setTmpfs(['/tmp' => 'rw,noexec']); + + $health = (new HealthConfig()) + ->setTest(['CMD-SHELL', 'curl -f localhost']) + ->setInterval(1_000_000_000) + ->setTimeout(3_000_000_000) + ->setRetries(3); + + $endpoint = (new EndpointSettings())->setNetworkID('my-net')->setAliases(['db', 'primary']); + $networking = (new NetworkingConfig())->setEndpointsConfig(['my-net' => $endpoint]); + + $body = (new ContainersCreatePostBody()) + ->setImage('alpine:3.14') + ->setCmd(['tail', '-f', '/dev/null']) + ->setLabels(['org.testcontainers' => 'true']) + ->setHostname('box') + ->setWorkingDir('/app') + ->setUser('1000:1000') + ->setEnv(['FOO=bar']) + ->setExposedPorts(['8080/tcp' => new \stdClass(), '9000/udp' => new \stdClass()]) + ->setEntrypoint(['/bin/sh', '-c']) + ->setHealthcheck($health) + ->setNetworkingConfig($networking) + ->setHostConfig($hostConfig); + + $response = $client->containerCreate($body, ['name' => 'my-container']); + + $this->assertSame('abc123', $response->getId()); + $this->assertSame([ + 'docker', 'create', + '--name', 'my-container', + '--label', 'org.testcontainers=true', + '--hostname', 'box', + '--workdir', '/app', + '--user', '1000:1000', + '--env', 'FOO=bar', + '--publish', '0.0.0.0:49152:8080/tcp', + '--expose', '9000/udp', + '--privileged', + '--rm', + '--mount', 'type=bind,source=/host,target=/data', + '--tmpfs', '/tmp:rw,noexec', + '--health-cmd', 'curl -f localhost', + '--health-interval', '1000000000ns', + '--health-timeout', '3000000000ns', + '--health-retries', '3', + '--network', 'my-net', + '--network-alias', 'db', + '--network-alias', 'primary', + '--entrypoint', '/bin/sh', + 'alpine:3.14', + '-c', 'tail', '-f', '/dev/null', + ], $runner->calls[0]['command']); + } + + public function testContainerCreateMapsMissingImageToNotFoundException(): void + { + $runner = new FakeCommandRunner(new CommandResult(125, '', 'Error: manifest unknown: manifest unknown')); + $client = new CliDockerClient('docker', $runner); + + $this->expectException(ContainerCreateNotFoundException::class); + $client->containerCreate((new ContainersCreatePostBody())->setImage('nope:1')); + } + + public function testFailedCommandThrowsWithStderr(): void + { + $runner = new FakeCommandRunner(new CommandResult(1, '', 'permission denied')); + $client = new CliDockerClient('docker', $runner); + + try { + $client->containerStart('abc'); + $this->fail('Expected exception'); + } catch (DockerCommandException $e) { + $this->assertSame(1, $e->getExitCode()); + $this->assertSame('permission denied', $e->getStderr()); + $this->assertSame(['docker', 'start', 'abc'], $e->getCommand()); + $this->assertStringContainsString('Start container failed', $e->getMessage()); + } + } + + public function testExecLifecycleRunsCommandOnStartAndExposesExitCode(): void + { + $runner = new FakeCommandRunner(new CommandResult(3, "out\n", "err\n")); + $client = new CliDockerClient('docker', $runner); + + $exec = $client->containerExec('abc', (new ContainersIdExecPostBody())->setCmd(['sh', '-c', 'exit 3'])); + $execId = $exec->getId(); + $this->assertNotNull($execId); + + // Nothing runs until execStart(); exit code is unknown so far. + $this->assertSame([], array_column($runner->calls, 'command')); + $this->assertNull($client->execInspect($execId)->getExitCode()); + + $response = $client->execStart($execId); + + $this->assertSame([['docker', 'exec', 'abc', 'sh', '-c', 'exit 3']], array_column($runner->calls, 'command')); + $this->assertNotNull($response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame("out\nerr\n", $response->getBody()->getContents()); + $this->assertSame(3, $client->execInspect($execId)->getExitCode()); + } + + public function testExecStartWithUnknownIdFails(): void + { + $client = new CliDockerClient('docker', new FakeCommandRunner()); + + $this->expectException(\RuntimeException::class); + $client->execStart('unknown'); + } + + public function testContainerInspectDecodesFormattedJson(): void + { + $json = json_encode([ + 'Name' => '/my-container', + 'State' => ['Status' => 'running'], + 'NetworkSettings' => ['Ports' => ['80/tcp' => [['HostIp' => '0.0.0.0', 'HostPort' => '32768']]]], + ], JSON_THROW_ON_ERROR); + $runner = new FakeCommandRunner(new CommandResult(0, $json . "\n", '')); + $client = new CliDockerClient('docker', $runner); + + $inspect = $client->containerInspect('abc'); + + $this->assertSame(['docker', 'inspect', '--type', 'container', '--format', '{{json .}}', 'abc'], $runner->calls[0]['command']); + $this->assertSame('my-container', trim($inspect->getName() ?? '', '/')); + $this->assertSame('running', $inspect->getState()?->getStatus()); + $ports = $inspect->getNetworkSettings()?->getPorts(); + $this->assertNotNull($ports); + $this->assertSame('32768', $ports['80/tcp'][0]->getHostPort()); + } + + public function testInspectUnwrapsSingleElementArrays(): void + { + $json = json_encode([['IPAM' => ['Config' => [['Gateway' => '172.17.0.1']]]]], JSON_THROW_ON_ERROR); + $client = new CliDockerClient('docker', new FakeCommandRunner(new CommandResult(0, $json, ''))); + + $network = $client->networkInspect('bridge'); + + $this->assertSame('172.17.0.1', $network->getIPAM()?->getConfig()[0]->getGateway()); + } + + public function testPutContainerArchivePipesTarToCp(): void + { + $runner = new FakeCommandRunner(); + $client = new CliDockerClient('docker', $runner); + + $handle = fopen('php://memory', 'r+'); + $this->assertIsResource($handle); + fwrite($handle, 'tar-bytes'); + rewind($handle); + + $client->putContainerArchive('abc', $handle, ['path' => '/']); + fclose($handle); + + $this->assertSame(['docker', 'cp', '-', 'abc:/'], $runner->calls[0]['command']); + $this->assertSame('tar-bytes', $runner->calls[0]['stdin']); + } + + public function testImageCreatePullsImageWithTag(): void + { + $runner = new FakeCommandRunner(); + $client = new CliDockerClient('docker', $runner); + + $client->imageCreate(null, ['fromImage' => 'alpine', 'tag' => '3.14'], ['X-Registry-Auth: ignored'])->wait(); + + $this->assertSame([['docker', 'pull', 'alpine:3.14']], array_column($runner->calls, 'command')); + } + + public function testContainerDeleteIgnoresMissingContainers(): void + { + $runner = new FakeCommandRunner(new CommandResult(1, '', 'Error response from daemon: No such container: abc')); + $client = new CliDockerClient('docker', $runner); + + $client->containerDelete('abc'); + + $this->assertCount(1, $runner->calls); + } + + public function testContainerLogsCombineStdoutAndStderr(): void + { + $runner = new FakeCommandRunner(new CommandResult(0, "hello\n", "warn\n")); + $client = new CliDockerClient('docker', $runner); + + $response = $client->containerLogs('abc', ['stdout' => true, 'stderr' => true]); + + $this->assertSame(['docker', 'logs', 'abc'], $runner->calls[0]['command']); + $this->assertSame("hello\nwarn\n", $response?->getBody()->getContents()); + } +}