Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 2 additions & 6 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down Expand Up @@ -59,8 +56,7 @@
},
"config": {
"allow-plugins": {
"phpstan/extension-installer": true,
"php-http/discovery": false
"phpstan/extension-installer": true
}
}
}
57 changes: 53 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 23 additions & 18 deletions src/Container/GenericContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,7 +28,7 @@

class GenericContainer implements TestContainer
{
protected Docker $dockerClient;
protected DockerClientInterface $dockerClient;

protected string $image;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
}
Expand Down Expand Up @@ -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
Expand All @@ -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 */
Expand Down
42 changes: 19 additions & 23 deletions src/Container/StartedGenericContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -40,7 +37,7 @@ public function getLastExecId(): ?string
return $this->lastExecId;
}

public function getClient(): Docker
public function getClient(): DockerClientInterface
{
return $this->dockerClient;
}
Expand All @@ -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);
}
Expand All @@ -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
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 3 additions & 3 deletions src/Container/StartedTestContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;

Expand Down
Loading
Loading