diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index ca0ba6f..6edcf2c 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -5,16 +5,20 @@ declare(strict_types=1); 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 InvalidArgumentException; use Testcontainers\ContainerClient\DockerContainerClient; use Testcontainers\Utils\PortGenerator\RandomUniquePortGenerator; +use Testcontainers\Utils\PortNormalizer; use Testcontainers\Wait\WaitForContainer; use Testcontainers\Wait\WaitStrategy; @@ -144,37 +148,13 @@ class GenericContainer implements TestContainer $this->withExposedPorts(...$port); } else { // Handle single port entry, either string or int - $this->exposedPorts[] = $this->normalizePort($port); + $this->exposedPorts[] = PortNormalizer::normalizePort($port); } } return $this; } - /** - * Normalize a port specification to ensure it includes a protocol. - * Defaults to 'tcp' if no protocol is specified. - * - * @param string|int $port Port to normalize. - * @return string Normalized port string. - * - * TODO: move this to a utility class - */ - private function normalizePort(string|int $port): string - { - if (is_int($port)) { - // Direct integer ports default to tcp - return "{$port}/tcp"; - } - - // Check if the port specification already includes a protocol - if (is_string($port) && !str_contains($port, '/')) { - return "{$port}/tcp"; - } - - return $port; - } - public function withPrivilegedMode(bool $privileged = true): static { $this->isPrivileged = $privileged; @@ -190,54 +170,16 @@ class GenericContainer implements TestContainer return $this; } - //TODO: needs refactoring public function start(): StartedGenericContainer { + $containerConfig = $this->createContainerConfig(); try { - $containerCreatePostBody = new ContainersCreatePostBody(); - //handle withExposedPorts - if (!empty($this->exposedPorts)) { - $portGenerator = new RandomUniquePortGenerator(); - $portMap = new \ArrayObject(); - - foreach ($this->exposedPorts as $port) { - $portBinding = new PortBinding(); - $portBinding->setHostPort((string) $portGenerator->generatePort()); - $portBinding->setHostIp('0.0.0.0'); - $portMap[$port] = [$portBinding]; - } - - $hostConfig = new HostConfig(); - $hostConfig->setPortBindings($portMap); - //handle withPrivilegedMode - if ($this->isPrivileged) { - $hostConfig->setPrivileged($this->isPrivileged); - } - $containerCreatePostBody->setHostConfig($hostConfig); - } - //handle withPrivilegedMode - if ($this->isPrivileged) { - $hostConfig = new HostConfig(); - $hostConfig->setPrivileged($this->isPrivileged); - } - $containerCreatePostBody->setImage($this->image); - $containerCreatePostBody->setCmd($this->command); - $envs = []; - foreach ($this->env as $key => $value) { - $envs[] = $key . '=' . $value; - } - $containerCreatePostBody->setEnv($envs); - - $containerCreateResponse = $this->dockerClient->containerCreate($containerCreatePostBody); + /** @var ContainerCreateResponse|null $containerCreateResponse */ + $containerCreateResponse = $this->dockerClient->containerCreate($containerConfig); $this->id = $containerCreateResponse?->getId() ?? ''; } catch (ContainerCreateNotFoundException) { - /** @var CreateImageStream $imageCreateResponse */ - $imageCreateResponse = $this->dockerClient->imageCreate(null, [ - 'fromImage' => explode(':', $this->image)[0], - 'tag' => explode(':', $this->image)[1] ?? 'latest', - ]); - $imageCreateResponse->wait(); - + // If the image is not found, pull it and try again + $this->pullImage(); return $this->start(); } @@ -252,4 +194,94 @@ class GenericContainer implements TestContainer return $startedContainer; } + + protected function createContainerConfig(): ContainersCreatePostBody + { + $containerCreatePostBody = new ContainersCreatePostBody(); + $containerCreatePostBody->setImage($this->image); + $containerCreatePostBody->setCmd($this->command); + + $envs = array_map(static fn ($key, $value) => "$key=$value", array_keys($this->env), $this->env); + $containerCreatePostBody->setEnv($envs); + + $hostConfig = $this->createHostConfig(); + $containerCreatePostBody->setHostConfig($hostConfig); + + if ($this->entryPoint !== null) { + $containerCreatePostBody->setEntrypoint([$this->entryPoint]); + } + + if ($this->healthConfig !== null) { + $containerCreatePostBody->setHealthcheck($this->healthConfig); + } + + if ($this->networkName !== null) { + $networkingConfig = new NetworkingConfig(); + $endpointsConfig = new \ArrayObject([ + $this->networkName => new EndpointSettings(), + ]); + $networkingConfig->setEndpointsConfig($endpointsConfig); + $containerCreatePostBody->setNetworkingConfig($networkingConfig); + } + + return $containerCreatePostBody; + } + + protected function createHostConfig(): ?HostConfig + { + /** + * For some reason, if some of the properties are not set, but HostConfig is returned, + * the API will throw ContainerCreateBadRequestException: bad parameter. + * Until it will be checked and fixed, we just return null if these properties are not set. + * */ + if ($this->exposedPorts === [] && !$this->isPrivileged && $this->mounts === []) { + return null; + } + + $hostConfig = new HostConfig(); + + if ($this->exposedPorts !== []) { + $portBindings = $this->createPortBindings(); + $hostConfig->setPortBindings($portBindings); + } + + if ($this->isPrivileged) { + $hostConfig->setPrivileged(true); + } + + if ($this->mounts !== []) { + $hostConfig->setMounts($this->mounts); + } + + return $hostConfig; + } + + /** + * @return \ArrayObject> + */ + protected function createPortBindings(): \ArrayObject + { + $portGenerator = new RandomUniquePortGenerator(); + $portBindings = new \ArrayObject(); + + foreach ($this->exposedPorts as $port) { + $portBinding = new PortBinding(); + $portBinding->setHostPort((string)$portGenerator->generatePort()); + $portBinding->setHostIp('0.0.0.0'); + $portBindings[$port] = [$portBinding]; + } + + return $portBindings; + } + + protected function pullImage(): void + { + [$fromImage, $tag] = explode(':', $this->image) + [1 => 'latest']; + /** @var CreateImageStream $imageCreateResponse */ + $imageCreateResponse = $this->dockerClient->imageCreate(null, [ + 'fromImage' => $fromImage, + 'tag' => $tag, + ]); + $imageCreateResponse->wait(); + } } diff --git a/src/Container/InternetProtocol.php b/src/Container/InternetProtocol.php new file mode 100644 index 0000000..8b4e8c1 --- /dev/null +++ b/src/Container/InternetProtocol.php @@ -0,0 +1,24 @@ +value); + } + + public static function fromDockerNotation(string $protocol): self + { + return self::from(strtoupper($protocol)); + } +} diff --git a/src/Utils/PortNormalizer.php b/src/Utils/PortNormalizer.php new file mode 100644 index 0000000..d9cded5 --- /dev/null +++ b/src/Utils/PortNormalizer.php @@ -0,0 +1,32 @@ +toDockerNotation()}"; + } + + // Check if the port specification already includes a protocol + if (is_string($port) && !str_contains($port, '/')) { + return "{$port}/{$internetProtocol->toDockerNotation()}"; + } + + return $port; + } +}