API-Docker

 view release on metacpan or  search on metacpan

.claude/agents/api-docker-engine-worker.md  view on Meta::CPAN

---
name: api-docker-engine-worker
description: "Docker Engine API specialist for API::Docker — use whenever the question is what the daemon does or expects: adding or correcting an endpoint, query-parameter and filter semantics, response shapes (204/304, NDJSON event streams, error...
model: inherit
allowed-tools: Read, Edit, Write, Bash, Glob, Grep
briefing:
  skills:
    - docker-engine-api
    - api-docker-core
    - getty-perl-core
    - getty-perl-moo
    - getty-perl-release-author-getty
    - getty-git-commit-style

.claude/agents/api-docker-engine-worker.md  view on Meta::CPAN

over rather than drifting into it. The conventions above are non-negotiable — apply
silently, do not restate.

Coordinate via `karr`: pick tickets from the local board, record drift you find as new
tickets rather than expanding scope mid-change.

## What the client currently does not model

Verified against the rootless Podman socket on this machine, not deduced:

- **`containers->logs` returns frames, unparsed.** A container created without a TTY
  produces `01 00 00 00 00 00 00 04 "OUT\n" 02 00 00 00 00 00 00 04 "ERR\n"`, and the
  method hands those header bytes to the caller as if they were log text. With
  `Tty => \1` the same container produces `"OUT\r\n" "ERR\r\n"` and looks fine — which is
  why nothing has caught it. `exec->start` with `Detach => 0` has the same problem, and
  there is no `attach` method at all.
- **`exec->start` never surfaces the exit status.** It comes from a separate
  `GET /exec/{id}/json`, which this client does expose as `exec->inspect` — a caller has
  no way to know that from the method's POD.
- **Nothing checks `errorDetail`.** `build`, `pull` and `push` return the event list and
  leave failure detection to the caller, while the HTTP status was 200.

Fixing any of these changes a public return shape. `../p5-dist-zilla-plugin-docker-api`
consumes `images->build`, `->tag`, `->push` and `->inspect` — verify it, or file a ticket
on its board, before landing.

## Working method

Measure, don't assume. The daemon is reachable at
`unix:///run/user/1000/podman/podman.sock` (Podman, API 1.41 — there is no Docker on this
machine). `curl --unix-socket <sock> http://localhost/v1.41/...` shows the raw stream
including frame headers, which is the fastest way to confirm a wire format before writing
code against it.

Podman is a reimplementation: anything beyond the documented surface — event payload
fields, healthcheck details, error message text — is unverified until you have measured
it there, and a difference from Docker is worth writing into the `Changes` entry.

New endpoint methods follow the existing shape: options normalised into `%params`,
`list`/`inspect` wrapped into entity objects, everything else returned raw, POD with an
`=item * C<name> - meaning` per accepted key. A behavior change gets a `Changes` entry
under `{{$NEXT}}` that states what was measured.

.claude/agents/api-docker-test-writer.md  view on Meta::CPAN

- **Decode exactly what the engine would receive.** The push-auth helper used to append
  the missing base64 padding before decoding and so passed with and without the defect
  it existed to catch. Never normalise the value under test on the way into the
  assertion.
- **Live and mock must both be able to pass, or the assertion is gated.** `test_docker`
  ignores the route table entirely under `API_DOCKER_TEST_HOST` — an assertion tied to
  fixture contents runs against a real daemon's data otherwise.

New fixtures are captured from a real daemon into `t/fixtures/*.json`, never hand-rolled
— and that includes wire formats. A test for the multiplexed log stream asserts against
bytes the engine actually produced, not against a frame header written from memory; the
Engine API reference above tells you what to expect, the socket tells you what is true.
Test filenames follow the existing flat, topical naming (`t/images.t`,
`t/images_push_auth.t`), one file per resource or per defect.

A test asserts intent: it must be able to fail when the logic changes. Reproduce a bug
before fixing it and leave the regression behind. Verify with `prove -lr t/`; a single
file with `prove -lv t/NN.t`.

.claude/rules/api-docker-rules.md  view on Meta::CPAN

- **There is no Docker on this machine — only rootless Podman.**
  `unix:///var/run/docker.sock` does not exist here, and `check_live_access` answers a
  missing socket with `skip_all`. A live run pointed at the default therefore reports
  success while testing nothing. The real endpoint is
  `API_DOCKER_TEST_HOST=unix:///run/user/1000/podman/podman.sock` (announces API 1.41).
- **The suite is not green against a live daemon.** `t/system.t`'s `events` subtest
  asserts `ref eq 'ARRAY'` outside its `is_live()` guard; a real daemon with no events in
  the requested window returns an empty body, `_request` returns undef, and the test
  fails. It is ticketed — do not rediscover it as a new finding and do not fix it
  opportunistically.
- **`containers->logs` hands frame headers to the caller.** A container created without
  a TTY produces an 8-byte-framed stream (`01 00 00 00 00 00 00 04` + payload, stream
  type in byte 0, big-endian length in bytes 4-7) and the method returns it undecoded;
  with a TTY the stream is raw and looks correct, so hand-testing interactively hides it.
  `exec->start` shares the problem and there is no `attach` at all. Ticketed — it is a
  public return-shape change, not a passing fix.
- **Live write tests mutate the real engine.** `API_DOCKER_TEST_WRITE=1` creates and
  removes actual containers, images, networks and volumes; cleanup runs in an `END`
  block, so an interrupted run leaves them behind. Run only when the task is about live
  behavior.
- **`images->push` publishes.** With credentials it writes to a real registry under the
  maintainer's account. Never run it — nor any test that does — without explicit

.claude/skills/docker-engine-api/SKILL.md  view on Meta::CPAN

  `{"status":…,"progress":…}`, `{"aux":{"ID":…}}`, `{"errorDetail":{…}}`.

**A failed build, pull or push is still HTTP 200.** The failure arrives as an
`errorDetail` object inside the stream, after the daemon has already committed
to a successful status line. Any client that treats HTTP status as the verdict
reports a broken build as a success. Scan the events.

## 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`:

.claude/skills/docker-engine-api/SKILL.md  view on Meta::CPAN

`\r\n` because a PTY is involved. That is the trap: a developer testing by hand
reaches for an interactive container, sees clean text, and ships a client that
emits header bytes into the caller's log output for every non-TTY container —
which is every container a program actually runs. Demultiplex by reading eight
bytes, taking the length, reading that many payload bytes, repeating. Go clients
get this from `stdcopy.StdCopy`; everyone else writes it.

`attach` and `exec/start` additionally accept `Upgrade: tcp` +
`Connection: Upgrade`, to which the daemon answers **101 Switching Protocols**
and hands over a bidirectional connection. Without those headers it answers 200
and streams the same frames one-way.

## Filters are JSON, and the shape is specific

`filters` is a query parameter holding a JSON-encoded **map of string to array
of string** — the values are arrays of *strings*, even for booleans:

```
?filters={"dangling":["true"],"label":["stage=build"]}     correct
?filters={"dangling":true}                                 matches nothing
```

.claude/skills/docker-engine-api/SKILL.md  view on Meta::CPAN


`exec` is two calls: `POST /containers/{id}/exec` creates the instance and
returns an `Id`, `POST /exec/{id}/start` runs it. The exit status comes from
`GET /exec/{id}/json` afterwards (`ExitCode`), never from the start call.

## Other engines

Podman serves this API on a compat socket — enable with
`systemctl --user enable --now podman.socket`, reach it at
`unix://$XDG_RUNTIME_DIR/podman/podman.sock`, and it announces API 1.41.
Multi-stage builds including `target` pass through unchanged, and the frame
format above is byte-identical. It is a reimplementation, not Docker: treat
anything beyond the documented surface — event payload details, healthcheck
fields, error message text — as unverified until measured against the engine
you actually target.

Clients differ in how they *find* the daemon: `DOCKER_HOST` is the one
mechanism all of them honour. Docker contexts
(`~/.docker/config.json` `currentContext` plus
`~/.docker/contexts/meta/*/meta.json`) are resolved by the `docker` CLI and
docker-java but not by most library clients, so "works in the terminal, fails
in my program" usually means a context the program never read.

## Probing by hand

```bash
curl --unix-socket /var/run/docker.sock http://localhost/v1.47/containers/json
curl --unix-socket /var/run/docker.sock -X POST \
  'http://localhost/v1.47/images/create?fromImage=alpine&tag=3'
```

`curl` writes the raw stream, frame headers included — that is the fastest way
to confirm what a client should be seeing before blaming the client.

.claude/skills/getty-perl-core/SKILL.md  view on Meta::CPAN


Both are active in **every** file, without exception. How they get there differs:

- **Scripts, tests, plain modules:** `use strict; use warnings;` explicitly.
- **Classes:** `Moo`, `Moose`, `Catalyst` and anything derived from them enable both on import — do not repeat them there.

The rule is "always on", never "leave them out". Omitting them from a class is correct only because the object system already did it.

## Object system

- **One object system per distribution.** Pick Moo or Moose and use it everywhere; mixing is for boundaries a framework forces (e.g. RapidApp), not a choice.
- **`is => 'ro'` is the default.** `rw` is the exception and needs a reason.
- **`lazy_build => 1` + `sub _build_foo`** over `default => sub { ... }` for anything non-trivial.
- **`weak_ref => 1`** on attributes holding a reference back to a parent/owner — standard for nested object graphs, prevents circular refs.
- **`namespace::autoclean`** on every class file. Classes extending DBIx::Class (`MooseX::NonMoose`) use **`MooseX::MarkAsMethods autoclean => 1`** instead.
- **`no Moose;` + `__PACKAGE__->meta->make_immutable;`** at the bottom of every Moose class.
- **Types:** the tendency is to type what arrives from outside — Moose's own constraints where Moose is already there, `Types::Standard` where it is not. Not every distribution needs a type system, and none needs one for every field: `getty-perl-ty...

## Singletons

- **`->instance`** for `MooseX::Singleton` / `MooX::Singleton` classes. Never `->new` on a singleton.

Changes  view on Meta::CPAN

    reachable from this distribution -- the one caller, `push`, passes
    the literal `X-Registry-Auth` -- but the option is public. Names are
    rejected rather than stripped, unlike values: a value can pick up a
    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
    framed when the walk consumes it exactly; the one way to fool it,
    and the `tty => 1` option that overrides it, are documented on
    `API::Docker::Role::HTTP::stream_frames`.
    `exec->start` also gained POD saying where the exit status actually
    comes from -- `exec->inspect($id)->{ExitCode}`, a separate call --
    which the method's documentation never mentioned.
  - `images->build`, `->pull` and `->push` now always return an ArrayRef
    of events. `_request` used to try `decode_json` on the whole body
    first and only fall back to line-by-line parsing, so a stream that
    carried exactly one JSON object came back as a HashRef while a
    multi-event stream came back as an ArrayRef, and every caller had to
    check `ref` before iterating. Measured on Podman: `POST /build?q=1`
    emits exactly one object, which is the case that used to change

MANIFEST  view on Meta::CPAN

t/fixtures/system_info.json
t/fixtures/system_version.json
t/fixtures/volumes_list.json
t/images.t
t/images_push_auth.t
t/lib/Test/API/Docker/Mock.pm
t/networks.t
t/release-changes_has_content.t
t/role_http.t
t/stream_error.t
t/stream_frames.t
t/streaming_shape.t
t/system.t
t/tls.t
t/version.t
t/volumes.t

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{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};
  return $self->client->stream_frames('GET', "/containers/$id/logs",
    params => \%params,
    defined $opts{tty} ? ( tty => $opts{tty} ) : (),
  );
}


sub top {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  my %params;

lib/API/Docker/API/Containers.pm  view on Meta::CPAN


    # 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;

=head1 DESCRIPTION

This module provides methods for managing Docker containers including creation,
lifecycle operations (start, stop, restart), inspection, logs, and more.

All C<list> and C<inspect> methods return L<API::Docker::Container> objects
for convenient access to container properties and operations.

Accessed via C<< $docker->containers >>.

lib/API/Docker/API/Containers.pm  view on Meta::CPAN

=item * C<force> - Force removal (kill if running)

=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<stdout> - Include stdout (default 1)

=item * C<stderr> - Include stderr (default 1)

lib/API/Docker/API/Exec.pm  view on Meta::CPAN

}


sub start {
  my ($self, $exec_id, %opts) = @_;
  croak "Exec ID required" unless $exec_id;
  my $body = {
    Detach => $opts{Detach} ? \1 : \0,
    Tty    => $opts{Tty}    ? \1 : \0,
  };
  return $self->client->stream_frames('POST', "/exec/$exec_id/start",
    body => $body,
    $opts{Tty} ? ( tty => 1 ) : (),
  );
}


sub resize {
  my ($self, $exec_id, %opts) = @_;
  croak "Exec ID required" unless $exec_id;
  my %params;

lib/API/Docker/API/Exec.pm  view on Meta::CPAN


    my $docker = API::Docker->new;

    # Create an exec instance
    my $exec = $docker->exec->create($container_id,
        Cmd         => ['/bin/sh', '-c', 'echo hello'],
        AttachStdout => 1,
        AttachStderr => 1,
    );

    # Start the exec -- ArrayRef of { stream => ..., data => ... } frames
    my $frames = $docker->exec->start($exec->{Id});
    my $output = join '', map { $_->{data} } @$frames;

    # The exit status comes from a separate call
    my $exit = $docker->exec->inspect($exec->{Id})->{ExitCode};

    # Inspect exec instance
    my $info = $docker->exec->inspect($exec->{Id});

=head1 DESCRIPTION

This module provides methods for executing commands inside running containers

lib/API/Docker/API/Exec.pm  view on Meta::CPAN


Create an exec instance. Returns hashref with C<Id>.

Required config: C<Cmd> (ArrayRef of command and arguments).

Common config keys: C<AttachStdin>, C<AttachStdout>, C<AttachStderr>, C<Tty>,
C<Env>, C<User>, C<WorkingDir>.

=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:

=over

=item * C<Detach> - Run detached; the engine returns immediately and no output
is streamed

=item * C<Tty> - Declares that this exec instance was created with a TTY. It is
sent in the request body, where the engine expects it to match the C<Tty> given
to L</create>, and it also suppresses demultiplexing of the response. Framing is
otherwise detected from the response bytes -- see
L<API::Docker::Role::HTTP/"Detecting a framed stream">

=back

=head2 resize

    $exec->resize($exec_id, h => 40, w => 120);

Resize the TTY for an exec instance. Options: C<h> (height), C<w> (width).

=head2 inspect

lib/API/Docker/Error/Stream.pm  view on Meta::CPAN


=head2 events

ArrayRef of every event decoded from the stream, in order, the C<errorDetail>
event included. This is the progress output the caller would otherwise lose
by never receiving a return value.

=head2 location

Carp's location suffix (C< at FILE line N.\n>), captured at the point the
error was raised so it names the same frame a plain C<croak> would have named.
Kept apart from L</message> so a caller can have the reason without it.

=head2 as_string

    my $text = $err->as_string;   # same as "$err"

The message and the location suffix, concatenated. This is what the
stringification overload returns.

=head1 SEE ALSO

lib/API/Docker/Role/HTTP.pm  view on Meta::CPAN

use JSON::MaybeXS qw( encode_json decode_json );
use Carp qw( croak shortmess );
use Log::Any qw( $log );
use API::Docker::Error::Stream;
use namespace::clean;


requires 'host';
requires 'api_version';

# 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/;

has _socket => (
  is      => 'lazy',
  clearer => '_clear_socket',

lib/API/Docker/Role/HTTP.pm  view on Meta::CPAN

        $error_msg = $data->{message} // $detail // $data->{error} // $body;
      };
    }
    croak "Docker API error ($status_code): $error_msg";
  }

  if ($status_code == 204 || !defined($body) || $body eq '') {
    return undef;
  }

  # The framed endpoints (logs, attach, exec/start) carry arbitrary bytes
  # that must not be mistaken for JSON -- a TTY container printing a JSON
  # line would otherwise come back decoded.
  return $body if $opts{raw};

  # Streaming endpoints (/build, /images/create, /images/*/push) always
  # return an ArrayRef of events, even when the stream carried exactly one
  # object.  See _decode_stream.
  if ($opts{ndjson}) {
    my $events = $self->_decode_stream($body);
    # A failed build, pull or push is HTTP 200 with the failure buried in the

lib/API/Docker/Role/HTTP.pm  view on Meta::CPAN


  # Newline-delimited JSON: one object per line.  A literal newline cannot
  # occur inside a JSON string, so splitting on lines is safe.
  my @events;
  for my $line (split /\r?\n/, $body) {
    next unless $line =~ /\S/;
    my $event = eval { decode_json($line) };
    push @events, $event if defined $event;
  }

  # Fall back to the whole body for a stream that is not newline-framed
  # (a single pretty-printed object), so nothing is silently dropped.
  unless (@events) {
    my $event = eval { decode_json($body) };
    push @events, $event if defined $event;
  }

  return \@events;
}

sub _assert_no_stream_error {

lib/API/Docker/Role/HTTP.pm  view on Meta::CPAN

    # trigger -- the trigger is errorDetail, and nothing else.
    my $reason = ref $detail eq 'HASH' ? $detail->{message} : undef;
    $reason = $event->{error}   unless defined $reason && length $reason;
    $reason = 'no message given' unless defined $reason && length $reason;
    # Engine messages end in a newline, and Carp appends no location to a
    # message that already does.
    $reason =~ s/\s+\z//;

    # Carp hands a reference straight back rather than decorating it, so this
    # croak is a die with an object -- hence the location captured by hand,
    # which names the same frame a croak of a plain string would have named.
    # The object goes into a variable first: `croak CLASS->new(...)` is
    # indirect object syntax and parses as CLASS->croak(new(...)).
    my $error = API::Docker::Error::Stream->new(
      message  => 'Docker API stream error (' . $endpoint . '): ' . $reason,
      events   => $events,
      location => shortmess(''),
    );
    croak $error;
  }

lib/API/Docker/Role/HTTP.pm  view on Meta::CPAN

  return $self->_request('PUT', $path, %opts);
}


sub delete_request {
  my ($self, $path, %opts) = @_;
  return $self->_request('DELETE', $path, %opts);
}


sub stream_frames {
  my ($self, $method, $path, %opts) = @_;

  my $tty = delete $opts{tty};
  my $body = $self->_request($method, $path, %opts, raw => 1);

  return [] unless defined $body && length $body;

  my $frames = $tty ? undef : $self->_demux_frames($body);

  return $frames if $frames;
  return [ { stream => 'raw', data => $body } ];
}


sub _demux_frames {
  my ($self, $body) = @_;

  my $len = length $body;
  my $pos = 0;
  my @frames;

  while ($pos < $len) {
    return undef if $len - $pos < 8;
    my ($type, $pad1, $pad2, $pad3, $size) = unpack 'C4 N', substr($body, $pos, 8);
    return undef if $type > $#STREAM_TYPE;
    return undef if $pad1 || $pad2 || $pad3;
    return undef if $len - $pos - 8 < $size;
    push @frames, {
      stream => $STREAM_TYPE[$type],
      data   => substr($body, $pos + 8, $size),
    };
    $pos += 8 + $size;
  }

  return undef unless @frames;
  return \@frames;
}


1;

__END__

=pod

=encoding UTF-8

lib/API/Docker/Role/HTTP.pm  view on Meta::CPAN


=item * TCP socket transport (C<tcp://host:port>)

=item * HTTP/1.1 chunked transfer encoding

=item * Automatic JSON encoding/decoding

=item * Newline-delimited JSON event streams (C<< ndjson => 1 >>), including
the failures the engine reports inside an HTTP 200 body

=item * Demultiplexing of the Docker stream format (L</stream_frames>)

=item * Request/response logging via L<Log::Any>

=item * Automatic connection management

=back

Consuming classes must provide C<host> and C<api_version> attributes.

B<Both transports are plaintext.> There is no TLS here: a C<tcp://> host is

lib/API/Docker/Role/HTTP.pm  view on Meta::CPAN

Options: C<params> (hashref of query parameters).

=head2 delete_request

    my $data = $client->delete_request($path, %opts);

Perform HTTP DELETE request.

Options: C<params> (hashref of query parameters).

=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:

=over

=item * C<tty> - Skip demultiplexing and return the body as a single C<raw>
frame. Set it when the container or exec instance was created with a TTY and
its output is binary; see L</"Detecting a framed stream"> for why.

=back

=head2 Detecting a framed stream

A container created without a TTY produces the Docker stream format -- an
8-byte header per frame (byte 0 the stream type, bytes 4-7 a big-endian uint32
payload length) followed by that many payload bytes. With a TTY there is no
header and the payload is raw pty output.

The engine is supposed to distinguish the two with the response C<Content-Type>
(C<application/vnd.docker.multiplexed-stream> against
C<application/vnd.docker.raw-stream>), but that signal is not dependable.
Measured against Podman 5.4.2 (API 1.41): C<< GET /containers/{id}/logs >>
sends no C<Content-Type> at all, for either kind of container, and
C<< POST /exec/{id}/start >> sends C<application/vnd.docker.raw-stream> for
both -- including the non-TTY exec whose body is in fact multiplexed. Trusting
the header would therefore hand frame headers to the caller on that engine.

The framing is decided from the bytes instead. The body is walked as frames:
each header must have a stream type of 0, 1 or 2, three zero bytes after it,
and a payload length that leaves at least that many bytes in the buffer. The
body is treated as framed only when the walk consumes it exactly and yields at
least one frame; anything else is returned as a single C<raw> frame.

This can be fooled in one direction only. Raw TTY output is misread as framed
if it begins with a byte no greater than C<0x02>, followed by three NUL bytes
and a length that happens to chain exactly to the end of the body. Text output
cannot do that -- a printable character is C<0x20> or above -- so it takes
binary output from a TTY-allocated container. Pass C<< tty => 1 >> for that
case. The reverse mistake cannot happen silently: a genuine frame stream is
only ever reported as raw when its final frame is truncated, which needs the
daemon to close the connection mid-frame.

=head1 SEE ALSO

=over

=item * L<API::Docker> - Main client using this role

=item * L<API::Docker::Error::Stream> - Raised for a failure reported inside
a 200 event stream

t/lib/Test/API/Docker/Mock.pm  view on Meta::CPAN


my @_cleanups;

sub load_fixture {
  my ($name) = @_;
  my $file = $FIXTURES_DIR->child("$name.json");
  croak "Fixture not found: $file" unless $file->exists;
  return decode_json($file->slurp_utf8);
}

# Some fixtures are not JSON: the framed log/exec streams are captured
# engine bytes, and the build/pull event streams are newline-delimited JSON
# whose line framing is the thing under test. Both must come back byte-exact.
sub load_fixture_raw {
  my ($name) = @_;
  my $file = $FIXTURES_DIR->child($name);
  croak "Fixture not found: $file" unless $file->exists;
  return $file->slurp_raw;
}

sub is_live {

t/stream_frames.t  view on Meta::CPAN

use strict;
use warnings;
use Test::More;
use FindBin;
use lib "$FindBin::Bin/lib";
use Test::API::Docker::Mock;
use JSON::MaybeXS;
use API::Docker;

# Regression coverage for karr #7: containers->logs and exec->start used to
# hand the caller the raw Docker stream, so the 8-byte frame headers ended up
# inside the log text. Every fixture below is bytes captured from the rootless
# Podman socket (5.4.2, API 1.41) with a container running
# "echo OUT; echo ERR 1>&2".

check_live_access();

# The demultiplexer is a method on the transport role; the client needs no
# connection to exercise it, the socket attribute is lazy.
my $client = API::Docker->new(
  host        => 'unix:///var/run/docker.sock',

t/stream_frames.t  view on Meta::CPAN

my $TTY         = load_fixture_raw('containers_logs_tty.bin');
my $TTY_JSON    = load_fixture_raw('containers_logs_tty_json.bin');
my $EXEC        = load_fixture_raw('exec_start_multiplexed.bin');

subtest 'the captured fixtures are the bytes the engine produced' => sub {
  is length $MULTIPLEXED, 24, 'multiplexed log fixture is 24 bytes';
  is unpack('H*', $MULTIPLEXED),
    '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';
};

subtest 'the framing walk rejects what it cannot consume exactly' => sub {
  is $client->_demux_frames(''), undef, 'empty body';
  is $client->_demux_frames("\x01\x00\x00\x00"), undef, 'short header';
  is $client->_demux_frames("\x01\x00\x00\x00\x00\x00\x00\x04OU"), undef,
    'truncated payload';
  is $client->_demux_frames("\x01\x00\x00\x00\x00\x00\x00\x04OUT\nX"), undef,
    'trailing byte after a complete frame';
  is $client->_demux_frames("\x03\x00\x00\x00\x00\x00\x00\x01A"), undef,
    'stream type above 2';
  is $client->_demux_frames("\x01\x00\x00\x01\x00\x00\x00\x01A"), undef,
    'non-zero padding byte';

  is_deeply $client->_demux_frames("\x00\x00\x00\x00\x00\x00\x00\x01A"),
    [ { stream => 'stdin', data => 'A' } ], 'stream type 0 is stdin';
};

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';
    ok defined $frames->[0]{stream}, 'no caller needs a defined-check';

    my $json = test_docker(
      'GET /containers/deadbeef/logs' => sub { $TTY_JSON },
    );
    is_deeply $json->containers->logs('deadbeef'),
      [ { stream => 'raw', data => qq[{"msg":"hi"}\r\n] } ],
      'a container printing JSON is returned verbatim, not decoded';

    my $forced = test_docker(
      'GET /containers/deadbeef/logs' => sub { $MULTIPLEXED },

t/stream_frames.t  view on Meta::CPAN

      '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';
  };
}

SKIP: {
  skip 'live write tests disabled (API_DOCKER_TEST_WRITE=1 to enable)', 1
    unless can_write();

  subtest 'live: frames off a real engine' => sub {
    my $docker = API::Docker->new(host => $ENV{API_DOCKER_TEST_HOST});
    my $name = 'apidocker-t-frames-' . $$;

    my $created = $docker->containers->create(
      Image => 'alpine:3',
      Cmd   => [ 'sh', '-c', 'echo OUT; echo ERR 1>&2' ],
      Tty   => JSON->false,
      name  => $name,
    );
    my $id = $created->{Id};
    register_cleanup(sub {
      eval { $docker->containers->remove($id, force => 1) };
    });

    $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;



( run in 0.634 second using v1.01-cache-2.11-cpan-2e0ccfb7a10 )