GeezerVison is showing github.com — fix(transport): support explicit endpoints and live output · docker-php/docker-php@3907105
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: src/DockerClientFactory.php +23-7Lines changed: 23 additions & 7 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 4 | 4 |
| 5 | 5 | namespace Docker; | 6 | 6 |
| | 7 | +use Docker\Http\StreamingDecoderPlugin; | 7 | 8 | use Http\Client\Common\Plugin\AddHostPlugin; | 8 | 9 | use Http\Client\Common\Plugin\AddPathPlugin; | 9 | 10 | use Http\Client\Common\Plugin\ContentLengthPlugin; | 10 | | -use Http\Client\Common\Plugin\DecoderPlugin; | 11 | 11 | use Http\Client\Common\Plugin\HeaderDefaultsPlugin; | 12 | 12 | use Http\Client\Common\PluginClient; | 13 | 13 | use Http\Client\Common\PluginClientFactory; | @@ -22,10 +22,26 @@ public static function create(array $config = [], ?PluginClientFactory $pluginCl | 22 | 22 | $config['remote_socket'] = 'unix:///var/run/docker.sock'; | 23 | 23 | } | 24 | 24 |
| 25 | | - $socketClient = new Client($config); | 26 | | - | 27 | 25 | $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); | 29 | 45 | $dockerApiVersion = ltrim(getenv('DOCKER_API_VERSION') ?: 'v1.45', '/'); | 30 | 46 | if (!str_starts_with($dockerApiVersion, 'v')) { | 31 | 47 | $dockerApiVersion = 'v'.$dockerApiVersion; | @@ -37,11 +53,11 @@ public static function create(array $config = [], ?PluginClientFactory $pluginCl | 37 | 53 | $socketClient, | 38 | 54 | [ | 39 | 55 | new ContentLengthPlugin(), | 40 | | - new DecoderPlugin(), | | 56 | + new StreamingDecoderPlugin(), | 41 | 57 | new AddPathPlugin($uriFactory->createUri('/'.$dockerApiVersion)), | 42 | | - new AddHostPlugin($uriFactory->createUri($host)), | | 58 | + new AddHostPlugin($host), | 43 | 59 | new HeaderDefaultsPlugin([ | 44 | | - 'host' => parse_url($host, \PHP_URL_HOST), | | 60 | + 'host' => $host->withUserInfo('')->getAuthority(), | 45 | 61 | ]), | 46 | 62 | ], | 47 | 63 | [ | |
Collapse file Copy file name to clipboard +40Lines changed: 40 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 Copy file name to clipboard +30Lines changed: 30 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 Copy file name to clipboard +31Lines changed: 31 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 Copy file name to clipboardExpand all lines: tests/DockerClientFactoryTest.php +56-2Lines changed: 56 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 8 | 8 | use Http\Client\Common\Plugin\AddPathPlugin; | 9 | 9 | use Http\Client\Common\PluginClient; | 10 | 10 | use Http\Client\Common\PluginClientFactory; | | 11 | +use Http\Client\Socket\Client; | | 12 | +use PHPUnit\Framework\Attributes\DataProvider; | 11 | 13 | use Psr\Http\Client\ClientInterface; | 12 | 14 |
| 13 | 15 | class DockerClientFactoryTest extends TestCase | 14 | 16 | { | | 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 | + | 15 | 28 | protected function tearDown(): void | 16 | 29 | { | 17 | 30 | parent::tearDown(); | @@ -20,15 +33,56 @@ protected function tearDown(): void | 20 | 33 |
| 21 | 34 | return new PluginClient($client, $plugins, $options); | 22 | 35 | }); | 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 | + } | 25 | 39 | } | 26 | 40 |
| 27 | 41 | public function testStaticConstructor(): void | 28 | 42 | { | 29 | 43 | $this->assertInstanceOf(ClientInterface::class, DockerClientFactory::create()); | 30 | 44 | } | 31 | 45 |
| | 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 | + | 32 | 86 | public function testDefaultApiVersion(): void | 33 | 87 | { | 34 | 88 | $this->assertSame('/v1.45', $this->getApiPath()); | |
Collapse file Copy file name to clipboard +140Lines changed: 140 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 | +} |
|