diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 1659e7d..d2d378a 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -47,20 +47,42 @@ 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] + 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: 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 - name: Run test suite run: composer run integration + env: + DOCKER_HOST: ${{ matrix.docker_host }} diff --git a/composer.json b/composer.json index 041db96..e6f8531 100644 --- a/composer.json +++ b/composer.json @@ -15,8 +15,7 @@ ], "require": { "ext-curl": "*", - "php": ">= 8.1", - "beluga-php/docker-php": "^1.45" + "php": ">= 8.1" }, "require-dev": { "ext-pdo": "*", diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index da3be02..7d49f5b 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; @@ -370,10 +370,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, @@ -402,6 +399,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]); } @@ -475,7 +480,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 @@ -488,7 +493,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 6e2acdd..e84ff1a 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -4,27 +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\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(); } @@ -39,7 +37,7 @@ public function getLastExecId(): ?string return $this->lastExecId; } - public function getClient(): Docker + public function getClient(): DockerClient { return $this->dockerClient; } @@ -65,7 +63,7 @@ public function exec(array $command): string $this->lastExecId = $exec->getId(); $contents = $this->dockerClient - ->execStart($this->lastExecId, null, Client::FETCH_RESPONSE) + ->execStart($this->lastExecId, null, DockerClient::FETCH_RESPONSE) ?->getBody() ->getContents() ?? ''; @@ -93,7 +91,7 @@ public function logs(): string ->containerLogs( $this->id, ['stdout' => true, 'stderr' => true], - DockerRuntimeClient::FETCH_RESPONSE + DockerClient::FETCH_RESPONSE ) ?->getBody() ->getContents() ?? ''; @@ -206,10 +204,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 8001992..141afd1 100644 --- a/src/ContainerClient/DockerContainerClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -4,7 +4,7 @@ namespace Testcontainers\ContainerClient; -use Docker\Docker as DockerClient; +use Testcontainers\Docker\DockerClient; class DockerContainerClient { diff --git a/src/Docker/Client/ClientInterface.php b/src/Docker/Client/ClientInterface.php new file mode 100644 index 0000000..75fbd7b --- /dev/null +++ b/src/Docker/Client/ClientInterface.php @@ -0,0 +1,23 @@ + $query + * @param list $headers + */ + public function request(string $method, string $path, array $query = [], ?string $body = null, array $headers = []): DockerResponse; + + /** + * @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..1f8ae3b --- /dev/null +++ b/src/Docker/Client/CurlClient.php @@ -0,0 +1,139 @@ +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); + } + + 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); + } + + 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->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 array $query + */ + 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..2c25b99 --- /dev/null +++ b/src/Docker/DockerClient.php @@ -0,0 +1,241 @@ +baseUri, + $config->unixSocketPath, + $config->tlsEnabled, + $config->tlsVerify, + $config->certPath, + $config->apiVersion + )); + } + + /** + * @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 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 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 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..8bef034 --- /dev/null +++ b/src/Docker/DockerHostConfig.php @@ -0,0 +1,126 @@ +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/ArraySerializableInterface.php b/src/Docker/Model/ArraySerializableInterface.php new file mode 100644 index 0000000..3f15876 --- /dev/null +++ b/src/Docker/Model/ArraySerializableInterface.php @@ -0,0 +1,18 @@ + + */ + public function toArray(): array; +} diff --git a/src/Docker/Model/ArraySerializableTrait.php b/src/Docker/Model/ArraySerializableTrait.php new file mode 100644 index 0000000..01b230b --- /dev/null +++ b/src/Docker/Model/ArraySerializableTrait.php @@ -0,0 +1,36 @@ + $payload The payload array to add to + * @param string $key The key to use in the payload + * @param mixed $value The value to add (can be a scalar, array, or ArraySerializableInterface) + */ + protected function addIfSet(array &$payload, string $key, mixed $value): void + { + if ($value === null) { + return; + } + + if (is_array($value) && $value === []) { + return; + } + + if ($value instanceof ArraySerializableInterface) { + $payload[$key] = $value->toArray(); + return; + } + + $payload[$key] = $value; + } +} diff --git a/src/Docker/Model/ContainerConfig.php b/src/Docker/Model/ContainerConfig.php new file mode 100644 index 0000000..09bc429 --- /dev/null +++ b/src/Docker/Model/ContainerConfig.php @@ -0,0 +1,47 @@ + */ + 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; + return is_array($labels) ? $labels : null; + } + + 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) ? $entrypoint : null; + } +} diff --git a/src/Docker/Model/ContainerCreateResponse.php b/src/Docker/Model/ContainerCreateResponse.php new file mode 100644 index 0000000..e2e0073 --- /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..0d33df4 --- /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..0f14399 --- /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..d675d66 --- /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..1b524a7 --- /dev/null +++ b/src/Docker/Model/EndpointSettings.php @@ -0,0 +1,66 @@ +|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; + + if (isset($data['aliases']) && is_array($data['aliases'])) { + $this->aliases = $data['aliases']; + } elseif (isset($data['Aliases']) && is_array($data['Aliases'])) { + $this->aliases = $data['Aliases']; + } + } + + 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..2679792 --- /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..7ddb6a6 --- /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 = $data['Test']; + } + 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..cf4283f --- /dev/null +++ b/src/Docker/Model/HostConfig.php @@ -0,0 +1,124 @@ +>|null */ + private ?array $portBindings = null; + private ?bool $privileged = null; + + /** @var array|null */ + private ?array $mounts = null; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + if (array_key_exists('Privileged', $data)) { + $this->privileged = (bool) $data['Privileged']; + } + if (isset($data['PortBindings']) && is_array($data['PortBindings'])) { + $this->portBindings = $this->hydratePortBindings($data['PortBindings']); + } + if (isset($data['Mounts']) && is_array($data['Mounts'])) { + $this->mounts = array_map( + static fn (array $mount) => new Mount($mount), + $data['Mounts'] + ); + } + } + + /** + * @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; + } + + /** + * @param array $mounts + */ + public function setMounts(array $mounts): self + { + $this->mounts = $mounts; + + return $this; + } + + public function getPrivileged(): ?bool + { + return $this->privileged; + } + + /** + * @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->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_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..0e45117 --- /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..8b939be --- /dev/null +++ b/src/Docker/Model/Mount.php @@ -0,0 +1,46 @@ + $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; + } + + /** + * @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..c97947e --- /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..b1e41ab --- /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..8d6f75d --- /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..cd68f73 --- /dev/null +++ b/src/Docker/Model/NetworkSettings.php @@ -0,0 +1,78 @@ + */ + 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_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_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..3945851 --- /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 ce7af35..7193624 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/src/Wait/WaitForHttp.php b/src/Wait/WaitForHttp.php index 31d4c08..bffb2e7 100644 --- a/src/Wait/WaitForHttp.php +++ b/src/Wait/WaitForHttp.php @@ -140,7 +140,9 @@ private function makeHttpRequest(string $url): int curl_exec($ch); $responseCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + if (PHP_VERSION_ID < 80500) { + curl_close($ch); + } return $responseCode; } diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 0b4188f..5b5df1d 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/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 a92ed90..ef3f5fe 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);