GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — fix(transport): support explicit endpoints and live output · docker-php/docker-php@3907105

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
  • src
    • DockerClientFactory.php
    • Http
      • StreamingDecoderPlugin.php
    • Stream
      • DechunkStream.php
      • SocketReadStream.php
  • tests
    • DockerClientFactoryTest.php
    • DockerClientFactoryTransportTest.php
    • Http
      • StreamingDecoderPluginTest.php
    • StreamingTransportTest.php
    • Stream
      • DechunkStreamTest.php
      • SocketReadStreamTest.php
    • TransportTestCase.php
    • fixtures
      • socket-server.php
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎src/DockerClientFactory.php‎

Copy file name to clipboardExpand all lines: src/DockerClientFactory.php
+23-7Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
namespace Docker;
66

7+
use Docker\Http\StreamingDecoderPlugin;
78
use Http\Client\Common\Plugin\AddHostPlugin;
89
use Http\Client\Common\Plugin\AddPathPlugin;
910
use Http\Client\Common\Plugin\ContentLengthPlugin;
10-
use Http\Client\Common\Plugin\DecoderPlugin;
1111
use Http\Client\Common\Plugin\HeaderDefaultsPlugin;
1212
use Http\Client\Common\PluginClient;
1313
use Http\Client\Common\PluginClientFactory;
@@ -22,10 +22,26 @@ public static function create(array $config = [], ?PluginClientFactory $pluginCl
2222
$config['remote_socket'] = 'unix:///var/run/docker.sock';
2323
}
2424

25-
$socketClient = new Client($config);
26-
2725
$uriFactory = Psr17FactoryDiscovery::findUriFactory();
28-
$host = preg_match('/unix:\/\//', $config['remote_socket']) ? 'http://localhost' : $config['remote_socket'];
26+
$unixSocket = str_starts_with($config['remote_socket'], 'unix://');
27+
$host = $uriFactory->createUri($unixSocket ? 'http://localhost' : $config['remote_socket']);
28+
$scheme = $unixSocket ? 'unix' : $host->getScheme();
29+
30+
if (\in_array($scheme, ['http', 'https', 'tcp'], true)) {
31+
if ('https' === $scheme) {
32+
$config['ssl'] = true;
33+
}
34+
35+
if ('http' === $scheme || 'https' === $scheme) {
36+
// The socket client negotiates TLS after opening the TCP connection.
37+
$port = $host->getPort() ?? ('https' === $scheme ? 443 : 80);
38+
$config['remote_socket'] = 'tcp://'.$host->getHost().':'.$port;
39+
}
40+
41+
$host = $host->withScheme(($config['ssl'] ?? false) ? 'https' : 'http');
42+
}
43+
44+
$socketClient = new Client($config);
2945
$dockerApiVersion = ltrim(getenv('DOCKER_API_VERSION') ?: 'v1.45', '/');
3046
if (!str_starts_with($dockerApiVersion, 'v')) {
3147
$dockerApiVersion = 'v'.$dockerApiVersion;
@@ -37,11 +53,11 @@ public static function create(array $config = [], ?PluginClientFactory $pluginCl
3753
$socketClient,
3854
[
3955
new ContentLengthPlugin(),
40-
new DecoderPlugin(),
56+
new StreamingDecoderPlugin(),
4157
new AddPathPlugin($uriFactory->createUri('/'.$dockerApiVersion)),
42-
new AddHostPlugin($uriFactory->createUri($host)),
58+
new AddHostPlugin($host),
4359
new HeaderDefaultsPlugin([
44-
'host' => parse_url($host, \PHP_URL_HOST),
60+
'host' => $host->withUserInfo('')->getAuthority(),
4561
]),
4662
],
4763
[
Collapse file

‎src/Http/StreamingDecoderPlugin.php‎

Copy file name to clipboard
+40Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Docker\Http;
6+
7+
use Docker\Stream\DechunkStream;
8+
use Docker\Stream\SocketReadStream;
9+
use Http\Client\Common\Plugin;
10+
use Http\Client\Common\Plugin\DecoderPlugin;
11+
use Http\Client\Socket\Stream as SocketStream;
12+
use Http\Promise\Promise;
13+
use Psr\Http\Message\RequestInterface;
14+
use Psr\Http\Message\ResponseInterface;
15+
16+
final class StreamingDecoderPlugin implements Plugin
17+
{
18+
private DecoderPlugin $decoder;
19+
20+
public function __construct(array $config = [])
21+
{
22+
$this->decoder = new DecoderPlugin($config);
23+
}
24+
25+
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
26+
{
27+
return $this->decoder->handleRequest($request, static function (RequestInterface $request) use ($next): Promise {
28+
return $next($request)->then(static function (ResponseInterface $response): ResponseInterface {
29+
if ($response->getBody() instanceof SocketStream) {
30+
$response = $response->withBody(new SocketReadStream($response->getBody()));
31+
}
32+
if ('chunked' === strtolower(trim($response->getHeaderLine('Transfer-Encoding')))) {
33+
return $response->withBody(new DechunkStream($response->getBody()))->withoutHeader('Transfer-Encoding');
34+
}
35+
36+
return $response;
37+
});
38+
}, $first);
39+
}
40+
}
Collapse file

‎src/Stream/DechunkStream.php‎

Copy file name to clipboard
+30Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Docker\Stream;
6+
7+
/**
8+
* Return decoded data as it arrives instead of filling the requested read size.
9+
*/
10+
class DechunkStream extends \Http\Message\Encoding\DechunkStream
11+
{
12+
public function read(int $length): string
13+
{
14+
if ($length < 0) {
15+
throw new \InvalidArgumentException('Read length must not be negative.');
16+
}
17+
if (0 === $length) {
18+
return '';
19+
}
20+
21+
while ('' === $this->buffer && !$this->stream->eof()) {
22+
$this->fill();
23+
}
24+
25+
$output = substr($this->buffer, 0, $length);
26+
$this->buffer = substr($this->buffer, \strlen($output));
27+
28+
return $output;
29+
}
30+
}
Collapse file

‎src/Stream/SocketReadStream.php‎

Copy file name to clipboard
+31Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Docker\Stream;
6+
7+
use Http\Message\Decorator\StreamDecorator;
8+
use Psr\Http\Message\StreamInterface;
9+
10+
/**
11+
* Drain PHP's socket read buffer before requesting more bytes from the peer.
12+
*/
13+
final class SocketReadStream implements StreamInterface
14+
{
15+
use StreamDecorator;
16+
17+
public function __construct(StreamInterface $stream)
18+
{
19+
$this->stream = $stream;
20+
}
21+
22+
public function read(int $length): string
23+
{
24+
$buffered = $this->stream->getMetadata('unread_bytes');
25+
if (\is_int($buffered) && $buffered > 0 && $length > 0) {
26+
$length = min($length, $buffered);
27+
}
28+
29+
return $this->stream->read($length);
30+
}
31+
}
Collapse file

‎tests/DockerClientFactoryTest.php‎

Copy file name to clipboardExpand all lines: tests/DockerClientFactoryTest.php
+56-2Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,23 @@
88
use Http\Client\Common\Plugin\AddPathPlugin;
99
use Http\Client\Common\PluginClient;
1010
use Http\Client\Common\PluginClientFactory;
11+
use Http\Client\Socket\Client;
12+
use PHPUnit\Framework\Attributes\DataProvider;
1113
use Psr\Http\Client\ClientInterface;
1214

1315
class DockerClientFactoryTest extends TestCase
1416
{
17+
private array $environment = [];
18+
19+
protected function setUp(): void
20+
{
21+
parent::setUp();
22+
foreach (['DOCKER_HOST', 'DOCKER_API_VERSION', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH', 'DOCKER_PEER_NAME'] as $name) {
23+
$this->environment[$name] = getenv($name);
24+
putenv($name);
25+
}
26+
}
27+
1528
protected function tearDown(): void
1629
{
1730
parent::tearDown();
@@ -20,15 +33,56 @@ protected function tearDown(): void
2033

2134
return new PluginClient($client, $plugins, $options);
2235
});
23-
putenv('DOCKER_API_VERSION');
24-
putenv('DOCKER_TLS_VERIFY');
36+
foreach ($this->environment as $name => $value) {
37+
putenv(false === $value ? $name : $name.'='.$value);
38+
}
2539
}
2640

2741
public function testStaticConstructor(): void
2842
{
2943
$this->assertInstanceOf(ClientInterface::class, DockerClientFactory::create());
3044
}
3145

46+
public static function socketAddresses(): array
47+
{
48+
return [
49+
'Unix socket' => ['unix:///var/run/docker.sock', 'unix:///var/run/docker.sock', 'http://localhost', null],
50+
'TCP socket' => ['tcp://docker.test:2375', 'tcp://docker.test:2375', 'http://docker.test:2375', null],
51+
'HTTP URL' => ['http://docker.test:2375', 'tcp://docker.test:2375', 'http://docker.test:2375', null],
52+
'HTTPS URL' => ['https://docker.test:2376', 'tcp://docker.test:2376', 'https://docker.test:2376', true],
53+
'Default HTTP port' => ['http://docker.test', 'tcp://docker.test:80', 'http://docker.test', null],
54+
'Default HTTPS port' => ['https://docker.test', 'tcp://docker.test:443', 'https://docker.test', true],
55+
'Explicit default HTTPS port' => ['https://docker.test:443', 'tcp://docker.test:443', 'https://docker.test', true],
56+
'IPv6 HTTP' => ['http://[::1]:2375', 'tcp://[::1]:2375', 'http://[::1]:2375', null],
57+
'IPv6 HTTPS' => ['https://[::1]', 'tcp://[::1]:443', 'https://[::1]', true],
58+
'Uppercase HTTPS scheme' => ['HTTPS://docker.test:2376', 'tcp://docker.test:2376', 'https://docker.test:2376', true],
59+
];
60+
}
61+
62+
#[DataProvider('socketAddresses')]
63+
public function testSocketAddressAndRequestUri(string $address, string $socket, string $host, ?bool $ssl): void
64+
{
65+
$config = [];
66+
$uri = null;
67+
PluginClientFactory::setFactory(static function (Client $client, array $plugins, array $options) use (&$config, &$uri): PluginClient {
68+
$config = (new \ReflectionProperty($client, 'config'))->getValue($client);
69+
foreach ($plugins as $plugin) {
70+
if ($plugin instanceof \Http\Client\Common\Plugin\AddHostPlugin) {
71+
$uri = (new \ReflectionProperty($plugin, 'host'))->getValue($plugin);
72+
}
73+
}
74+
unset($options['client_name']);
75+
76+
return new PluginClient($client, $plugins, $options);
77+
});
78+
79+
DockerClientFactory::create(['remote_socket' => $address]);
80+
81+
$this->assertSame($socket, $config['remote_socket']);
82+
$this->assertSame($ssl, $config['ssl']);
83+
$this->assertSame($host, (string) $uri);
84+
}
85+
3286
public function testDefaultApiVersion(): void
3387
{
3488
$this->assertSame('/v1.45', $this->getApiPath());
Collapse file

‎tests/DockerClientFactoryTransportTest.php‎

Copy file name to clipboard
+140Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Docker\Tests;
6+
7+
use Docker\DockerClientFactory;
8+
use Http\Client\Socket\Exception\SSLConnectionException;
9+
use Nyholm\Psr7\Request;
10+
use PHPUnit\Framework\Attributes\DataProvider;
11+
12+
class DockerClientFactoryTransportTest extends TransportTestCase
13+
{
14+
public static function transports(): array
15+
{
16+
return [
17+
'Unix socket' => ['unix'],
18+
'TCP socket' => ['tcp'],
19+
'HTTP URL' => ['http'],
20+
'HTTPS URL' => ['https'],
21+
];
22+
}
23+
24+
#[DataProvider('transports')]
25+
public function testRealConnection(string $scheme): void
26+
{
27+
$serverConfig = [];
28+
$clientConfig = ['timeout' => 3000];
29+
if ('https' === $scheme) {
30+
$this->createCertificates();
31+
$serverConfig['ssl'] = $this->serverTlsOptions();
32+
$clientConfig['stream_context_options']['ssl']['cafile'] = $this->directory.'/ca.pem';
33+
}
34+
$address = $this->startServer($serverConfig, 'unix' === $scheme);
35+
$clientConfig['remote_socket'] = $scheme.'://'.$address;
36+
$response = DockerClientFactory::create($clientConfig)->sendRequest(new Request('GET', '/_ping'));
37+
38+
$this->assertSame(200, $response->getStatusCode());
39+
$this->assertSame('OK', $response->getBody()->getContents());
40+
$request = $this->serverResult()['request'];
41+
$this->assertStringStartsWith("GET /v1.45/_ping HTTP/1.1\r\n", $request);
42+
$expectedHost = 'unix' === $scheme ? 'localhost' : $address;
43+
$this->assertStringContainsString('Host: '.$expectedHost."\r\n", $request);
44+
}
45+
46+
public function testHttpUrlFromEnvironment(): void
47+
{
48+
$address = $this->startServer();
49+
putenv('DOCKER_HOST=http://'.$address);
50+
putenv('DOCKER_API_VERSION=1.52');
51+
52+
$response = DockerClientFactory::createFromEnv()->sendRequest(new Request('GET', '/_ping'));
53+
54+
$this->assertSame('OK', $response->getBody()->getContents());
55+
$this->assertStringStartsWith('GET /v1.52/_ping HTTP/1.1', $this->serverResult()['request']);
56+
}
57+
58+
public function testHttpsDoesNotAllowPlaintextDowngrade(): void
59+
{
60+
$this->createCertificates();
61+
$address = $this->startServer(['ssl' => $this->serverTlsOptions()]);
62+
$response = DockerClientFactory::create([
63+
'remote_socket' => 'https://'.$address,
64+
'ssl' => false,
65+
'timeout' => 3000,
66+
'stream_context_options' => ['ssl' => ['cafile' => $this->directory.'/ca.pem']],
67+
])->sendRequest(new Request('GET', '/_ping'));
68+
69+
$this->assertSame('OK', $response->getBody()->getContents());
70+
$this->assertNotEmpty($this->serverResult()['request']);
71+
}
72+
73+
public function testHttpsRejectsUntrustedCertificate(): void
74+
{
75+
$this->createCertificates();
76+
$address = $this->startServer(['ssl' => $this->serverTlsOptions()]);
77+
$this->expectException(SSLConnectionException::class);
78+
79+
DockerClientFactory::create([
80+
'remote_socket' => 'https://'.$address,
81+
'timeout' => 3000,
82+
])->sendRequest(new Request('GET', '/_ping'));
83+
}
84+
85+
public function testHttpsRejectsWrongPeerName(): void
86+
{
87+
$this->createCertificates();
88+
$address = $this->startServer(['ssl' => $this->serverTlsOptions()]);
89+
$this->expectException(SSLConnectionException::class);
90+
91+
DockerClientFactory::create([
92+
'remote_socket' => 'https://'.$address,
93+
'timeout' => 3000,
94+
'stream_context_options' => ['ssl' => [
95+
'cafile' => $this->directory.'/ca.pem',
96+
'peer_name' => 'not-docker.test',
97+
]],
98+
])->sendRequest(new Request('GET', '/_ping'));
99+
}
100+
101+
public static function tlsTransports(): array
102+
{
103+
return ['TCP with TLS' => ['tcp'], 'HTTPS with client certificates' => ['https']];
104+
}
105+
106+
#[DataProvider('tlsTransports')]
107+
public function testTlsEnvironmentWithClientCertificates(string $scheme): void
108+
{
109+
$this->createCertificates();
110+
$address = $this->startServer(['ssl' => $this->serverTlsOptions() + [
111+
'verify_peer' => true,
112+
'verify_peer_name' => false,
113+
'cafile' => $this->directory.'/ca.pem',
114+
'capture_peer_cert' => true,
115+
]]);
116+
putenv('DOCKER_HOST='.$scheme.'://'.$address);
117+
putenv('DOCKER_TLS_VERIFY=1');
118+
putenv('DOCKER_CERT_PATH='.$this->directory);
119+
putenv('DOCKER_PEER_NAME=docker.test');
120+
121+
$response = DockerClientFactory::createFromEnv()->sendRequest(new Request('GET', '/_ping'));
122+
123+
$this->assertSame('OK', $response->getBody()->getContents());
124+
$this->assertSame('docker-client', $this->serverResult()['peer']);
125+
}
126+
127+
public function testUpgradedResponseRemainsReadable(): void
128+
{
129+
$address = $this->startServer(['upgrade' => true]);
130+
$response = DockerClientFactory::create(['remote_socket' => 'http://'.$address])->sendRequest(
131+
new Request('POST', '/exec/test/start', ['Connection' => 'Upgrade', 'Upgrade' => 'tcp'])
132+
);
133+
134+
$this->assertSame(101, $response->getStatusCode());
135+
$this->assertSame('stream-output', $response->getBody()->getContents());
136+
$request = $this->serverResult()['request'];
137+
$this->assertStringContainsString("Connection: Upgrade\r\n", $request);
138+
$this->assertStringContainsString("Upgrade: tcp\r\n", $request);
139+
}
140+
}

0 commit comments

Comments
0 (0)
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.