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) { + // + } + } + } + } +}