diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 1659e7d..18eb12f 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -48,19 +48,46 @@ 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 - + - 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: + TESTCONTAINERS_CLIENT: ${{ matrix.adapter }} + + 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, pdo, pdo_mysql, pdo_pgsql + + - 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 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/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 34c6408..bab4319 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\DockerClientInterface; +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 DockerClientInterface $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..0d480ea 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\DockerClientInterface; +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 DockerClientInterface $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, ?DockerClientInterface $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(): DockerClientInterface { 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, DockerClientInterface::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 + DockerClientInterface::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..a6e73f4 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\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(): Docker; + public function getClient(): DockerClientInterface; public function getFirstMappedPort(): int; diff --git a/src/ContainerClient/DockerContainerClient.php b/src/ContainerClient/DockerContainerClient.php index e308a68..0c29951 100644 --- a/src/ContainerClient/DockerContainerClient.php +++ b/src/ContainerClient/DockerContainerClient.php @@ -5,28 +5,24 @@ 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 RuntimeException; +use Testcontainers\Docker\Cli\CliDockerClient; +use Testcontainers\Docker\DockerClient; +use Testcontainers\Docker\DockerClientInterface; class DockerContainerClient { - /** - * @var DockerClient|null Singleton instance of DockerClient - */ - private static ?DockerClient $dockerClient = null; + public const ADAPTER_API = 'api'; + public const ADAPTER_CLI = 'cli'; /** - * @var (callable(): ClientInterface)|null Factory for the base HTTP client. - * When null, DockerClientFactory::createFromEnv() is used. + * @var DockerClientInterface|null Singleton instance of the Docker client */ - private static $httpClientFactory = null; + private static ?DockerClientInterface $dockerClient = null; /** - * @var (callable(ClientInterface): DockerClient)|null Factory for the Docker client. - * 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; @@ -35,29 +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) { - $version = static::resolveVersion(); - - $baseHttpClient = self::createHttpClient(); - - $httpClient = new PluginClient( - $baseHttpClient, - [new HeaderDefaultsPlugin(['User-Agent' => 'tc-php/' . $version])] - ); + $userAgent = 'tc-php/' . static::resolveVersion(); - self::$dockerClient = self::createDockerClient($httpClient); + self::$dockerClient = self::$dockerClientFactory !== null + ? (self::$dockerClientFactory)($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. * @@ -79,57 +92,34 @@ 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. * - * @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; } /** - * 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): DockerClientInterface)|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/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/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..7b3cd60 --- /dev/null +++ b/src/Docker/DockerClient.php @@ -0,0 +1,249 @@ +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/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/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 @@ + $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/Docker/Exception/DockerRequestException.php b/src/Docker/Exception/DockerRequestException.php new file mode 100644 index 0000000..49784cd --- /dev/null +++ b/src/Docker/Exception/DockerRequestException.php @@ -0,0 +1,32 @@ +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/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 6c74dc2..976bac4 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\DockerClientInterface; +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 ?DockerClientInterface $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..4e8688d 100644 --- a/tests/Unit/ContainerClient/DockerContainerClientTest.php +++ b/tests/Unit/ContainerClient/DockerContainerClientTest.php @@ -4,12 +4,11 @@ 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\Cli\CliDockerClient; +use Testcontainers\Docker\Client\ClientInterface; +use Testcontainers\Docker\DockerClient; /** * Test subclass used by testUserAgentHeaderContainsUnknownWhenVersionResolutionFails. @@ -23,10 +22,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 +49,27 @@ private function resetState(): void $property = $reflection->getProperty('dockerClient'); $property->setValue(null, null); - DockerContainerClient::resetFactories(); - } - - // ------------------------------------------------------------------------- - // 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. - */ - private function injectNoSocketFactories(?PluginClient &$capturedHttpClient = null): 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(); - } - ); + DockerContainerClient::resetDockerClientFactory(); + putenv('TESTCONTAINERS_CLIENT'); + putenv('TESTCONTAINERS_CLI_BINARY'); } /** - * Returns the private $plugins array from a PluginClient via reflection. - * - * @return \Http\Client\Common\Plugin[] + * 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 getPlugins(PluginClient $client): array + private function injectCapturingFactory(?string &$capturedUserAgent): void { - $prop = (new \ReflectionClass(PluginClient::class))->getProperty('plugins'); - - /** @var \Http\Client\Common\Plugin[] $plugins */ - $plugins = $prop->getValue($client); + $httpClient = $this->createMock(ClientInterface::class); - return $plugins; - } + DockerContainerClient::setDockerClientFactory( + static function (string $userAgent) use (&$capturedUserAgent, $httpClient): DockerClient { + $capturedUserAgent = $userAgent; - /** - * 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 +122,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,88 +136,59 @@ 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' ); } + + // ------------------------------------------------------------------------- + // 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()); + } +} 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); 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. */