Skip to content

Commit b3f7bef

Browse files
committed
Merge pull request #37 from clue-labs/execstartstream
Add execStartStream() API endpoint
2 parents c73b287 + ef1ad27 commit b3f7bef

7 files changed

+259
-9
lines changed

README.md

+47
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ execute arbitrary commands within isolated containers, stop running containers a
3030
* [Commands](#commands)
3131
* [Promises](#promises)
3232
* [Blocking](#blocking)
33+
* [Command streaming](#command-streaming)
3334
* [TAR streaming](#tar-streaming)
3435
* [JSON streaming](#json-streaming)
3536
* [JsonProgressException](#jsonprogressexception)
@@ -183,6 +184,52 @@ $inspections = Block\awaitAll($promises, $loop);
183184

184185
Please refer to [clue/block-react](https://github.com/clue/php-block-react#readme) for more details.
185186

187+
#### Command streaming
188+
189+
The following API endpoint resolves with a buffered string of the command output
190+
(STDOUT and/or STDERR):
191+
192+
```php
193+
$client->execStart($exec);
194+
```
195+
196+
Keep in mind that this means the whole string has to be kept in memory.
197+
If you want to access the individual output chunks as they happen or
198+
for bigger command outputs, it's usually a better idea to use a streaming
199+
approach.
200+
201+
This works for (any number of) commands of arbitrary sizes.
202+
The following API endpoint complements the default Promise-based API and returns
203+
a [`Stream`](https://github.com/reactphp/stream) instance instead:
204+
205+
```php
206+
$stream = $client->execStartStream($exec);
207+
```
208+
209+
The resulting stream is a well-behaving readable stream that will emit
210+
the normal stream events:
211+
212+
```php
213+
$stream = $client->execStartStream($exec, $config);
214+
$stream->on('data', function ($data) {
215+
// data will be emitted in multiple chunk
216+
echo $data;
217+
});
218+
$stream->on('close', function () {
219+
// the stream just ended, this could(?) be a good thing
220+
echo 'Ended' . PHP_EOL;
221+
});
222+
```
223+
224+
See also the [streaming exec example](examples/exec-stream.php) and the [exec benchmark example](examples/benchmark-exec.php).
225+
226+
Running this benchmark on my personal (rather mediocre) VM setup reveals that
227+
the benchmark achieves a throughput of ~300 MiB/s while the (totally unfair)
228+
comparison script using the plain Docker client only yields ~100 MiB/s.
229+
Instead of me posting more details here, I encourage you to re-run the benchmark
230+
yourself and adjust it to better suite your problem domain.
231+
The key takeway here is: *PHP is faster than you probably thought*.
232+
186233
#### TAR streaming
187234

188235
The following API endpoints resolve with a string in the [TAR file format](https://en.wikipedia.org/wiki/Tar_%28computing%29):

examples/benchmark-exec.php

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
// this simple example executes a command within the given running container and
3+
// displays how fast it can receive its output.
4+
// expect this to be significantly faster than the (totally unfair) equivalent:
5+
// $ docker exec asd dd if=/dev/zero bs=1M count=1000 | dd of=/dev/null
6+
7+
require __DIR__ . '/../vendor/autoload.php';
8+
9+
use React\EventLoop\Factory as LoopFactory;
10+
use Clue\React\Docker\Factory;
11+
use React\Stream\Stream;
12+
13+
$container = 'asd';
14+
$cmd = array('dd', 'if=/dev/zero', 'bs=1M', 'count=1000');
15+
16+
if (isset($argv[1])) {
17+
$container = $argv[1];
18+
$cmd = array_slice($argv, 2);
19+
}
20+
21+
$loop = LoopFactory::create();
22+
23+
$factory = new Factory($loop);
24+
$client = $factory->createClient();
25+
26+
$client->execCreate($container, array('Cmd' => $cmd, 'AttachStdout' => true, 'AttachStderr' => true))->then(function ($info) use ($client) {
27+
$stream = $client->execStartStream($info['Id'], array('Tty' => true));
28+
29+
$start = microtime(true);
30+
$bytes = 0;
31+
$stream->on('data', function ($chunk) use (&$bytes) {
32+
$bytes += strlen($chunk);
33+
});
34+
35+
$stream->on('error', 'printf');
36+
37+
// show stats when stream ends
38+
$stream->on('close', function () use ($client, &$bytes, $start) {
39+
$time = microtime(true) - $start;
40+
41+
echo 'Received ' . $bytes . ' bytes in ' . round($time, 1) . 's => ' . round($bytes / $time / 1024 / 1024, 1) . ' MiB/s' . PHP_EOL;
42+
});
43+
}, 'printf');
44+
45+
$loop->run();

examples/exec-stream.php

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
// this example executes some commands within the given running container and
3+
// displays the streaming output as it happens.
4+
5+
require __DIR__ . '/../vendor/autoload.php';
6+
7+
use React\EventLoop\Factory as LoopFactory;
8+
use Clue\React\Docker\Factory;
9+
use React\Stream\Stream;
10+
11+
$container = 'asd';
12+
//$cmd = array('echo', 'hello world');
13+
//$cmd = array('sleep', '2');
14+
$cmd = array('sh', '-c', 'echo -n hello && sleep 1 && echo world && sleep 1 && env');
15+
//$cmd = array('cat', 'invalid-path');
16+
17+
if (isset($argv[1])) {
18+
$container = $argv[1];
19+
$cmd = array_slice($argv, 2);
20+
}
21+
22+
$loop = LoopFactory::create();
23+
24+
$factory = new Factory($loop);
25+
$client = $factory->createClient();
26+
27+
$out = new Stream(STDOUT, $loop);
28+
$out->pause();
29+
30+
$client->execCreate($container, array('Cmd' => $cmd, 'AttachStdout' => true, 'AttachStderr' => true, 'Tty' => true))->then(function ($info) use ($client, $out) {
31+
$stream = $client->execStartStream($info['Id'], array('Tty' => true));
32+
$stream->pipe($out);
33+
34+
$stream->on('error', 'printf');
35+
36+
// exit with error code of executed command once it closes
37+
$stream->on('close', function () use ($client, $info) {
38+
$client->execInspect($info['Id'])->then(function ($info) {
39+
exit($info['ExitCode']);
40+
}, 'printf');
41+
});
42+
}, 'printf');
43+
44+
$loop->run();

src/Client.php

+44-8
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,9 @@ public function execCreate($container, $config)
907907
* as set up in the `execCreate()` call.
908908
*
909909
* Keep in mind that this means the whole string has to be kept in memory.
910+
* If you want to access the individual output chunks as they happen or
911+
* for bigger command outputs, it's usually a better idea to use a streaming
912+
* approach, see `execStartStream()` for more details.
910913
*
911914
* If detach is true, this API returns after starting the exec command.
912915
* Otherwise, this API sets up an interactive session with the exec command.
@@ -915,18 +918,51 @@ public function execCreate($container, $config)
915918
* @param array $config (see link)
916919
* @return PromiseInterface Promise<string> buffered exec data
917920
* @link https://docs.docker.com/reference/api/docker_remote_api_v1.15/#exec-start
921+
* @uses self::execStartStream()
922+
* @see self::execStartStream()
918923
*/
919924
public function execStart($exec, $config = array())
920925
{
921-
return $this->postJson(
922-
$this->uri->expand(
923-
'/exec/{exec}/start',
926+
return $this->streamingParser->bufferedStream(
927+
$this->execStartStream($exec, $config)
928+
);
929+
}
930+
931+
/**
932+
* Starts a previously set up exec instance id.
933+
*
934+
* This is a streaming API endpoint that returns a readable stream instance
935+
* containing the command output, i.e. STDOUT and STDERR as set up in the
936+
* `execCreate()` call.
937+
*
938+
* This works for command output of any size as only small chunks have to
939+
* be kept in memory.
940+
*
941+
* If detach is true, this API returns after starting the exec command.
942+
* Otherwise, this API sets up an interactive session with the exec command.
943+
*
944+
* @param string $exec exec ID
945+
* @param array $config (see link)
946+
* @return ReadableStreamInterface stream of exec data
947+
* @link https://docs.docker.com/reference/api/docker_remote_api_v1.15/#exec-start
948+
* @see self::execStart()
949+
*/
950+
public function execStartStream($exec, $config = array())
951+
{
952+
return $this->streamingParser->parsePlainStream(
953+
$this->browser->withOptions(array('streaming' => true))->post(
954+
$this->uri->expand(
955+
'/exec/{exec}/start',
956+
array(
957+
'exec' => $exec
958+
)
959+
),
924960
array(
925-
'exec' => $exec
926-
)
927-
),
928-
$config
929-
)->then(array($this->parser, 'expectPlain'));
961+
'Content-Type' => 'application/json'
962+
),
963+
$this->json($config)
964+
)
965+
);
930966
}
931967

932968
/**

src/Io/StreamingParser.php

+11
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ public function parsePlainStream(PromiseInterface $promise)
8484
}));
8585
}
8686

87+
/**
88+
* Returns a promise which resolves with the buffered stream contents of the given stream
89+
*
90+
* @param ReadableStreamInterface $stream
91+
* @return PromiseInterface Promise<string, Exception>
92+
*/
93+
public function bufferedStream(ReadableStreamInterface $stream)
94+
{
95+
return Stream\buffer($stream);
96+
}
97+
8798
/**
8899
* Returns a promise which resolves with an array of all "progress" events
89100
*

tests/ClientTest.php

+17-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use RingCentral\Psr7\Response;
77
use Psr\Http\Message\ResponseInterface;
88
use Psr\Http\Message\RequestInterface;
9+
use React\Promise;
910

1011
class ClientTest extends TestCase
1112
{
@@ -388,11 +389,26 @@ public function testExecStart()
388389
{
389390
$data = 'hello world';
390391
$config = array();
391-
$this->expectRequestFlow('post', '/exec/123/start', $this->createResponse($data), 'expectPlain');
392+
$stream = $this->getMock('React\Stream\ReadableStreamInterface');
393+
394+
$this->expectRequest('POST', '/exec/123/start', $this->createResponse($data));
395+
$this->streamingParser->expects($this->once())->method('parsePlainStream')->will($this->returnValue($stream));
396+
$this->streamingParser->expects($this->once())->method('bufferedStream')->with($this->equalTo($stream))->willReturn(Promise\resolve($data));
392397

393398
$this->expectPromiseResolveWith($data, $this->client->execStart(123, $config));
394399
}
395400

401+
public function testExecStartStream()
402+
{
403+
$config = array();
404+
$stream = $this->getMock('React\Stream\ReadableStreamInterface');
405+
406+
$this->expectRequest('POST', '/exec/123/start', $this->createResponse());
407+
$this->streamingParser->expects($this->once())->method('parsePlainStream')->will($this->returnValue($stream));
408+
409+
$this->assertSame($stream, $this->client->execStartStream(123, $config));
410+
}
411+
396412
public function testExecResize()
397413
{
398414
$this->expectRequestFlow('POST', '/exec/123/resize?w=800&h=600', $this->createResponse(), 'expectEmpty');

tests/FunctionalClientTest.php

+51
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use React\EventLoop\Factory as LoopFactory;
55
use Clue\React\Docker\Factory;
66
use Clue\React\Block;
7+
use Clue\React\Promise\Stream;
78

89
class FunctionalClientTest extends TestCase
910
{
@@ -157,6 +158,56 @@ public function testExecInspectAfterRunning($exec)
157158
$this->assertEquals(0, $info['ExitCode']);
158159
}
159160

161+
/**
162+
* @depends testStartRunning
163+
* @param string $container
164+
*/
165+
public function testExecStreamEmptyOutputWhileRunning($container)
166+
{
167+
$promise = $this->client->execCreate($container, array(
168+
'Cmd' => array('true'),
169+
'AttachStdout' => true,
170+
'AttachStderr' => true,
171+
'Tty' => true
172+
));
173+
$exec = Block\await($promise, $this->loop);
174+
175+
$this->assertTrue(is_array($exec));
176+
$this->assertTrue(is_string($exec['Id']));
177+
178+
$stream = $this->client->execStartStream($exec['Id'], array('Tty' => true));
179+
$stream->on('end', $this->expectCallableOnce());
180+
181+
$output = Block\await(Stream\buffer($stream), $this->loop);
182+
183+
$this->assertEquals('', $output);
184+
}
185+
186+
/**
187+
* @depends testStartRunning
188+
* @param string $container
189+
*/
190+
public function testExecStreamEmptyOutputBecauseOfDetachWhileRunning($container)
191+
{
192+
$promise = $this->client->execCreate($container, array(
193+
'Cmd' => array('sleep', '10'),
194+
'AttachStdout' => true,
195+
'AttachStderr' => true,
196+
'Tty' => true
197+
));
198+
$exec = Block\await($promise, $this->loop);
199+
200+
$this->assertTrue(is_array($exec));
201+
$this->assertTrue(is_string($exec['Id']));
202+
203+
$stream = $this->client->execStartStream($exec['Id'], array('Tty' => true, 'Detach' => true));
204+
$stream->on('end', $this->expectCallableOnce());
205+
206+
$output = Block\await(Stream\buffer($stream), $this->loop);
207+
208+
$this->assertEquals('', $output);
209+
}
210+
160211
/**
161212
* @depends testStartRunning
162213
* @param string $container

0 commit comments

Comments
 (0)