diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index ee6b594..49f210a 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,9 +1,4 @@ setRules([ diff --git a/composer.json b/composer.json index 01d13a1..f9b0bc5 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "ext-pdo_pgsql": "*", "phpunit/phpunit": "^9.5", "brianium/paratest": "^6.11", - "friendsofphp/php-cs-fixer": "^3.12", + "friendsofphp/php-cs-fixer": "^3.92", "phpstan/phpstan": "^1.8", "phpstan/phpstan-phpunit": "^1.1", "phpstan/extension-installer": "^1.2", diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 8d6a0dc..da3be02 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -24,6 +24,7 @@ use Testcontainers\Utils\TarBuilder; use Testcontainers\Wait\WaitForContainer; use Testcontainers\Wait\WaitStrategy; +use Testcontainers\Utils\DockerAuthConfig; class GenericContainer implements TestContainer { @@ -473,11 +474,32 @@ protected function createPortBindings(): array protected function pullImage(): void { [$fromImage, $tag] = explode(':', $this->image) + [1 => 'latest']; + + // Build headers for the request + $headers = []; + + // Try to get authentication for the registry + $registry = DockerAuthConfig::getRegistryFromImage($fromImage); + $credentials = DockerAuthConfig::getInstance()->getAuthForRegistry($registry); + + if ($credentials !== null) { + // Docker expects the X-Registry-Auth header to be a base64-encoded JSON + $authData = [ + 'username' => $credentials['username'], + 'password' => $credentials['password'], + ]; + $headers['X-Registry-Auth'] = base64_encode(json_encode($authData, JSON_THROW_ON_ERROR)); + } + /** @var CreateImageStream $imageCreateResponse */ - $imageCreateResponse = $this->dockerClient->imageCreate(null, [ - 'fromImage' => $fromImage, - 'tag' => $tag, - ]); + $imageCreateResponse = $this->dockerClient->imageCreate( + null, + [ + 'fromImage' => $fromImage, + 'tag' => $tag, + ], + $headers + ); $imageCreateResponse->wait(); } } diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index 13493dd..5ae3f82 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -99,7 +99,7 @@ public function logs(): string ->getContents() ?? ''; $converted = mb_convert_encoding($output, 'UTF-8', 'UTF-8'); - return $this->sanitizeOutput($converted === false ? $output : $converted); + return $this->sanitizeOutput($converted == false ? $output : $converted); } public function getHost(): string diff --git a/src/Utils/DockerAuthConfig.php b/src/Utils/DockerAuthConfig.php new file mode 100644 index 0000000..1f4a342 --- /dev/null +++ b/src/Utils/DockerAuthConfig.php @@ -0,0 +1,260 @@ + + */ + private array $auths = []; + + private ?string $credsStore = null; + + /** + * @var array + */ + private array $credHelpers = []; + + public function __construct() + { + $this->loadConfig(); + } + + /** + * Get the singleton instance of DockerAuthConfig. + * This avoids re-reading config files/environment on every image pull. + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Reset the singleton instance (useful for testing). + */ + public static function resetInstance(): void + { + self::$instance = null; + } + + /** + * Get authentication for a specific registry + * + * @return array{username: string, password: string}|null + */ + public function getAuthForRegistry(string $registry): ?array + { + $registry = $this->normalizeRegistry($registry); + + if (isset($this->auths[$registry])) { + $auth = $this->auths[$registry]; + + if (isset($auth['auth'])) { + $decoded = base64_decode($auth['auth'], true); + if ($decoded === false) { + throw new RuntimeException('Invalid base64 auth string'); + } + + if (!str_contains($decoded, ':')) { + throw new RuntimeException('Invalid auth format'); + } + [$username, $password] = explode(':', $decoded, 2); + return ['username' => $username, 'password' => $password]; + } + + if (isset($auth['username']) && isset($auth['password'])) { + return ['username' => $auth['username'], 'password' => $auth['password']]; + } + } + + if (isset($this->credHelpers[$registry])) { + return $this->getCredentialsFromHelper($this->credHelpers[$registry], $registry); + } + + if ($this->credsStore !== null) { + return $this->getCredentialsFromHelper($this->credsStore, $registry); + } + + return null; + } + + /** + * Load Docker configuration from environment or default paths + */ + private function loadConfig(): void + { + $configData = null; + + $envConfig = getenv('DOCKER_AUTH_CONFIG'); + if ($envConfig !== false && $envConfig !== '') { + try { + $configData = json_decode($envConfig, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RuntimeException('Invalid JSON in DOCKER_AUTH_CONFIG: ' . $e->getMessage(), 0, $e); + } + } else { + foreach (self::DEFAULT_CONFIG_PATHS as $path) { + $expandedPath = str_replace('~', getenv('HOME') ?: '', $path); + if (file_exists($expandedPath)) { + $content = file_get_contents($expandedPath); + if ($content === false) { + continue; + } + + try { + $configData = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RuntimeException("Invalid JSON in $expandedPath: " . $e->getMessage(), 0, $e); + } + break; + } + } + } + + if (!is_array($configData)) { + return; + } + + if (isset($configData['auths']) && is_array($configData['auths'])) { + /** @var array $auths */ + $auths = $configData['auths']; + $this->auths = $auths; + } + + if (isset($configData['credsStore']) && is_string($configData['credsStore'])) { + $this->credsStore = $configData['credsStore']; + } + + if (isset($configData['credHelpers']) && is_array($configData['credHelpers'])) { + /** @var array $credHelpers */ + $credHelpers = $configData['credHelpers']; + $this->credHelpers = $credHelpers; + } + } + + /** + * Get credentials from a credential helper + * + * @return array{username: string, password: string}|null + */ + private function getCredentialsFromHelper(string $helper, string $registry): ?array + { + $helperCommand = 'docker-credential-' . $helper; + + $checkCommand = sprintf('command -v %s 2>/dev/null', escapeshellarg($helperCommand)); + $helperPath = trim(shell_exec($checkCommand) ?: ''); + + if (empty($helperPath)) { + return null; + } + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open([$helperCommand, 'get'], $descriptors, $pipes); + + if (!is_resource($process)) { + throw new RuntimeException("Failed to execute credential helper: $helperCommand"); + } + + fwrite($pipes[0], $registry); + fclose($pipes[0]); + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($exitCode !== 0) { + if ($stderr !== false && strpos($stderr, 'credentials not found') !== false) { + return null; + } + throw new RuntimeException("Credential helper failed: $stderr"); + } + + if ($stdout === false) { + return null; + } + + try { + $credentials = json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RuntimeException('Invalid JSON from credential helper: ' . $e->getMessage(), 0, $e); + } + + if (!is_array($credentials)) { + throw new RuntimeException('Credential helper returned invalid response'); + } + + if (!isset($credentials['Username']) || !isset($credentials['Secret']) || + !is_string($credentials['Username']) || !is_string($credentials['Secret'])) { + return null; + } + + return [ + 'username' => $credentials['Username'], + 'password' => $credentials['Secret'], + ]; + } + + /** + * Normalize registry URL to match Docker config format + */ + private function normalizeRegistry(string $registry): string + { + $normalized = preg_replace('#^https?://#', '', $registry); + + if ($normalized === null) { + $normalized = $registry; + } + + $normalized = rtrim($normalized, '/'); + + if ($normalized === 'docker.io' || $normalized === 'index.docker.io' || $normalized === 'registry-1.docker.io') { + return 'https://index.docker.io/v1/'; + } + + return $normalized; + } + + public static function getRegistryFromImage(string $image): string + { + $slashPos = strpos($image, '/'); + + if ($slashPos === false) { + return 'docker.io'; + } + + $potentialRegistry = substr($image, 0, $slashPos); + + if (str_contains($potentialRegistry, '.') || + str_contains($potentialRegistry, ':') || + $potentialRegistry === 'localhost') { + return $potentialRegistry; + } + + return 'docker.io'; + } +} diff --git a/tests/Unit/Utils/DockerAuthConfigTest.php b/tests/Unit/Utils/DockerAuthConfigTest.php new file mode 100644 index 0000000..5bc8aa2 --- /dev/null +++ b/tests/Unit/Utils/DockerAuthConfigTest.php @@ -0,0 +1,210 @@ +originalHome = getenv('HOME') ?: ''; + $this->originalAuthConfig = getenv('DOCKER_AUTH_CONFIG') ?: null; + + // Clear the environment variable + putenv('DOCKER_AUTH_CONFIG'); + } + + protected function tearDown(): void + { + // Reset singleton instance to avoid test interference + DockerAuthConfig::resetInstance(); + + // Restore original values + putenv('HOME=' . $this->originalHome); + if ($this->originalAuthConfig !== null) { + putenv('DOCKER_AUTH_CONFIG=' . $this->originalAuthConfig); + } else { + putenv('DOCKER_AUTH_CONFIG'); + } + parent::tearDown(); + } + + public function testLoadConfigFromEnvironmentVariable(): void + { + $config = [ + 'auths' => [ + 'https://index.docker.io/v1/' => [ + 'auth' => base64_encode('user:pass'), + ], + ], + ]; + + putenv('DOCKER_AUTH_CONFIG=' . json_encode($config)); + + $authConfig = new DockerAuthConfig(); + $creds = $authConfig->getAuthForRegistry('docker.io'); + + $this->assertNotNull($creds); + $this->assertEquals('user', $creds['username']); + $this->assertEquals('pass', $creds['password']); + } + + public function testLoadConfigWithUsernamePassword(): void + { + $config = [ + 'auths' => [ + 'myregistry.com' => [ + 'username' => 'myuser', + 'password' => 'mypass', + ], + ], + ]; + + putenv('DOCKER_AUTH_CONFIG=' . json_encode($config)); + + $authConfig = new DockerAuthConfig(); + $creds = $authConfig->getAuthForRegistry('myregistry.com'); + + $this->assertNotNull($creds); + $this->assertEquals('myuser', $creds['username']); + $this->assertEquals('mypass', $creds['password']); + } + + public function testGetAuthForUnknownRegistry(): void + { + $config = [ + 'auths' => [ + 'docker.io' => [ + 'auth' => base64_encode('user:pass'), + ], + ], + ]; + + putenv('DOCKER_AUTH_CONFIG=' . json_encode($config)); + + $authConfig = new DockerAuthConfig(); + $creds = $authConfig->getAuthForRegistry('unknown.registry.com'); + + $this->assertNull($creds); + } + + public function testInvalidJsonInEnvironmentVariable(): void + { + putenv('DOCKER_AUTH_CONFIG=invalid json'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON in DOCKER_AUTH_CONFIG'); + + new DockerAuthConfig(); + } + + public function testInvalidBase64Auth(): void + { + // Use a string that decodes to valid base64 but doesn't contain a colon + $config = [ + 'auths' => [ + 'https://index.docker.io/v1/' => [ + 'auth' => base64_encode('noColonInThis'), + ], + ], + ]; + + putenv('DOCKER_AUTH_CONFIG=' . json_encode($config)); + + $authConfig = new DockerAuthConfig(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid auth format'); + + $authConfig->getAuthForRegistry('docker.io'); + } + + public function testGetRegistryFromImage(): void + { + // Test various image formats + $this->assertEquals('docker.io', DockerAuthConfig::getRegistryFromImage('ubuntu')); + $this->assertEquals('docker.io', DockerAuthConfig::getRegistryFromImage('library/ubuntu')); + $this->assertEquals('docker.io', DockerAuthConfig::getRegistryFromImage('ubuntu:20.04')); + $this->assertEquals('ghcr.io', DockerAuthConfig::getRegistryFromImage('ghcr.io/owner/repo')); + $this->assertEquals('ghcr.io', DockerAuthConfig::getRegistryFromImage('ghcr.io/owner/repo:tag')); + $this->assertEquals('myregistry.com', DockerAuthConfig::getRegistryFromImage('myregistry.com/image')); + $this->assertEquals('localhost:5000', DockerAuthConfig::getRegistryFromImage('localhost:5000/image')); + } + + public function testNormalizeRegistry(): void + { + $config = [ + 'auths' => [ + 'https://index.docker.io/v1/' => [ + 'auth' => base64_encode('user:pass'), + ], + ], + ]; + + putenv('DOCKER_AUTH_CONFIG=' . json_encode($config)); + + $authConfig = new DockerAuthConfig(); + + // All these should resolve to the same Docker Hub entry + $registries = ['docker.io', 'index.docker.io', 'registry-1.docker.io']; + + foreach ($registries as $registry) { + $creds = $authConfig->getAuthForRegistry($registry); + $this->assertNotNull($creds, "Failed to get auth for $registry"); + $this->assertEquals('user', $creds['username']); + $this->assertEquals('pass', $creds['password']); + } + } + + public function testEmptyConfig(): void + { + putenv('DOCKER_AUTH_CONFIG={}'); + + $authConfig = new DockerAuthConfig(); + $creds = $authConfig->getAuthForRegistry('docker.io'); + + $this->assertNull($creds); + } + + public function testLoadConfigFromFile(): void + { + // Create a temporary directory for testing + $tempDir = sys_get_temp_dir() . '/testcontainers_test_' . uniqid(); + mkdir($tempDir); + mkdir($tempDir . '/.docker'); + + $config = [ + 'auths' => [ + 'fileregistry.com' => [ + 'auth' => base64_encode('fileuser:filepass'), + ], + ], + ]; + + file_put_contents($tempDir . '/.docker/config.json', json_encode($config)); + + // Set HOME to our temp directory + putenv('HOME=' . $tempDir); + + $authConfig = new DockerAuthConfig(); + $creds = $authConfig->getAuthForRegistry('fileregistry.com'); + + $this->assertNotNull($creds); + $this->assertEquals('fileuser', $creds['username']); + $this->assertEquals('filepass', $creds['password']); + + // Cleanup + unlink($tempDir . '/.docker/config.json'); + rmdir($tempDir . '/.docker'); + rmdir($tempDir); + } +}