Skip to content

Commit f47d14d

Browse files
committed
Add containerAttach() and containerAttachStream() API methods
1 parent 0bfc2b2 commit f47d14d

File tree

6 files changed

+246
-10
lines changed

6 files changed

+246
-10
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ The following API endpoints resolve with a buffered string of the command output
172172
(STDOUT and/or STDERR):
173173

174174
```php
175+
$client->containerAttach($container);
175176
$client->containerLogs($container);
176177
$client->execStart($exec);
177178
```
@@ -186,6 +187,7 @@ The following API endpoints complement the default Promise-based API and return
186187
a [`Stream`](https://github.com/reactphp/stream) instance instead:
187188

188189
```php
190+
$stream = $client->containerAttachStream($container);
189191
$stream = $client->containerLogsStream($container);
190192
$stream = $client->execStartStream($exec);
191193
```

examples/attach-stream.php

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
// this example shows how the containerAttachStream() call can be used to get the output of the given container.
4+
// demonstrates the streaming attach API, which can be used to dump the container output as it arrives
5+
//
6+
// $ docker run -it --rm --name=foo busybox sh
7+
// $ php examples/attach-stream.php foo
8+
9+
use Clue\CaretNotation\Encoder;
10+
use Clue\React\Docker\Client;
11+
12+
require __DIR__ . '/../vendor/autoload.php';
13+
14+
$container = isset($argv[1]) ? $argv[1] : 'foo';
15+
echo 'Dumping output of container "' . $container . '" (pass as argument to this example)' . PHP_EOL;
16+
17+
$loop = React\EventLoop\Factory::create();
18+
$client = new Client($loop);
19+
20+
// use caret notation for any control characters except \t, \r and \n
21+
$caret = new Encoder("\t\r\n");
22+
23+
$stream = $client->containerAttachStream($container, true, true);
24+
$stream->on('data', function ($data) use ($caret) {
25+
echo $caret->encode($data);
26+
});
27+
28+
$stream->on('error', function (Exception $e) {
29+
// will be called if either parameter is invalid
30+
echo 'ERROR: ' . $e->getMessage() . PHP_EOL;
31+
});
32+
33+
$stream->on('close', function () {
34+
echo 'CLOSED' . PHP_EOL;
35+
});
36+
37+
$loop->run();

examples/logs.php

+5-1
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22

33
// this example shows how the containerLogs() call can be used to get the logs of the given container.
44
// demonstrates the deferred logs API, which can be used to dump the logs in one go
5+
//
6+
// $ docker run -d --name=foo busybox ps
7+
// $ php examples/logs.php foo
8+
// $ docker rm foo
59

610
use Clue\CaretNotation\Encoder;
711
use Clue\React\Docker\Client;
812

913
require __DIR__ . '/../vendor/autoload.php';
1014

11-
$container = isset($argv[1]) ? $argv[1] : 'asd';
15+
$container = isset($argv[1]) ? $argv[1] : 'foo';
1216
echo 'Dumping logs (last 100 lines) of container "' . $container . '" (pass as argument to this example)' . PHP_EOL;
1317

1418
$loop = React\EventLoop\Factory::create();

src/Client.php

+100
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,106 @@ public function containerUnpause($container)
672672
)->then(array($this->parser, 'expectEmpty'));
673673
}
674674

675+
/**
676+
* Attach to a container to read its output.
677+
*
678+
* This resolves with a string containing the container output, i.e. STDOUT
679+
* and STDERR as requested.
680+
*
681+
* Keep in mind that this means the whole string has to be kept in memory.
682+
* For a larger container output it's usually a better idea to use a streaming
683+
* approach, see `containerAttachStream()` for more details.
684+
* In particular, the same also applies for the `$stream` flag. It can be used
685+
* to follow the container output as long as the container is running.
686+
*
687+
* Note that this endpoint internally has to check the `containerInspect()`
688+
* endpoint first in order to figure out the TTY settings to properly decode
689+
* the raw container output.
690+
*
691+
* @param string $container container ID
692+
* @param bool $logs replay previous logs before attaching. Default false
693+
* @param bool $stream continue streaming. Default false
694+
* @param bool $stdout attach to stdout. Default true
695+
* @param bool $stderr attach to stderr. Default true
696+
* @return PromiseInterface Promise<string> container output string
697+
* @link https://docs.docker.com/engine/api/v1.40/#operation/ContainerAttach
698+
* @uses self::containerAttachStream()
699+
* @see self::containerAttachStream()
700+
*/
701+
public function containerAttach($container, $logs = false, $stream = false, $stdout = true, $stderr = true)
702+
{
703+
return $this->streamingParser->bufferedStream(
704+
$this->containerAttachStream($container, $logs, $stream, $stdout, $stderr)
705+
);
706+
}
707+
708+
/**
709+
* Attach to a container to read its output.
710+
*
711+
* This is a streaming API endpoint that returns a readable stream instance
712+
* containing the container output, i.e. STDOUT and STDERR as requested.
713+
*
714+
* This works for container output of arbitrary sizes as only small chunks have to
715+
* be kept in memory.
716+
*
717+
* This is particularly useful for the `$stream` flag. It can be used to
718+
* follow the container output as long as the container is running. Either
719+
* the `$stream` or `$logs` parameter must be `true` for this endpoint to do
720+
* anything meaningful.
721+
*
722+
* Note that by default the output of both STDOUT and STDERR will be emitted
723+
* as normal "data" events. You can optionally pass a custom event name which
724+
* will be used to emit STDERR data so that it can be handled separately.
725+
* Note that the normal streaming primitives likely do not know about this
726+
* event, so special care may have to be taken.
727+
* Also note that this option has no effect if the container has been
728+
* created with a TTY.
729+
*
730+
* Note that this endpoint internally has to check the `containerInspect()`
731+
* endpoint first in order to figure out the TTY settings to properly decode
732+
* the raw container output.
733+
*
734+
* Note that this endpoint intentionally does not expose the `$stdin` flag.
735+
* Access to STDIN will be exposed as a dedicated API endpoint in a future
736+
* version.
737+
*
738+
* @param string $container container ID
739+
* @param bool $logs replay previous logs before attaching. Default false
740+
* @param bool $stream continue streaming. Default false
741+
* @param bool $stdout attach to stdout. Default true
742+
* @param bool $stderr attach to stderr. Default true
743+
* @param string $stderrEvent custom event to emit for STDERR data (otherwise emits as "data")
744+
* @return ReadableStreamInterface container output stream
745+
* @link https://docs.docker.com/engine/api/v1.40/#operation/ContainerAttach
746+
* @see self::containerAttach()
747+
*/
748+
public function containerAttachStream($container, $logs = false, $stream = false, $stdout = true, $stderr = true, $stderrEvent = null)
749+
{
750+
$parser = $this->streamingParser;
751+
$browser = $this->browser;
752+
$url = $this->uri->expand(
753+
'/containers/{container}/attach{?logs,stream,stdout,stderr}',
754+
array(
755+
'container' => $container,
756+
'logs' => $this->boolArg($logs),
757+
'stream' => $this->boolArg($stream),
758+
'stdout' => $this->boolArg($stdout),
759+
'stderr' => $this->boolArg($stderr)
760+
)
761+
);
762+
763+
// first inspect container to check TTY setting, then attach with appropriate log parser
764+
return \React\Promise\Stream\unwrapReadable($this->containerInspect($container)->then(function ($info) use ($url, $browser, $parser, $stderrEvent) {
765+
$stream = $parser->parsePlainStream($browser->withOptions(array('streaming' => true))->post($url));
766+
767+
if (!$info['Config']['Tty']) {
768+
$stream = $parser->demultiplexStream($stream, $stderrEvent);
769+
}
770+
771+
return $stream;
772+
}));
773+
}
774+
675775
/**
676776
* Block until container id stops, then returns the exit code
677777
*

tests/ClientTest.php

+91-4
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ public function testContainerLogsRejectsWhenInspectingContainerRejects()
190190
$promise->then($this->expectCallableNever(), $this->expectCallableOnce());
191191
}
192192

193-
public function testContainerLogsReturnsPendingPromiseWhenInspectingContainerResolvesWithTtyAndContainerLogsArePending()
193+
public function testContainerLogsReturnsPendingPromiseWhenInspectingContainerResolvesWithTtyAndContainerLogsRequestIsPending()
194194
{
195195
$this->browser->expects($this->once())->method('withOptions')->willReturnSelf();
196196
$this->browser->expects($this->exactly(2))->method('get')->withConsecutive(
@@ -211,7 +211,7 @@ public function testContainerLogsReturnsPendingPromiseWhenInspectingContainerRes
211211
$promise->then($this->expectCallableNever(), $this->expectCallableNever());
212212
}
213213

214-
public function testContainerLogsReturnsPendingPromiseWhenInspectingContainerResolvesWithoutTtyAndContainerLogsArePending()
214+
public function testContainerLogsReturnsPendingPromiseWhenInspectingContainerResolvesWithoutTtyAndContainerLogsRequestIsPending()
215215
{
216216
$this->browser->expects($this->once())->method('withOptions')->willReturnSelf();
217217
$this->browser->expects($this->exactly(2))->method('get')->withConsecutive(
@@ -232,7 +232,7 @@ public function testContainerLogsReturnsPendingPromiseWhenInspectingContainerRes
232232
$promise->then($this->expectCallableNever(), $this->expectCallableNever());
233233
}
234234

235-
public function testContainerLogsResolvesWhenInspectingContainerResolvesWithTtyAndContainerLogsResolves()
235+
public function testContainerLogsResolvesWhenInspectingContainerResolvesWithTtyAndContainerLogsRequestResolves()
236236
{
237237
$this->browser->expects($this->once())->method('withOptions')->willReturnSelf();
238238
$this->browser->expects($this->exactly(2))->method('get')->withConsecutive(
@@ -253,7 +253,7 @@ public function testContainerLogsResolvesWhenInspectingContainerResolvesWithTtyA
253253
$promise->then($this->expectCallableOnceWith('output'), $this->expectCallableNever());
254254
}
255255

256-
public function testContainerLogsStreamReturnStreamWhenInspectingContainerResolvesWithTtyAndContainerLogsResolves()
256+
public function testContainerLogsStreamReturnStreamWhenInspectingContainerResolvesWithTtyAndContainerLogsRequestResolves()
257257
{
258258
$this->browser->expects($this->once())->method('withOptions')->willReturnSelf();
259259
$this->browser->expects($this->exactly(2))->method('get')->withConsecutive(
@@ -379,6 +379,93 @@ public function testContainerUnpause()
379379
$this->expectPromiseResolveWith('', $this->client->containerUnpause(123));
380380
}
381381

382+
public function testContainerAttachReturnsPendingPromiseWhenInspectingContainerIsPending()
383+
{
384+
$this->browser->expects($this->once())->method('get')->with('/containers/123/json')->willReturn(new \React\Promise\Promise(function () { }));
385+
386+
$this->streamingParser->expects($this->once())->method('bufferedStream')->with($this->isInstanceOf('React\Stream\ReadableStreamInterface'))->willReturn(new \React\Promise\Promise(function () { }));
387+
388+
$promise = $this->client->containerAttach('123', true, false);
389+
390+
$promise->then($this->expectCallableNever(), $this->expectCallableNever());
391+
}
392+
393+
public function testContainerAttachRejectsWhenInspectingContainerRejects()
394+
{
395+
$this->browser->expects($this->once())->method('get')->with('/containers/123/json')->willReturn(\React\Promise\reject(new \RuntimeException()));
396+
397+
$this->streamingParser->expects($this->once())->method('bufferedStream')->with($this->isInstanceOf('React\Stream\ReadableStreamInterface'))->willReturn(\React\Promise\reject(new \RuntimeException()));
398+
399+
$promise = $this->client->containerAttach('123', true, false);
400+
401+
$promise->then($this->expectCallableNever(), $this->expectCallableOnce());
402+
}
403+
404+
public function testContainerAttachReturnsPendingPromiseWhenInspectingContainerResolvesWithTtyAndContainerAttachIsPending()
405+
{
406+
$this->browser->expects($this->once())->method('withOptions')->willReturnSelf();
407+
$this->browser->expects($this->once())->method('get')->with('/containers/123/json')->willReturn(\React\Promise\resolve(new Response(200, array(), '{"Config":{"Tty":true}}')));
408+
$this->browser->expects($this->once())->method('post')->with('/containers/123/attach?logs=1&stdout=1&stderr=1')->willReturn(new \React\Promise\Promise(function () { }));
409+
410+
$this->parser->expects($this->once())->method('expectJson')->willReturn(array('Config' => array('Tty' => true)));
411+
$this->streamingParser->expects($this->once())->method('bufferedStream')->with($this->isInstanceOf('React\Stream\ReadableStreamInterface'))->willReturn(new \React\Promise\Promise(function () { }));
412+
$this->streamingParser->expects($this->once())->method('parsePlainStream')->willReturn($this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock());
413+
$this->streamingParser->expects($this->never())->method('demultiplexStream');
414+
415+
$promise = $this->client->containerAttach('123', true, false);
416+
417+
$promise->then($this->expectCallableNever(), $this->expectCallableNever());
418+
}
419+
420+
public function testContainerAttachReturnsPendingPromiseWhenInspectingContainerResolvesWithoutTtyAndContainerAttachRequestIsPending()
421+
{
422+
$this->browser->expects($this->once())->method('withOptions')->willReturnSelf();
423+
$this->browser->expects($this->once())->method('get')->with('/containers/123/json')->willReturn(\React\Promise\resolve(new Response(200, array(), '{"Config":{"Tty":false}}')));
424+
$this->browser->expects($this->once())->method('post')->with('/containers/123/attach?logs=1&stdout=1&stderr=1')->willReturn(new \React\Promise\Promise(function () { }));
425+
426+
$this->parser->expects($this->once())->method('expectJson')->willReturn(array('Config' => array('Tty' => false)));
427+
$this->streamingParser->expects($this->once())->method('bufferedStream')->with($this->isInstanceOf('React\Stream\ReadableStreamInterface'))->willReturn(new \React\Promise\Promise(function () { }));
428+
$this->streamingParser->expects($this->once())->method('parsePlainStream')->willReturn($this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock());
429+
$this->streamingParser->expects($this->once())->method('demultiplexStream')->willReturn($this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock());
430+
431+
$promise = $this->client->containerAttach('123', true, false);
432+
433+
$promise->then($this->expectCallableNever(), $this->expectCallableNever());
434+
}
435+
436+
public function testContainerAttachResolvesWhenInspectingContainerResolvesWithTtyAndContainerAttachResolvesAndContainerAttachRequestResolves()
437+
{
438+
$this->browser->expects($this->once())->method('withOptions')->willReturnSelf();
439+
$this->browser->expects($this->once())->method('get')->with('/containers/123/json')->willReturn(\React\Promise\resolve(new Response(200, array(), '{"Config":{"Tty":true}}')));
440+
$this->browser->expects($this->once())->method('post')->with('/containers/123/attach?logs=1&stdout=1&stderr=1')->willReturn(\React\Promise\resolve(new Response(200, array(), '')));
441+
442+
$this->parser->expects($this->once())->method('expectJson')->willReturn(array('Config' => array('Tty' => true)));
443+
$this->streamingParser->expects($this->once())->method('bufferedStream')->with($this->isInstanceOf('React\Stream\ReadableStreamInterface'))->willReturn(\React\Promise\resolve('output'));
444+
$this->streamingParser->expects($this->once())->method('parsePlainStream')->willReturn($this->getMockBuilder('React\Stream\ReadableStreamInterface')->getMock());
445+
$this->streamingParser->expects($this->never())->method('demultiplexStream');
446+
447+
$promise = $this->client->containerAttach('123', true, false);
448+
449+
$promise->then($this->expectCallableOnceWith('output'), $this->expectCallableNever());
450+
}
451+
452+
public function testContainerAttachStreamReturnStreamWhenInspectingContainerResolvesWithTtyAndContainerAttachRequestResolves()
453+
{
454+
$this->browser->expects($this->once())->method('withOptions')->willReturnSelf();
455+
$this->browser->expects($this->once())->method('get')->with('/containers/123/json')->willReturn(\React\Promise\resolve(new Response(200, array(), '{"Config":{"Tty":true}}')));
456+
$this->browser->expects($this->once())->method('post')->with('/containers/123/attach?logs=1&stream=1&stdout=1&stderr=1')->willReturn(\React\Promise\resolve(new Response(200, array(), '')));
457+
458+
$response = new ThroughStream();
459+
$this->parser->expects($this->once())->method('expectJson')->willReturn(array('Config' => array('Tty' => true)));
460+
$this->streamingParser->expects($this->once())->method('parsePlainStream')->willReturn($response);
461+
$this->streamingParser->expects($this->never())->method('demultiplexStream');
462+
463+
$stream = $this->client->containerAttachStream('123', true, true);
464+
465+
$stream->on('data', $this->expectCallableOnceWith('output'));
466+
$response->write('output');
467+
}
468+
382469
public function testContainerRemove()
383470
{
384471
$this->expectRequestFlow('delete', '/containers/123', $this->createResponse(), 'expectEmpty');

tests/FunctionalClientTest.php

+11-5
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ public function testCreateStartAndRemoveContainer()
7474

7575
$this->assertEquals("test\n", $ret);
7676

77+
$promise = $this->client->containerAttach($container['Id'], true, false);
78+
$ret = Block\await($promise, $this->loop);
79+
80+
$this->assertEquals("test\n", $ret);
81+
7782
$promise = $this->client->containerRemove($container['Id'], false, true);
7883
$ret = Block\await($promise, $this->loop);
7984

@@ -85,12 +90,13 @@ public function testCreateStartAndRemoveContainer()
8590
$promise = $this->client->events($start, $end, array('container' => array($container['Id'])));
8691
$ret = Block\await($promise, $this->loop);
8792

88-
// expects "start", "kill", "die", "destroy" events
89-
$this->assertEquals(4, count($ret));
93+
// expects "start", "attach", "kill", "die", "destroy" events
94+
$this->assertEquals(5, count($ret));
9095
$this->assertEquals('start', $ret[0]['status']);
91-
$this->assertEquals('kill', $ret[1]['status']);
92-
$this->assertEquals('die', $ret[2]['status']);
93-
$this->assertEquals('destroy', $ret[3]['status']);
96+
$this->assertEquals('attach', $ret[1]['status']);
97+
$this->assertEquals('kill', $ret[2]['status']);
98+
$this->assertEquals('die', $ret[3]['status']);
99+
$this->assertEquals('destroy', $ret[4]['status']);
94100
}
95101

96102
/**

0 commit comments

Comments
 (0)