view release on metacpan or search on metacpan
.claude/skills/docker-engine-api/SKILL.md view on Meta::CPAN
## The multiplexed stream â the one that looks like it works
`GET /containers/{id}/logs`, `/containers/{id}/attach` and
`POST /exec/{id}/start` return **frames, not text**, whenever the container was
created **without** a TTY:
```
[STREAM_TYPE, 0, 0, 0, SIZE1, SIZE2, SIZE3, SIZE4][payload of SIZE bytes]
```
`STREAM_TYPE` is 0 stdin, 1 stdout, 2 stderr. `SIZE` is a big-endian uint32.
Frames repeat until the stream ends. Measured against a container running
`echo OUT; echo ERR 1>&2`:
```
Tty=0: 01 00 00 00 00 00 00 04 "OUT\n" 02 00 00 00 00 00 00 04 "ERR\n"
Tty=1: "OUT\r\n" "ERR\r\n"
```
**With `Tty: true` the stream is raw** â no headers, and newlines arrive as
`\r\n` because a PTY is involved. That is the trap: a developer testing by hand
stray newline honestly (`encode_base64` wraps its output by
default), and flattening it keeps what the caller meant, while a
name is a literal the programmer wrote and rewriting
"X-Foo\r\nX-Bar" into "X-FooX-Bar" would put a header on the wire
under a name nobody asked for. The check also catches spaces and
colons, which corrupt the request without injecting anything.
- `containers->logs` and `exec->start` now demultiplex the Docker
stream format and return an ArrayRef of frames, each a HashRef with
`stream` and `data`:
[ { stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" } ]
Both used to hand the caller the framed bytes, so the 8-byte frame
header of every frame landed inside the log text. Measured against
the rootless Podman socket (5.4.2, API 1.41) with a container
running `echo OUT; echo ERR 1>&2`: without a TTY the body is
`01 00 00 00 00 00 00 04 "OUT\n" 02 00 00 00 00 00 00 04 "ERR\n"`,
and the same exec produces byte-identical output. With a TTY there
is no framing at all -- the body is `"OUT\r\n" "ERR\r\n"` -- which
is why hand-testing interactively never showed the defect. TTY
output comes back as one frame with `stream => 'raw'`, so the shape
never varies and `$_->{stream} eq 'stderr'` is safe on any frame.
Callers wanting plain text use
`join '', map { $_->{data} } @$frames`.
Framing is decided from the response bytes, not from `Content-Type`.
Measured on Podman: `GET /containers/{id}/logs` sends no
`Content-Type` whatsoever, for either kind of container, and
`POST /exec/{id}/start` sends
`application/vnd.docker.raw-stream` for both -- including the
non-TTY exec whose body is in fact multiplexed. Trusting that header
would put frame headers back into the caller's output on that
engine. Instead the body is walked as frames and is only treated as
lib/API/Docker/API/Containers.pm view on Meta::CPAN
);
}
sub logs {
my ($self, $id, %opts) = @_;
croak "Container ID required" unless $id;
my %params;
$params{follow} = $opts{follow} ? 1 : 0 if defined $opts{follow};
$params{stdout} = defined $opts{stdout} ? ($opts{stdout} ? 1 : 0) : 1;
$params{stderr} = defined $opts{stderr} ? ($opts{stderr} ? 1 : 0) : 1;
$params{since} = $opts{since} if defined $opts{since};
$params{until} = $opts{until} if defined $opts{until};
$params{timestamps} = $opts{timestamps} ? 1 : 0 if defined $opts{timestamps};
$params{tail} = $opts{tail} if defined $opts{tail};
# exists, not truth: an unset callback is a caller bug, and quietly falling
# back to the buffered path for it would answer a follow with a hang.
return $self->client->stream_frames('GET', "/containers/$id/logs",
params => \%params,
defined $opts{tty} ? ( tty => $opts{tty} ) : (),
%{ $self->_request_options },
lib/API/Docker/API/Containers.pm view on Meta::CPAN
# The pre-flight is a request the caller never wrote, and one that hangs is
# exactly what a bound was set to prevent -- so it carries the same one. It
# does that by itself here: the check runs on $self, which is the clone
# ->using returned when there was one (karr k74).
$self->_assert_container_running($id) if $require_running;
my %params;
$params{stream} = $opts{stream} ? 1 : 0;
$params{logs} = defined $opts{logs} ? ($opts{logs} ? 1 : 0) : 1;
$params{stdout} = defined $opts{stdout} ? ($opts{stdout} ? 1 : 0) : 1;
$params{stderr} = defined $opts{stderr} ? ($opts{stderr} ? 1 : 0) : 1;
$params{stdin} = $opts{stdin} ? 1 : 0 if defined $opts{stdin};
return $self->client->stream_frames('POST', "/containers/$id/attach",
params => \%params,
defined $opts{tty} ? ( tty => $opts{tty} ) : (),
%{ $self->_request_options },
exists $opts{on_frame} ? ( on_frame => $opts{on_frame} ) : (),
);
}
lib/API/Docker/API/Containers.pm view on Meta::CPAN
$docker->containers->start($result->{Id});
# Inspect container details
my $container = $docker->containers->inspect($result->{Id});
say $container->name;
# Stop and remove
$docker->containers->stop($result->{Id}, timeout => 10);
$docker->containers->remove($result->{Id});
# View logs (ArrayRef of { stream => 'stdout'|'stderr'|'raw', data => ... })
my $frames = $docker->containers->logs($result->{Id}, tail => 100);
my $text = join '', map { $_->{data} } @$frames;
# Attach one-way: replays the same frames and returns (stream => 0 by
# default -- stream => 1 on a stopped container never returns). On Podman,
# attaching to a container that has ALREADY EXITED destroys its exit
# status; use logs() for that case, see attach()
my $attached = $docker->containers->attach($result->{Id});
# Copy a file out, and a tar archive in (what docker cp is built on)
lib/API/Docker/API/Containers.pm view on Meta::CPAN
=item * C<volumes> - Remove associated volumes
=item * C<link> - Remove specified link
=back
=head2 logs
my $frames = $containers->logs($id, tail => 100, timestamps => 1);
# stdout and stderr, in the order the engine emitted them
my $text = join '', map { $_->{data} } @$frames;
# stderr only
my @errors = grep { $_->{stream} eq 'stderr' } @$frames;
Get container logs. Returns an ArrayRef of frames, each a HashRef with
C<stream> and C<data>:
[ { stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" } ]
A container created without a TTY multiplexes stdout and stderr into a single
framed stream, and this method demultiplexes it -- without that, the 8-byte
frame headers end up in the caller's log text. A container created B<with> a
TTY writes to one pty and the engine sends no frame headers, so its whole
output arrives as a single frame with C<< stream => 'raw' >>: with a TTY there
is no stdout/stderr distinction left to report. C<stream> is always a plain
string, so C<< $_->{stream} eq 'stderr' >> is safe on any frame.
Framing is detected from the response bytes, because the engine's
C<Content-Type> cannot be trusted for it -- see
L<API::Docker::Role::HTTP/"Detecting a framed stream"> for the rule and its one
failure mode.
Options:
=over
=item * C<follow> - Keep the connection open and send new output as the
container writes it. Only usable with C<on_frame>; see below
=item * C<stdout> - Include stdout (default 1)
=item * C<stderr> - Include stderr (default 1)
=item * C<since> - Show logs since timestamp
=item * C<until> - Show logs before timestamp
=item * C<timestamps> - Include timestamps
=item * C<tail> - Number of lines from end (e.g., C<100> or C<all>)
=item * C<tty> - Set to 1 when the container was created with a TTY and its
lib/API/Docker/API/Containers.pm view on Meta::CPAN
=head2 attach
my $frames = $containers->attach($id);
my $text = join '', map { $_->{data} } @$frames;
Attach to a container's streams and return everything they produced, as an
ArrayRef of frames in the same shape L</logs> returns:
[ { stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" } ]
A container created without a TTY multiplexes its output into one framed
stream, which this method demultiplexes; one created with a TTY arrives as a
single C<< stream => 'raw' >> frame. See L</logs> and
L<API::Docker::Role::HTTP/"Detecting a framed stream">.
B<The container must be running.> Attaching to one that has already exited
destroys its exit status on Podman, so this method checks first and croaks
rather than attaching -- read the output of a finished container with
L</logs>. Both halves of that are worth knowing before the call: see
lib/API/Docker/API/Containers.pm view on Meta::CPAN
connection -- the response carries no C<Content-Length> and no chunked
terminator -- so HTTP framing cannot signal the end either. The transport
reads until EOF, there is no EOF, and the call hangs. C<on_frame> does not
help: nothing will ever call C<< $stop->() >>.
Measured on Podman 5.4.2 (API 1.41), all four against one and the same
container:
=over
=item * C<?logs=1&stdout=1&stderr=1&stream=0>, exited container -- 200, the
frames, connection closed after 13 ms
=item * C<?logs=1&stdout=1&stderr=1&stream=1>, exited container -- 200, the
same frames, then hangs
=item * the same with C<Upgrade: tcp> -- 101 UPGRADED, the same frames, still
hangs
=item * C<?stream=1> while the container is still B<running> and exits three
seconds later -- closes cleanly after 3 s
=back
B<Docker does exactly the same, and that is measured now too.> Against Docker
29.7.2 (API 1.55): C<?logs=1&stdout=1&stderr=1&stream=1> on an exited
container was still open when a 10 s probe gave up, and
C<?logs=1&stdout=1&stderr=1&stream=0> answered 200 with byte-identical frames
and closed in half a millisecond. So the hang is not a Podman quirk to be
worked around -- it is what both engines do with a subscription whose only
terminator is already in the past, on an endpoint whose reference promises a
close in neither direction. It is unspecified behavior on both, which is the
case for the C<< stream => 0 >> default rather than an argument against it.
One more measured difference: Podman refuses C<< stream => 0 >> together with
C<< logs => 0 >> outright, with B<400> C<at least one of Logs or Stream must
be set>, rather than answering an empty 200.
lib/API/Docker/API/Containers.pm view on Meta::CPAN
L</"The defaults follow the engine">
=item * C<logs> - Replay what the container has already written. Default
B<1>, so the call returns something without subscribing; combined with
C<< stream => 1 >> the replay comes first and then transitions seamlessly
into the live output. C<< logs => 0 >> without C<< stream => 1 >> is the
combination the engine refuses (400 on Podman)
=item * C<stdout> - Attach stdout. Default 1 (engine default: false)
=item * C<stderr> - Attach stderr. Default 1 (engine default: false)
=item * C<stdin> - Attach stdin. Sent as asked, but nothing can be written to
it here; see above
=item * C<tty> - Set to 1 when the container was created with a TTY and its
output is binary, to skip demultiplexing. Same meaning as in L</logs>, and
with C<on_frame> the same promise
=item * C<on_frame> - CodeRef called with each frame as it arrives, instead of
the ArrayRef being collected and returned. Same contract as in L</logs>
lib/API/Docker/API/Exec.pm view on Meta::CPAN
=head2 start
my $frames = $exec->start($exec_id, Detach => 0);
my $output = join '', map { $_->{data} } @$frames;
Start an exec instance. Returns an ArrayRef of frames in the same shape as
L<API::Docker::API::Containers/logs>:
[ { stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" } ]
An exec instance created without a TTY multiplexes stdout and stderr into one
framed stream, which this method demultiplexes. One created with a TTY has no
frame headers and its output arrives as a single C<< stream => 'raw' >> frame.
A detached start produces no output, so it returns an empty ArrayRef.
The exit status is B<not> part of this response. It comes from a separate call
once the exec has finished:
my $exit = $exec->inspect($exec_id)->{ExitCode};
Options:
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
use namespace::clean;
requires 'host';
requires 'api_version';
requires 'tls';
requires 'cert_path';
requires 'tls_insecure';
# Docker stream frame types, indexed by the first byte of the frame header.
my @STREAM_TYPE = qw( stdin stdout stderr );
# A field name is an RFC 9110 token and nothing else. Anything outside this
# set -- CR, LF, a space, a colon -- is rejected rather than stripped; see
# _assert_header_name.
my $HEADER_NAME = qr/\A[0-9A-Za-z!#\$%&'*+.^_`|~-]+\z/;
# The request-target path is caller data -- a container name, an image
# reference -- spliced straight into the request line as /v$version$path, so a
# byte the line's own grammar reads rewrites the request rather than naming a
# resource: CR or LF ends the line, a space opens the HTTP-version field, and
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
=head2 stream_frames
my $frames = $client->stream_frames('GET', "/containers/$id/logs", %opts);
Perform a request against one of the engine's framed endpoints
(C<< /containers/{id}/logs >>, C<< /exec/{id}/start >>) and return an ArrayRef
of frames:
[ { stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" } ]
C<stream> is C<stdout>, C<stderr> or C<stdin> for a multiplexed stream, and
C<raw> for an unframed one. It is always a plain string, so callers never need
a defined-check. Joining the payloads gives the plain text:
my $text = join '', map { $_->{data} } @$frames;
The response body is never JSON-decoded, so a container printing JSON lines is
returned verbatim.
Options are those of C<_request> (C<params>, C<body>, C<headers>), plus:
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
=back
=head2 Following a framed stream
With C<on_frame> the frames are handed over as they arrive and the return
value is the summary HashRef described in L</"Streaming a response as it
arrives">, not an ArrayRef:
my $summary = $client->stream_frames('GET', "/containers/$id/logs",
params => { follow => 1, stdout => 1, stderr => 1 },
on_frame => sub {
my ($frame, $stop) = @_;
print $frame->{data};
$stop->() if $frame->{data} =~ /listening on/;
},
);
This is the only way to use C<< follow => 1 >> at all: without it the request
does not return until the container exits.
lib/API/Docker/Type/ContainerConfig.pm view on Meta::CPAN
docker user => Str;
docker attach_stdin => Bool;
docker attach_stdout => Bool;
docker attach_stderr => Bool;
docker exposed_ports => { Str, Any };
docker tty => Bool;
docker open_stdin => Bool;
lib/API/Docker/Type/ContainerConfig.pm view on Meta::CPAN
a colon (C<< <user-name|UID>[<:group-name|GID>] >>).
=head2 attach_stdin
Whether to attach to C<stdin>. The daemon defaults it to false.
=head2 attach_stdout
Whether to attach to C<stdout>. The daemon defaults it to true.
=head2 attach_stderr
Whether to attach to C<stderr>. The daemon defaults it to true.
=head2 exposed_ports
An object mapping ports to an empty object in the form:
C<< {"<port>/<tcp|udp|sctp>": {}} >> B<The keys are the caller's data> and
are never translated.
=head2 tty
t/containers_endpoints.t view on Meta::CPAN
# here, so this is no longer a difference between the two engines. The
# fixture below is kept as the 5.4.2 capture rather than recaptured: nothing
# in this file asserts uname/gname (only length, the ustar magic, the member
# name and byte-exact roundtrip through the transport are checked), so the
# 5.4.2 bytes still exercise exactly what this file tests.
my $TAR = load_fixture_raw('containers_archive.tar');
# The one-way attach stream is byte-identical to the logs stream, which is the
# whole claim of karr k19 -- and now measured, not just documented: karr k36
# attached live to an apidocker-fixture-attach-live container across its run
# (POST .../attach?stream=1&stdout=1&stderr=1, connected before the container
# started so the daemon had output to send) and diffed the bytes against
# GET .../logs?stdout=1&stderr=1 on an equivalent run; both came back as this
# same 24-byte frame pair, byte for byte. This is the captured logs fixture
# rather than a second file holding the same bytes: it is real engine output,
# and a copy made by hand would only look like one.
#
# A related hazard the measurement also turned up: attaching with stream=1 to
# a container that has *already* exited still sends the same 24 bytes, but
# Podman never closes the connection afterward -- no Content-Length, no
# chunked encoding, and no close even when the client sends Connection: close
# itself, which _request always does. Reading blocks until EOF, so that call
# hangs forever.
#
# karr k52 narrowed that down: it is stream=1 that hangs, not attach as such.
# Re-measured on Podman 5.4.2 (API 1.41) against one exited container:
# ?logs=1&stdout=1&stderr=1&stream=0 answers 200, sends the 24 bytes and
# closes after 13ms; the same request with stream=1 sends the identical bytes
# and hangs; with Upgrade: tcp it answers 101 UPGRADED and hangs the same
# way; and stream=1 against a container still *running*, which exits three
# seconds later, closes cleanly after 3s. The spec explains it -- stream is
# "from the time the request was made onwards" and its only terminator is the
# container ending, which for a stopped container already happened. So this
# client now follows the engine's own default of stream=0 and defaults logs=1
# instead. Docker was unverified when this was written; it has since been
# measured (29.7.2, API 1.55) and hangs identically, so the hang is not a
# Podman quirk but behaviour the reference leaves unspecified for both.
t/containers_endpoints.t view on Meta::CPAN
# is about. The check itself is covered further down.
my $docker = test_docker(
'POST /containers/deadbeef/attach' => sub { $FRAMES },
'GET /containers/deadbeef/logs' => sub { $FRAMES },
'GET /containers/deadbeef/json' => { State => { Running => 1 } },
);
my $attached = $docker->containers->attach('deadbeef');
is_deeply $attached, [
{ stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" },
], 'two frames, headers stripped';
is_deeply $attached, $docker->containers->logs('deadbeef'),
'the same bytes give the same frames through either method';
is join('', map { $_->{data} } @$attached), "OUT\nERR\n",
'joining the payloads gives the plain text';
};
subtest 'attach: the query parameters, and the defaults that differ from the engine' => sub {
my $t = fake_client();
$t->canned([200, 'OK', {}, $FRAMES]);
# The default that matters: stream=0, the engine's own default, plus logs=1
# so the call still has something to return. Measured on Podman 5.4.2 (API
# 1.41) against one exited container: ?logs=1&stdout=1&stderr=1&stream=0
# answers 200 and closes after 13ms, while the same request with stream=1
# sends the identical 24 bytes and then never closes -- attach hijacks the
# connection, so there is no Content-Length and no chunked terminator, and
# stream's only terminator (the container ending) is already in the past.
# Turning stream back on by default puts every attach() on a stopped
# container back into that hang, which is what this assertion guards.
# require_running => 0 throughout: this subtest is about the query string,
# and the running-container check would otherwise put a GET .../json between
# the call and the request line being asserted. That it does not appear here
# is itself the point -- opting out skips the round trip rather than making
# it and ignoring the answer.
$t->containers->attach('deadbeef', require_running => 0);
is $t->request_line,
'POST /v1.41/containers/deadbeef/attach?logs=1&stderr=1&stdout=1&stream=0 HTTP/1.1',
'stream defaults OFF as the engine does, logs defaults ON so the call replays';
$t->containers->attach('deadbeef', require_running => 0,
stream => 1, stdout => 0, stderr => 0, stdin => 1, logs => 0);
is $t->request_line,
'POST /v1.41/containers/deadbeef/attach?logs=0&stderr=0&stdin=1&stdout=0&stream=1 HTTP/1.1',
'every one of the five is sent as asked, false as 0';
$t->containers->attach('deadbeef', stdin => 0, require_running => 0);
is $t->request_line,
'POST /v1.41/containers/deadbeef/attach?logs=1&stderr=1&stdin=0&stdout=1&stream=0 HTTP/1.1',
'stdin appears only when named; a false one is still sent';
# logs => 0 alone leaves both flags off, which the engine refuses outright:
# Podman answers 400 "at least one of Logs or Stream must be set". The
# client passes it through rather than second-guessing it.
$t->containers->attach('deadbeef', logs => 0, require_running => 0);
is $t->request_line,
'POST /v1.41/containers/deadbeef/attach?logs=0&stderr=1&stdout=1&stream=0 HTTP/1.1',
'logs => 0 alone is sent as asked -- the both-off 400 is the engine\'s call';
# require_running is a client-side option and must not reach the engine as
# one: the engine has no such query parameter and would ignore it silently.
unlike $t->request_line, qr/require_running/,
'require_running is consumed here, never sent as a query parameter';
my $err = do { local $@; eval { $t->containers->attach }; $@ };
like $err, qr/Container ID required/, 'a missing id croaks';
};
t/containers_endpoints.t view on Meta::CPAN
my $docker = test_docker(
'GET /containers/deadbeef/json' => sub {
$called{inspect}++;
return { State => { Running => 0, Status => 'exited' } };
},
'POST /containers/deadbeef/attach' => sub { $called{attach}++; return $FRAMES },
);
is_deeply $docker->containers->attach('deadbeef', require_running => 0), [
{ stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" },
], 'the frames come back from a stopped container when the check is off';
is $called{attach}, 1, 'the attach request went out';
ok !$called{inspect},
'and no inspect was made -- opting out skips the check, not just its verdict';
};
subtest 'attach: the check reads State.Running, and fails open when it cannot' => sub {
plan skip_all => 'route assertions are fixture-only' if is_live();
t/entity_container.t view on Meta::CPAN
);
my ($c) = @{ $docker->containers->list };
$c->kill(signal => 'SIGTERM');
is_deeply $seen{kill}, { signal => 'SIGTERM' },
"kill reached POST /containers/deadbeef/kill under the entity's own id";
is_deeply $c->logs, [
{ stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" },
], 'logs reached GET /containers/deadbeef/logs and demultiplexed the frames';
is $c->pause, 1, 'pause reached POST /containers/deadbeef/pause';
ok $seen{pause}, 'and was actually requested';
is $c->restart, 1, 'restart reached POST /containers/deadbeef/restart';
ok $seen{restart}, 'and was actually requested';
is_deeply $c->top, { Titles => [], Processes => [] },
'top reached GET /containers/deadbeef/top';
t/stream_frames.t view on Meta::CPAN
'0100000000000004' . '4f55540a' . '0200000000000004' . '4552520a',
'multiplexed log fixture: 01 header + "OUT\n", 02 header + "ERR\n"';
is $TTY, "OUT\r\nERR\r\n", 'tty log fixture has no headers and CRLF endings';
is $EXEC, $MULTIPLEXED, 'exec/start frames are byte-identical to logs frames';
};
subtest 'demultiplexing a framed stream' => sub {
my $frames = $client->_demux_frames($MULTIPLEXED);
is_deeply $frames, [
{ stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" },
], 'two frames, stdout then stderr, headers stripped';
is join('', map { $_->{data} } @$frames), "OUT\nERR\n",
'joining the payloads gives the plain text';
};
subtest 'raw (TTY) output is not mistaken for frames' => sub {
is $client->_demux_frames($TTY), undef,
'text pty output does not walk as frames';
is $client->_demux_frames($TTY_JSON), undef,
'JSON pty output does not walk as frames';
t/stream_frames.t view on Meta::CPAN
SKIP: {
skip 'mock routes are bypassed in live mode', 3 if is_live();
subtest 'containers->logs demultiplexes' => sub {
my $docker = test_docker(
'GET /containers/deadbeef/logs' => sub { $MULTIPLEXED },
);
my $frames = $docker->containers->logs('deadbeef');
is_deeply $frames, [
{ stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" },
], 'frames, not header bytes';
};
subtest 'containers->logs on a TTY container' => sub {
my $docker = test_docker(
'GET /containers/deadbeef/logs' => sub { $TTY },
);
my $frames = $docker->containers->logs('deadbeef');
is_deeply $frames, [ { stream => 'raw', data => "OUT\r\nERR\r\n" } ],
'one raw frame; stream is a plain string, never undef';
t/stream_frames.t view on Meta::CPAN
[ { stream => 'raw', data => $MULTIPLEXED } ],
'tty => 1 suppresses demultiplexing';
};
subtest 'exec->start demultiplexes' => sub {
my $docker = test_docker(
'POST /exec/abc123/start' => sub { $EXEC },
);
is_deeply $docker->exec->start('abc123', Detach => 0), [
{ stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" },
], 'same frame shape as logs';
my $empty = test_docker(
'POST /exec/abc123/start' => sub { undef },
);
is_deeply $empty->exec->start('abc123', Detach => 1), [],
'a detached start produces no frames';
};
}
t/stream_frames.t view on Meta::CPAN
});
$docker->containers->start($id);
$docker->containers->wait($id);
my $frames = $docker->containers->logs($id);
is ref $frames, 'ARRAY', 'logs returns an ArrayRef';
ok scalar(@$frames) >= 1, 'at least one frame';
my %seen = map { $_->{stream} => 1 } @$frames;
ok $seen{stdout}, 'stdout frame present';
ok $seen{stderr}, 'stderr frame present';
like join('', map { $_->{data} } @$frames), qr/OUT/,
'payload carries the text';
unlike join('', map { $_->{data} } @$frames), qr/\x00\x00\x00/,
'no frame header bytes leaked into the payload';
};
}
done_testing;
t/streaming_callback.t view on Meta::CPAN
# so a reader that expected a header to arrive whole gets nothing right.
my @pieces = ($body =~ /(.{1,5})/gs);
my $client = transport(chunked(\@pieces, closed => 1), step => 3);
my @got;
my $summary = $client->stream_frames('GET', '/containers/x/logs',
on_frame => sub { push @got, $_[0] });
is_deeply \@got, [
{ stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" },
], 'demultiplexed exactly as the buffered path demultiplexes it';
is_deeply $summary, { delivered => 2, stopped => 0 }, 'two frames, ran out';
};
subtest 'on_frame: the callback stops between frames' => sub {
my $body = $FIXTURES->child('containers_logs_multiplexed.bin')->slurp_raw;
my $client = transport(chunked([$body]), step => 4, at_end => 'die');
my @got;
my $summary = eval {
t/streaming_methods.t view on Meta::CPAN
. 'against a followed log is a process that never comes back';
};
subtest 'containers->logs: the buffered call is unchanged' => sub {
my $body = $FIXTURES->child('containers_logs_multiplexed.bin')->slurp_raw;
my $docker = client(chunked([$body], closed => 1), step => 5);
my $frames = $docker->containers->logs('abc', tail => 100);
is_deeply $frames, [
{ stream => 'stdout', data => "OUT\n" },
{ stream => 'stderr', data => "ERR\n" },
], 'no callback, no follow: the ArrayRef of frames as before';
unlike $docker->request_line, qr/follow/,
'and follow is not sent unless it was asked for';
};
subtest 'system->events: unbounded, and it comes back' => sub {
my @lines = ndjson_lines('system_events_stream.ndjson');
my $docker = client(chunked(\@lines), step => 17, at_end => 'die');
my @got;
t/streaming_methods.t view on Meta::CPAN
on_frame => sub {
my ($frame, $stop) = @_;
push @got, $frame;
$stop->();
},
);
};
is $@, '', 'returned at the first frame instead of waiting for the exit';
is scalar(@got), 1, 'one frame';
ok $got[0]{stream} =~ /\A(?:stdout|stderr|stdin)\z/,
'demultiplexed, so the 8-byte headers did not reach the caller';
is_deeply $summary, { delivered => 1, stopped => 1 }, 'summary back';
};
subtest 'images->build: progress as it arrives, and the failure croaks early' => sub {
my @lines = ndjson_lines('images_build_error_stream.ndjson');
# at_end => 'die': a transport that kept collecting after the failing event
# would run off the end of the script rather than croaking on the spot.
my $docker = client(chunked(\@lines), step => 9, at_end => 'die');
t/streaming_methods.t view on Meta::CPAN
'plugins->push' => sub {
my ($cb) = @_;
test_docker('POST /plugins/sshfs/push' => \@progress)
->plugins->push('sshfs', on_event => $cb);
},
'containers->attach' => sub {
my ($cb) = @_;
test_docker('POST /containers/abc/attach' => [
{ stream => 'stdout', data => "one\n" },
{ stream => 'stdout', data => "two\n" },
{ stream => 'stderr', data => "three\n" },
# require_running => 0: this case is about the callback contract, not
# about attach's running-container check, and opting out keeps the route
# table down to the one endpoint under test.
])->containers->attach('abc', on_frame => $cb, require_running => 0);
},
);
for my $name (sort keys %case) {
my @got;
my $summary = $case{$name}->(sub {