diff --git a/composer.json b/composer.json index 773c728..42cc19a 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "integration:old": "phpunit tests/Integration/OldTests --bootstrap vendor/autoload.php", "cs": "php-cs-fixer fix --dry-run", "cs:fix": "php-cs-fixer fix", - "phpstan": "phpstan analyse" + "phpstan": "phpstan analyse --memory-limit=256M" }, "config": { "allow-plugins": { diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index 5e45977..259abd6 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -6,21 +6,27 @@ namespace Testcontainers\Container; use Docker\API\Client; use Docker\API\Model\ContainersIdExecPostBody; +use Docker\API\Model\ContainersIdJsonGetResponse200; +use Docker\API\Model\EndpointSettings; use Docker\API\Model\IdResponse; +use Docker\API\Model\PortBinding; use Docker\API\Runtime\Client\Client as DockerRuntimeClient; use Docker\Docker; -use Psr\Http\Message\ResponseInterface; +use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; +use Testcontainers\Utils\HostResolver; class StartedGenericContainer implements StartedTestContainer { protected Docker $dockerClient; + protected ?ContainersIdJsonGetResponse200 $inspectResponse = null; + protected ?string $lastExecId = null; - public function __construct(protected readonly string $id) + public function __construct(protected readonly string $id, ?Docker $dockerClient = null) { - $this->dockerClient = DockerContainerClient::getDockerClient(); + $this->dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); } public function getId(): string @@ -53,7 +59,7 @@ class StartedGenericContainer implements StartedTestContainer $exec = $this->dockerClient->containerExec($this->id, $execConfig); if ($exec === null || $exec->getId() === null) { - throw new \RuntimeException('Failed to create exec command'); + throw new RuntimeException('Failed to create exec command'); } $this->lastExecId = $exec->getId(); @@ -63,7 +69,7 @@ class StartedGenericContainer implements StartedTestContainer ?->getBody() ->getContents() ?? ''; - return preg_replace('/[\x00-\x1F\x7F]/u', '', $contents) ?? ''; + return $this->sanitizeOutput($contents); } public function stop(): StoppedTestContainer @@ -92,81 +98,117 @@ class StartedGenericContainer implements StartedTestContainer ?->getBody() ->getContents() ?? ''; - return preg_replace('/[\x00-\x1F\x7F]/u', '', mb_convert_encoding($output, 'UTF-8', 'UTF-8')) ?? ''; + return $this->sanitizeOutput(mb_convert_encoding($output, 'UTF-8', 'UTF-8')); } - //TODO: replace with the proper implementation public function getHost(): string { - return '127.0.0.1'; + return (new HostResolver($this->dockerClient))->resolveHost(); } - //TODO: not ready yet public function getMappedPort(int $port): int { - return $this->inspect()->ports[$port]; + $ports = (array) $this->ports(); + /** @var PortBinding | null $portBinding */ + $portBinding = $ports["{$port}/tcp"][0] ?? null; + $mappedPort = $portBinding?->getHostPort(); + if ($mappedPort !== null) { + return (int) $mappedPort; + } + + throw new RuntimeException("Failed to get mapped port ‘{$mappedPort}’ for container"); } - /** - * @throws \JsonException - */ public function getFirstMappedPort(): int { - //For some reason, containerInspect can crash when using FETCH_OBJECT option (e.g. with OpenSearch) - //should be checked within beluga-php/docker-php client library - /** @var ResponseInterface | null $containerInspectResponse */ - $containerInspectResponse = $this->dockerClient->containerInspect($this->id, [], Docker::FETCH_RESPONSE); - if ($containerInspectResponse === null) { - throw new \RuntimeException('Failed to inspect container'); - } - - $containerInspectResponseAsArray = json_decode( - $containerInspectResponse->getBody()->getContents(), - true, - 512, - JSON_THROW_ON_ERROR - ); - - /** @var array>> $ports */ - $ports = $containerInspectResponseAsArray['NetworkSettings']['Ports'] ?? []; - - if ($ports === []) { - throw new \RuntimeException('Failed to get ports from container'); - } - + $ports = (array) $this->ports(); $port = array_key_first($ports); + /** @var PortBinding | null $firstPortBinding */ + $firstPortBinding = $ports[$port][0] ?? null; + $firstMappedPort = $firstPortBinding?->getHostPort(); + if ($firstMappedPort !== null) { + return (int) $firstMappedPort; + } - return (int) $ports[$port][0]['HostPort']; + throw new RuntimeException('Failed to get first mapped port for container'); } public function getName(): string { - // TODO: Implement getName() method. - return ''; + return trim($this->inspect()?->getName() ?? '', '/ '); } + /** + * @return array + */ public function getLabels(): array { - // TODO: Implement getLabels() method. - return []; + return (array) $this->inspect()?->getConfig()?->getLabels(); } - + /** + * @return string[] + */ public function getNetworkNames(): array { - // TODO: Implement getNetworkNames() method. - return []; + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); + return array_keys($networks); } public function getNetworkId(string $networkName): string { - // TODO: Implement getNetworkId() method. - return ''; + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); + /** @var EndpointSettings | null $endpointSettings */ + $endpointSettings = $networks[$networkName] ?? null; + $networkID = $endpointSettings?->getNetworkID(); + if ($networkID !== null) { + return $networkID; + } + + throw new RuntimeException("Network with name ‘{$networkName}’ does not exist"); } public function getIpAddress(string $networkName): string { - // TODO: Implement getIpAddress() method. - return ''; + $networks = (array) $this->inspect()?->getNetworkSettings()?->getNetworks(); + /** @var EndpointSettings | null $endpointSettings */ + $endpointSettings = $networks[$networkName] ?? null; + $ipAddress = $endpointSettings?->getIPAddress(); + if ($ipAddress !== null) { + return $ipAddress; + } + + throw new RuntimeException("Network with name ‘{$networkName}’ does not exist"); + } + + protected function inspect(): ContainersIdJsonGetResponse200 | null + { + if ($this->inspectResponse === null) { + /** @var ContainersIdJsonGetResponse200 | null $inspectResponse */ + $inspectResponse = $this->dockerClient->containerInspect($this->id); + $this->inspectResponse = $inspectResponse; + } + + return $this->inspectResponse; + } + + /** + * @return array> + * @throws RuntimeException + */ + protected function ports(): iterable + { + $ports = $this->inspect()?->getNetworkSettings()?->getPorts(); + + if ($ports === null) { + throw new RuntimeException('Failed to get ports from container'); + } + + return $ports; + } + + protected function sanitizeOutput(string $output): string + { + return preg_replace('/[\x00-\x1F\x7F]/u', '', $output) ?? ''; } } diff --git a/src/Container/StartedTestContainer.php b/src/Container/StartedTestContainer.php index 3a75b0b..8d1acd3 100644 --- a/src/Container/StartedTestContainer.php +++ b/src/Container/StartedTestContainer.php @@ -23,6 +23,9 @@ interface StartedTestContainer public function getIpAddress(string $networkName): string; + /** + * @return array + */ public function getLabels(): array; public function logs(): string; @@ -35,6 +38,9 @@ interface StartedTestContainer public function getNetworkId(string $networkName): string; + /** + * @return string[] + */ public function getNetworkNames(): array; public function restart(): self; diff --git a/src/Utils/HostResolver.php b/src/Utils/HostResolver.php new file mode 100644 index 0000000..6c74dc2 --- /dev/null +++ b/src/Utils/HostResolver.php @@ -0,0 +1,138 @@ +dockerClient = $dockerClient ?? DockerContainerClient::getDockerClient(); + } + + /** + * Resolves the host address for connecting to a container. + * + * The resolution process is as follows: + * 1. If user overrides are allowed and TESTCONTAINERS_HOST_OVERRIDE is set, its value is returned. + * 2. Otherwise, the DOCKER_HOST environment variable is parsed. + * - If the scheme is one of http, https, or tcp, the hostname is used. + * - If the scheme is unix or npipe and the process is running in a container, the network gateway + * is determined by inspecting the relevant Docker network or running a temporary container. + * 3. If no other value can be determined, "localhost" is returned. + * + * @return string + * @throws RuntimeException If the DOCKER_HOST scheme is unsupported. + */ + public function resolveHost(): string + { + if ($this->allowUserOverrides() && ($override = getenv('TESTCONTAINERS_HOST_OVERRIDE')) !== false) { + return $override; + } + + // Get DOCKER_HOST URI, defaulting to a TCP endpoint if not set. + $dockerHostUri = getenv('DOCKER_HOST') ?: 'tcp://127.0.0.1:2375'; + $parts = parse_url($dockerHostUri); + if ($parts === false || !isset($parts['scheme'])) { + return 'localhost'; + } + + $scheme = $parts['scheme']; + + switch ($scheme) { + case 'http': + case 'https': + case 'tcp': + return $parts['host'] ?? 'localhost'; + + case 'unix': + case 'npipe': + if ($this->isInContainer()) { + // If using podman, choose "podman" network; otherwise, use "bridge" + $networkName = (str_contains($dockerHostUri, 'podman.sock')) ? 'podman' : 'bridge'; + if ($gateway = $this->findGateway($networkName)) { + return $gateway; + } + if ($defaultGateway = $this->findDefaultGateway()) { + return $defaultGateway; + } + } + return 'localhost'; + + default: + throw new RuntimeException("Unsupported Docker host scheme: {$scheme}"); + } + } + + protected function allowUserOverrides(): bool + { + return true; + } + + /** + * Determines if the code is running inside a container. + */ + protected function isInContainer(): bool + { + return file_exists('/.dockerenv'); + } + + /** + * Inspects the given network and returns its gateway IP address if found. + * + * @param string $networkName + * @return string|null + */ + protected function findGateway(string $networkName): ?string + { + try { + /** @var Network|null $networkInspect */ + $networkInspect = $this->dockerClient?->networkInspect($networkName); + $ipamConfig = $networkInspect?->getIPAM()?->getConfig(); + if ($ipamConfig !== null) { + foreach ($ipamConfig as $config) { + if ($config->getGateway() !== null) { + return $config->getGateway(); + } + } + } + } catch (\Throwable) { + return null; + } + return null; + } + + /** + * Runs a temporary container to determine the default gateway. + */ + protected function findDefaultGateway(): ?string + { + $tmpContainer = null; + try { + // Create a temporary container using a lightweight Alpine image. + $tmpContainer = (new GenericContainer('alpine:3.14')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + $result = $tmpContainer->exec(['sh', '-c', "ip route | awk '/default/ { print $3 }'"]); + $tmpContainer->stop(); + return $result; + } catch (\Throwable) { + return null; + } finally { + if ($tmpContainer !== null) { + try { + $tmpContainer->stop(); + } catch (\Throwable) { + // + } + } + } + } +} diff --git a/tests/Integration/ContainerTestCase.php b/tests/Integration/ContainerTestCase.php index bbe9d84..9a3966f 100644 --- a/tests/Integration/ContainerTestCase.php +++ b/tests/Integration/ContainerTestCase.php @@ -13,7 +13,9 @@ abstract class ContainerTestCase extends TestCase protected function tearDown(): void { - $this->container->stop(); + if (isset($this->container)) { + $this->container->stop(); + } parent::tearDown(); } } diff --git a/tests/Integration/StartedGenericContainerTest.php b/tests/Integration/StartedGenericContainerTest.php new file mode 100644 index 0000000..a59976c --- /dev/null +++ b/tests/Integration/StartedGenericContainerTest.php @@ -0,0 +1,207 @@ +withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + self::assertNotEmpty($container->getId(), 'Container ID should not be empty'); + } + + public function testShouldReturnLastExecId(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $container->exec(['echo', 'Test Exec ID']); + + $lastExecId = $container->getLastExecId(); + + self::assertNotNull($lastExecId, 'Last exec ID should not be null'); + self::assertNotEmpty($lastExecId, 'Last exec ID should not be empty'); + self::assertMatchesRegularExpression('/^[0-9a-f]+$/', $lastExecId, 'Last exec ID should be a valid hexadecimal string'); + } + + public function testShouldExecuteCommandInContainer(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $output = $container->exec(['echo', 'Hello, Testcontainers!']); + self::assertSame('Hello, Testcontainers!', $output); + } + + public function testShouldStopContainer(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + self::assertNotEmpty($container->getId(), 'Container ID should not be empty'); + + $stoppedContainer = $container->stop(); + + self::assertNotNull($stoppedContainer, 'Stopped container should not be null'); + self::assertSame( + $container->getId(), + $stoppedContainer->getId(), + 'Stopped container ID should match the original container ID' + ); + + self::assertStringContainsString( + 'No such container', + $container->logs(), + 'Expected message indicating container does not exist' + ); + } + + public function testShouldRestartContainer(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $containerIdBeforeRestart = $container->getId(); + $container->restart(); + $containerIdAfterRestart = $container->getId(); + + self::assertSame( + $containerIdBeforeRestart, + $containerIdAfterRestart, + 'Container ID should remain the same after restart' + ); + } + + public function testShouldRetrieveLogs(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['sh', '-c', 'echo "Hello from logs!" && tail -f /dev/null']) + ->start(); + + $this->container = $container; + + $logs = $container->logs(); + self::assertStringContainsString('Hello from logs!', $logs); + } + + public function testShouldRetrieveHost(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $host = $container->getHost(); + self::assertSame('127.0.0.1', $host, 'Host should be 127.0.0.1'); + } + + public function testShouldRetrieveFirstMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $mappedPort = $container->getFirstMappedPort(); + self::assertGreaterThan(0, $mappedPort, 'Mapped port should be greater than 0'); + } + + public function testShouldRetrieveMappedPort(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $mappedPort = $container->getMappedPort(80); + self::assertGreaterThan(0, $mappedPort, 'Mapped port for 80 should be greater than 0'); + } + + public function testShouldRetrieveContainerName(): void + { + $name = 'test-container-name'; + $container = (new GenericContainer('alpine')) + ->withName($name) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + self::assertSame($name, $container->getName(), 'Container name should match'); + } + + public function testShouldRetrieveLabels(): void + { + $labels = [ + 'label-1' => 'value-1', + 'label-2' => 'value-2', + ]; + + $container = (new GenericContainer('alpine')) + ->withLabels($labels) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $this->container = $container; + + $retrievedLabels = $container->getLabels(); + + self::assertArrayHasKey('label-1', $retrievedLabels); + self::assertSame('value-1', $retrievedLabels['label-1']); + self::assertArrayHasKey('label-2', $retrievedLabels); + self::assertSame('value-2', $retrievedLabels['label-2']); + } + + public function testShouldRetrieveNetworkNames(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $networks = $container->getNetworkNames(); + + self::assertNotEmpty($networks, 'Networks should not be empty'); + } + + public function testShouldRetrieveIpAddressFromNetwork(): void + { + $container = (new GenericContainer('nginx')) + ->withExposedPorts(80) + ->start(); + + $this->container = $container; + + $networks = $container->getNetworkNames(); + $networkName = $networks[0] ?? null; + + self::assertNotNull($networkName, 'Network name should not be null'); + + $ipAddress = $container->getIpAddress($networkName); + + self::assertNotEmpty($ipAddress, 'IP address should not be empty'); + } +} diff --git a/tests/Unit/Utils/HostResolverTest.php b/tests/Unit/Utils/HostResolverTest.php new file mode 100644 index 0000000..f428b94 --- /dev/null +++ b/tests/Unit/Utils/HostResolverTest.php @@ -0,0 +1,253 @@ +createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + $host = $resolver->resolveHost(); + $this->assertEquals('tcp://another:2375', $host); + } + + public function testReturnsHostnameForTcpProtocols(): void + { + $protocols = ['tcp', 'http', 'https']; + foreach ($protocols as $protocol) { + putenv('DOCKER_HOST=' . $protocol . '://docker:2375'); + // Clear any override. + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $dummyClient = $this->createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + $host = $resolver->resolveHost(); + $this->assertEquals('docker', $host, "Protocol {$protocol} did not return expected hostname."); + } + } + + public function testDoesNotReturnOverrideWhenAllowUserOverridesIsFalse(): void + { + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function allowUserOverrides(): bool + { + return false; + } + }; + + putenv('TESTCONTAINERS_HOST_OVERRIDE=tcp://another:2375'); + putenv('DOCKER_HOST=tcp://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('docker', $host); + } + + public function testReturnsLocalhostForUnixAndNpipeProtocolsWhenNotInContainer(): void + { + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return false; + } + }; + + foreach (['unix://docker:2375', 'npipe://docker:2375'] as $uri) { + putenv('DOCKER_HOST=' . $uri); + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host, "URI {$uri} should return 'localhost' when not in a container."); + } + } + + 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) + ->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; + } + }; + + // Expect that networkInspect will be called with "bridge" (since DOCKER_HOST does not contain "podman.sock") + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('bridge')) + ->willReturn($fakeNetwork); + + // Override isInContainer() to simulate being inside a container. + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + putenv('TESTCONTAINERS_HOST_OVERRIDE'); + $host = $resolver->resolveHost(); + $this->assertEquals('172.0.0.1', $host); + } + + public function testUsesBridgeNetworkAsGatewayForDockerProvider(): void + { + // For Docker provider (non-Podman) the network used should be "bridge". + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + // Expect networkInspect to be called with "bridge" + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('bridge')) + ->willReturn(null); // Simulate not finding a gateway + + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + // Since no gateway is found, fallback is "localhost" + $this->assertEquals('localhost', $host); + } + + public function testUsesPodmanNetworkAsGatewayForPodmanProvider(): void + { + // For Podman, DOCKER_HOST contains "podman.sock" so the network should be "podman". + $dockerClient = $this->getMockBuilder(Docker::class) + ->disableOriginalConstructor() + ->getMock(); + // Expect networkInspect to be called with "podman" + $dockerClient->expects($this->once()) + ->method('networkInspect') + ->with($this->equalTo('podman')) + ->willReturn(null); // Simulate not finding a gateway + + $resolver = new class ($dockerClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + }; + + putenv('DOCKER_HOST=unix://podman.sock'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host); + } + + 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); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + protected function findGateway(string $networkName): ?string + { + return null; + } + protected function findDefaultGateway(): ?string + { + return '172.0.0.2'; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('172.0.0.2', $host); + } + + public function testReturnsLocalhostIfUnableToFindGateway(): void + { + // Override to simulate that neither network inspection nor default gateway yield a result. + $dummyClient = $this->createMock(Docker::class); + $resolver = new class ($dummyClient) extends HostResolver { + protected function isInContainer(): bool + { + return true; + } + protected function findGateway(string $networkName): ?string + { + return null; + } + protected function findDefaultGateway(): ?string + { + return null; + } + }; + + putenv('DOCKER_HOST=unix://docker:2375'); + $host = $resolver->resolveHost(); + $this->assertEquals('localhost', $host); + } + + public function testThrowsForUnsupportedProtocol(): void + { + putenv('DOCKER_HOST=invalid://unknown'); + $dummyClient = $this->createMock(Docker::class); + $resolver = new HostResolver($dummyClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Unsupported Docker host scheme: invalid"); + + $resolver->resolveHost(); + } +} diff --git a/tests/Unit/Utils/TarBuilderTest.php b/tests/Unit/Utils/TarBuilderTest.php index b2f326f..965b9de 100644 --- a/tests/Unit/Utils/TarBuilderTest.php +++ b/tests/Unit/Utils/TarBuilderTest.php @@ -146,7 +146,7 @@ class TarBuilderTest extends TestCase mkdir($extractDir); $this->extractTar($tarPath, $extractDir); - $scanned = array_diff(scandir($extractDir), ['.', '..']); + $scanned = array_diff(scandir($extractDir) ?: [], ['.', '..']); $this->assertCount(0, $scanned, 'Expected empty directory'); } @@ -166,7 +166,7 @@ class TarBuilderTest extends TestCase mkdir($extractDir); $this->extractTar($tarPath, $extractDir); - $scanned = array_diff(scandir($extractDir), ['.', '..']); + $scanned = array_diff(scandir($extractDir) ?: [], ['.', '..']); $this->assertCount(0, $scanned, 'Expected no files after clear()'); }