Skip to content

Commit b77cd2b

Browse files
authored
Merge pull request #44 from testcontainers/feat/docker-auth-config
feat: add Docker registry authentication support
2 parents 2953d56 + 9749a20 commit b77cd2b

File tree

6 files changed

+498
-11
lines changed

6 files changed

+498
-11
lines changed

.php-cs-fixer.dist.php

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
11
<?php
2-
/*
3-
* This document has been generated with
4-
* https://mlocati.github.io/php-cs-fixer-configurator/#version:3.12.0|configurator
5-
* you can change this configuration by importing this file.
6-
*/
72
$config = new \PhpCsFixer\Config();
83
return $config
94
->setRules([

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"ext-pdo_pgsql": "*",
2626
"phpunit/phpunit": "^9.5",
2727
"brianium/paratest": "^6.11",
28-
"friendsofphp/php-cs-fixer": "^3.12",
28+
"friendsofphp/php-cs-fixer": "^3.92",
2929
"phpstan/phpstan": "^1.8",
3030
"phpstan/phpstan-phpunit": "^1.1",
3131
"phpstan/extension-installer": "^1.2",

src/Container/GenericContainer.php

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Testcontainers\Utils\TarBuilder;
2525
use Testcontainers\Wait\WaitForContainer;
2626
use Testcontainers\Wait\WaitStrategy;
27+
use Testcontainers\Utils\DockerAuthConfig;
2728

2829
class GenericContainer implements TestContainer
2930
{
@@ -473,11 +474,32 @@ protected function createPortBindings(): array
473474
protected function pullImage(): void
474475
{
475476
[$fromImage, $tag] = explode(':', $this->image) + [1 => 'latest'];
477+
478+
// Build headers for the request
479+
$headers = [];
480+
481+
// Try to get authentication for the registry
482+
$registry = DockerAuthConfig::getRegistryFromImage($fromImage);
483+
$credentials = DockerAuthConfig::getInstance()->getAuthForRegistry($registry);
484+
485+
if ($credentials !== null) {
486+
// Docker expects the X-Registry-Auth header to be a base64-encoded JSON
487+
$authData = [
488+
'username' => $credentials['username'],
489+
'password' => $credentials['password'],
490+
];
491+
$headers['X-Registry-Auth'] = base64_encode(json_encode($authData, JSON_THROW_ON_ERROR));
492+
}
493+
476494
/** @var CreateImageStream $imageCreateResponse */
477-
$imageCreateResponse = $this->dockerClient->imageCreate(null, [
478-
'fromImage' => $fromImage,
479-
'tag' => $tag,
480-
]);
495+
$imageCreateResponse = $this->dockerClient->imageCreate(
496+
null,
497+
[
498+
'fromImage' => $fromImage,
499+
'tag' => $tag,
500+
],
501+
$headers
502+
);
481503
$imageCreateResponse->wait();
482504
}
483505
}

src/Container/StartedGenericContainer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public function logs(): string
9999
->getContents() ?? '';
100100

101101
$converted = mb_convert_encoding($output, 'UTF-8', 'UTF-8');
102-
return $this->sanitizeOutput($converted === false ? $output : $converted);
102+
return $this->sanitizeOutput($converted == false ? $output : $converted);
103103
}
104104

105105
public function getHost(): string

src/Utils/DockerAuthConfig.php

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testcontainers\Utils;
6+
7+
use RuntimeException;
8+
use JsonException;
9+
10+
class DockerAuthConfig
11+
{
12+
private const DEFAULT_CONFIG_PATHS = [
13+
'~/.docker/config.json',
14+
'/etc/docker/config.json',
15+
];
16+
17+
private static ?self $instance = null;
18+
19+
/**
20+
* @var array<string, array{auth?: string, username?: string, password?: string, email?: string}>
21+
*/
22+
private array $auths = [];
23+
24+
private ?string $credsStore = null;
25+
26+
/**
27+
* @var array<string, string>
28+
*/
29+
private array $credHelpers = [];
30+
31+
public function __construct()
32+
{
33+
$this->loadConfig();
34+
}
35+
36+
/**
37+
* Get the singleton instance of DockerAuthConfig.
38+
* This avoids re-reading config files/environment on every image pull.
39+
*/
40+
public static function getInstance(): self
41+
{
42+
if (self::$instance === null) {
43+
self::$instance = new self();
44+
}
45+
46+
return self::$instance;
47+
}
48+
49+
/**
50+
* Reset the singleton instance (useful for testing).
51+
*/
52+
public static function resetInstance(): void
53+
{
54+
self::$instance = null;
55+
}
56+
57+
/**
58+
* Get authentication for a specific registry
59+
*
60+
* @return array{username: string, password: string}|null
61+
*/
62+
public function getAuthForRegistry(string $registry): ?array
63+
{
64+
$registry = $this->normalizeRegistry($registry);
65+
66+
if (isset($this->auths[$registry])) {
67+
$auth = $this->auths[$registry];
68+
69+
if (isset($auth['auth'])) {
70+
$decoded = base64_decode($auth['auth'], true);
71+
if ($decoded === false) {
72+
throw new RuntimeException('Invalid base64 auth string');
73+
}
74+
75+
if (!str_contains($decoded, ':')) {
76+
throw new RuntimeException('Invalid auth format');
77+
}
78+
[$username, $password] = explode(':', $decoded, 2);
79+
return ['username' => $username, 'password' => $password];
80+
}
81+
82+
if (isset($auth['username']) && isset($auth['password'])) {
83+
return ['username' => $auth['username'], 'password' => $auth['password']];
84+
}
85+
}
86+
87+
if (isset($this->credHelpers[$registry])) {
88+
return $this->getCredentialsFromHelper($this->credHelpers[$registry], $registry);
89+
}
90+
91+
if ($this->credsStore !== null) {
92+
return $this->getCredentialsFromHelper($this->credsStore, $registry);
93+
}
94+
95+
return null;
96+
}
97+
98+
/**
99+
* Load Docker configuration from environment or default paths
100+
*/
101+
private function loadConfig(): void
102+
{
103+
$configData = null;
104+
105+
$envConfig = getenv('DOCKER_AUTH_CONFIG');
106+
if ($envConfig !== false && $envConfig !== '') {
107+
try {
108+
$configData = json_decode($envConfig, true, 512, JSON_THROW_ON_ERROR);
109+
} catch (JsonException $e) {
110+
throw new RuntimeException('Invalid JSON in DOCKER_AUTH_CONFIG: ' . $e->getMessage(), 0, $e);
111+
}
112+
} else {
113+
foreach (self::DEFAULT_CONFIG_PATHS as $path) {
114+
$expandedPath = str_replace('~', getenv('HOME') ?: '', $path);
115+
if (file_exists($expandedPath)) {
116+
$content = file_get_contents($expandedPath);
117+
if ($content === false) {
118+
continue;
119+
}
120+
121+
try {
122+
$configData = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
123+
} catch (JsonException $e) {
124+
throw new RuntimeException("Invalid JSON in $expandedPath: " . $e->getMessage(), 0, $e);
125+
}
126+
break;
127+
}
128+
}
129+
}
130+
131+
if (!is_array($configData)) {
132+
return;
133+
}
134+
135+
if (isset($configData['auths']) && is_array($configData['auths'])) {
136+
/** @var array<string, array{auth?: string, username?: string, password?: string, email?: string}> $auths */
137+
$auths = $configData['auths'];
138+
$this->auths = $auths;
139+
}
140+
141+
if (isset($configData['credsStore']) && is_string($configData['credsStore'])) {
142+
$this->credsStore = $configData['credsStore'];
143+
}
144+
145+
if (isset($configData['credHelpers']) && is_array($configData['credHelpers'])) {
146+
/** @var array<string, string> $credHelpers */
147+
$credHelpers = $configData['credHelpers'];
148+
$this->credHelpers = $credHelpers;
149+
}
150+
}
151+
152+
/**
153+
* Get credentials from a credential helper
154+
*
155+
* @return array{username: string, password: string}|null
156+
*/
157+
private function getCredentialsFromHelper(string $helper, string $registry): ?array
158+
{
159+
$helperCommand = 'docker-credential-' . $helper;
160+
161+
$checkCommand = sprintf('command -v %s 2>/dev/null', escapeshellarg($helperCommand));
162+
$helperPath = trim(shell_exec($checkCommand) ?: '');
163+
164+
if (empty($helperPath)) {
165+
return null;
166+
}
167+
168+
$descriptors = [
169+
0 => ['pipe', 'r'],
170+
1 => ['pipe', 'w'],
171+
2 => ['pipe', 'w'],
172+
];
173+
174+
$process = proc_open([$helperCommand, 'get'], $descriptors, $pipes);
175+
176+
if (!is_resource($process)) {
177+
throw new RuntimeException("Failed to execute credential helper: $helperCommand");
178+
}
179+
180+
fwrite($pipes[0], $registry);
181+
fclose($pipes[0]);
182+
183+
$stdout = stream_get_contents($pipes[1]);
184+
$stderr = stream_get_contents($pipes[2]);
185+
fclose($pipes[1]);
186+
fclose($pipes[2]);
187+
188+
$exitCode = proc_close($process);
189+
190+
if ($exitCode !== 0) {
191+
if ($stderr !== false && strpos($stderr, 'credentials not found') !== false) {
192+
return null;
193+
}
194+
throw new RuntimeException("Credential helper failed: $stderr");
195+
}
196+
197+
if ($stdout === false) {
198+
return null;
199+
}
200+
201+
try {
202+
$credentials = json_decode($stdout, true, 512, JSON_THROW_ON_ERROR);
203+
} catch (JsonException $e) {
204+
throw new RuntimeException('Invalid JSON from credential helper: ' . $e->getMessage(), 0, $e);
205+
}
206+
207+
if (!is_array($credentials)) {
208+
throw new RuntimeException('Credential helper returned invalid response');
209+
}
210+
211+
if (!isset($credentials['Username']) || !isset($credentials['Secret']) ||
212+
!is_string($credentials['Username']) || !is_string($credentials['Secret'])) {
213+
return null;
214+
}
215+
216+
return [
217+
'username' => $credentials['Username'],
218+
'password' => $credentials['Secret'],
219+
];
220+
}
221+
222+
/**
223+
* Normalize registry URL to match Docker config format
224+
*/
225+
private function normalizeRegistry(string $registry): string
226+
{
227+
$normalized = preg_replace('#^https?://#', '', $registry);
228+
229+
if ($normalized === null) {
230+
$normalized = $registry;
231+
}
232+
233+
$normalized = rtrim($normalized, '/');
234+
235+
if ($normalized === 'docker.io' || $normalized === 'index.docker.io' || $normalized === 'registry-1.docker.io') {
236+
return 'https://index.docker.io/v1/';
237+
}
238+
239+
return $normalized;
240+
}
241+
242+
public static function getRegistryFromImage(string $image): string
243+
{
244+
$slashPos = strpos($image, '/');
245+
246+
if ($slashPos === false) {
247+
return 'docker.io';
248+
}
249+
250+
$potentialRegistry = substr($image, 0, $slashPos);
251+
252+
if (str_contains($potentialRegistry, '.') ||
253+
str_contains($potentialRegistry, ':') ||
254+
$potentialRegistry === 'localhost') {
255+
return $potentialRegistry;
256+
}
257+
258+
return 'docker.io';
259+
}
260+
}

0 commit comments

Comments
 (0)