view release on metacpan or search on metacpan
.claude/skills/docker-engine-api/SKILL.md view on Meta::CPAN
```
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
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
.claude/skills/getty-perl-core/SKILL.md view on Meta::CPAN
Why: bare subs hide what the call needs, can't be overridden or mocked, and force every caller to thread state by hand.
## Errors
- **`croak`, never `die`.** Errors report the caller's line, not ours.
- **Import it:** `use Carp qw( croak );` and call `croak(...)` bare.
- **Name the origin in the message:** `croak __PACKAGE__."->state too many args"` â or whatever identifies the operation in that module's DSL.
## Strings
- **Concatenate, do not interpolate:** `'Adding '.$f.' with '.$length.' bytes'`. Interpolate only where concatenation would be unreadable.
- **Single quotes by default.** `'...'` and `"..."` are genuinely different in Perl â `"` interpolates and processes escapes, `'` does not. Reach for `"` when you need that, not by habit.
- **Import lists as `qw( croak confess )`** â spaces inside the parens. Never rely on default exports.
## Control flow
- **Postfix `if`/`unless`** for guards and short conditions: `croak(...) if $self->readonly;`
- **`unless $x`** instead of `if !$x`.
- **Guard clauses return bare:** `return unless $res->is_success;` â not `return undef;`.
- **Nested ternaries** for a return that picks between expressions, instead of an if/elsif chain.
## Data
- **`{ %hash }` and `\%hash` are different operations, not two styles.** `{ %h }` builds a new anonymous copy; `\%h` references the existing hash. Return a copy when the caller must not mutate your state; return the reference when sharing is the poin...
- **`Path::Tiny`** for every file operation â not `File::Spec`, not bare `open`. `path(...)->child(...)->slurp_utf8`.
- **`JSON::MaybeXS`** always â never `JSON::PP`, `JSON::XS`, `Cpanel::JSON::XS` directly. Encoders get `canonical => 1, convert_blessed => 1`.
- **Every serialiser is deterministic.** MessagePack `->canonical`, DBIC `serializer_options => { canonical => 1 }`. Same rule, every format.
- **Booleans: `JSON->true` / `JSON->false`.** `use JSON::MaybeXS;` covers codec and booleans.
- `$YAML::XS::Boolean = 'JSON::PP'` is one of YAML::XS's fixed mode names, not a module choice â leave it alone.
- **Align `=>` in multi-line hash literals** when keys are of similar length.
- **Optional pairs inline:** `$cond ? ( experimental => 1 ) : (),`
## Configuration
Config comes from environment variables prefixed with the project name
(`$ENV{MYPROJECT_TIME_ZONE}`), each with a default in code. Where many
attributes share that shape, write a generator that wraps `has` rather than
repeating the declaration.
## DBIC-ish result classes
.claude/skills/getty-perl-core/SKILL.md view on Meta::CPAN
```
Getty-authored (non-exhaustive): `Langertha`, `IO::K8s`, `Kubernetes::REST`, `WWW::Crawl4AI`, `Net::Async::Crawl4AI`, `Net::Async::WebSearch`, `Catalyst::Plugin::ChainedURI`, `Locale::Simple`, `DBIO::*`, `WWW::Zitadel`, `WWW::PayPal`, `WWW::Chain`.
## Changelog (the Changes file)
Every distribution ships a `Changes` file with a `{{$NEXT}}` token at the top (Dist::Zilla's `[NextRelease]` fills it at release time).
- **Add a bullet under `{{$NEXT}}` in the SAME commit as any user-facing change** â new bindings, behaviour changes, bug fixes, deprecations. If a CPAN consumer would notice, it belongs there.
- **Match the existing style:** two-space indent, ` - ` bullets, wrap near 78 columns, present-tense imperative ("New binding X", "Fix Y on macOS").
- **One topic, one bullet, one to three lines** â touching an area again rewrites the bullet that is already there instead of adding a second. Wording and length: `getty-git-commit-style`.
- **Skip pure dev-tooling noise** â skill hardlinks, editor config, internal CI refactors. A CI fix that unbreaks the build for everyone IS worth a line.
- **Never hand-edit the version line or timestamp** â `[NextRelease]` owns those.
## Forbidden
â `require Foo` inside a method to "speed up startup" · â a Getty repo's `$VERSION` as a cpanfile requirement · â `'0'` or `'>= x'` as a version argument · â `default => sub {...}` for a non-trivial attribute default · â 4-space indent ...
## When in doubt
Grep hand-written Getty code for how the pattern is used there â the reference is an older repo with no AI commits in its history. Newer repos may show an agent's guess rather than the house rule.
.claude/skills/getty-perl-moo/SKILL.md view on Meta::CPAN
$class->$orig(@args);
};
sub FOREIGNBUILDARGS { # maps args to non-Moo parent's constructor
my ($class, $args) = @_;
return ($args->{source});
}
sub BUILD { # runs AFTER all attributes are set; parentâchild order
my ($self, $args) = @_;
die "invalid" unless length $args->{source};
}
# DEMOLISH: childâparent order. Never override DESTROY directly.
```
Do NOT call `SUPER::BUILD` manually â Moo handles the chain.
---
## Pattern 10 â Strict Constructor
lib/API/Docker/API/Configs.pm view on Meta::CPAN
$self->client->get('/configs',
params => \%params,
%{ $self->_request_options },
) // []);
}
sub create {
my ($self, %spec) = @_;
croak __PACKAGE__ . '->create Name required'
unless defined $spec{Name} && length $spec{Name};
croak __PACKAGE__ . '->create Data required'
unless defined $spec{Data} && length $spec{Data};
$spec{Data} = $self->_encode_data('create', $spec{Data});
return $self->client->post('/configs/create', \%spec);
}
sub inspect {
my ($self, $id) = @_;
croak __PACKAGE__ . '->inspect config ID or name required'
unless defined $id && length $id;
return $self->_wrap('API::Docker::Type::Config',
$self->client->get("/configs/$id",
%{ $self->_request_options },
));
}
sub update {
my ($self, $id, $version, %spec) = @_;
croak __PACKAGE__ . '->update config ID or name required'
unless defined $id && length $id;
croak __PACKAGE__ . '->update requires the current version as its second '
. 'argument: the Version.Index from inspect($id), which the daemon uses '
. 'as an optimistic-concurrency token and will not accept the update '
. 'without'
unless defined $version;
croak __PACKAGE__ . '->update version must be the numeric Version.Index '
. "from inspect(\$id), got '$version'"
unless $version =~ /\A[0-9]+\z/;
$spec{Data} = $self->_encode_data('update', $spec{Data})
if defined $spec{Data};
return $self->client->post("/configs/$id/update", \%spec,
params => { version => $version });
}
sub remove {
my ($self, $id) = @_;
croak __PACKAGE__ . '->remove config ID or name required'
unless defined $id && length $id;
return $self->client->delete_request("/configs/$id",
%{ $self->_request_options },
);
}
1;
__END__
lib/API/Docker/API/Containers.pm view on Meta::CPAN
my $inspected = $self->inspect($id);
# An API::Docker::Type::ContainerState, or undef where the daemon sent no
# State at all -- which is the "does not recognise" case above, not a stopped
# container.
my $state = $inspected->state;
return unless blessed($state) && defined $state->running;
return if $state->running;
my $status = $state->status;
$status = 'not running' unless defined $status && length $status;
croak __PACKAGE__ . '->attach refused: container ' . $id . ' is ' . $status
. '. Attaching to a container that is not running destroys its exit status '
. 'on Podman, irrecoverably -- the engine keeps no copy -- and with '
. 'stream => 1 never returns on either engine. Read its output with logs() '
. 'instead, or pass require_running => 0 to attach anyway';
}
sub attach {
my ($self, $id, %opts) = @_;
lib/API/Docker/API/Containers.pm view on Meta::CPAN
# points at. Two of its attributes are left at their defaults on this path, on
# purpose: ->reason, because the status line's reason phrase was "OK" and
# putting that on a 500 would mislead, and ->body, because the bytes were
# decoded by the transport before this check ever saw them.
sub _assert_no_podman_error {
my ($self, $endpoint, $value) = @_;
my $error = $self->_podman_error_object($value) or return $value;
my $reason = $error->{message};
$reason = $error->{cause} unless defined $reason && length $reason;
$reason = 'no message given' unless defined $reason && length $reason;
# Carp appends no location to a message that already ends in a newline.
$reason =~ s/\s+\z//;
# The object goes into a variable first: `croak CLASS->new(...)` is indirect
# object syntax and parses as CLASS->croak(new(...)). Carp hands a reference
# straight back rather than decorating it, so the location is captured by
# hand, naming the frame a croak of a plain string would have named.
my $err = API::Docker::Error::HTTP->new(
message => 'Docker API error (' . $error->{response} . '): ' . $reason
. ' -- reported inside a 200 response to ' . $endpoint,
lib/API/Docker/API/Containers.pm view on Meta::CPAN
# The engine reports what a path is in a response header rather than a body,
# so both GET and HEAD carry it and only HEAD has nothing else to say. The
# header is base64-encoded JSON; handing the caller the base64 would make
# every one of them write this.
sub _decode_path_stat {
my ($self, $response) = @_;
my $header = $response->{headers}{'x-docker-container-path-stat'};
return undef unless defined $header && length $header;
# Docker encodes this one with Go's base64.StdEncoding -- unlike
# X-Registry-Auth, which is URLEncoding. Decoded tolerantly rather than
# strictly: translating the two URL-safe characters first costs nothing and
# means an engine that reached for the other alphabet is still read.
$header =~ tr{-_}{+/};
my $stat = eval { decode_json(decode_base64($header)) };
croak "Cannot decode X-Docker-Container-Path-Stat header: $@"
unless ref $stat eq 'HASH';
return $stat;
}
sub get_archive {
my ($self, $id, %opts) = @_;
croak "Container ID required" unless $id;
croak "Path required" unless defined $opts{path} && length $opts{path};
croak "The stat option must be a HashRef"
if exists $opts{stat} && ref $opts{stat} ne 'HASH';
my %response;
my $tar = $self->client->get("/containers/$id/archive",
params => { path => $opts{path} },
raw => 1,
response => \%response,
%{ $self->_request_options },
);
lib/API/Docker/API/Containers.pm view on Meta::CPAN
%$out = %{ $self->_decode_path_stat(\%response) // {} };
}
return $tar;
}
sub put_archive {
my ($self, $id, $tar, %opts) = @_;
croak "Container ID required" unless $id;
croak "Path required" unless defined $opts{path} && length $opts{path};
croak "Tar archive required (raw bytes or a scalar ref)" unless defined $tar;
my %params = ( path => $opts{path} );
$params{noOverwriteDirNonDir} = $opts{noOverwriteDirNonDir} ? 1 : 0
if defined $opts{noOverwriteDirNonDir};
$params{copyUIDGID} = $opts{copyUIDGID} ? 1 : 0
if defined $opts{copyUIDGID};
my $raw = ref $tar eq 'SCALAR' ? $$tar : $tar;
lib/API/Docker/API/Containers.pm view on Meta::CPAN
raw_body => $raw,
content_type => 'application/x-tar',
%{ $self->_request_options },
);
}
sub stat_archive {
my ($self, $id, %opts) = @_;
croak "Container ID required" unless $id;
croak "Path required" unless defined $opts{path} && length $opts{path};
my %response;
$self->client->head("/containers/$id/archive",
params => { path => $opts{path} },
response => \%response,
%{ $self->_request_options },
);
return $self->_decode_path_stat(\%response);
}
lib/API/Docker/API/Plugins.pm view on Meta::CPAN
Accessed via C<< $docker->plugins >>, or through
L<API::Docker::Role::Using/using> for a run of calls that needs its own
transport bound: C<< $docker->plugins->using(read_timeout => 5) >>.
=head2 Installing is two calls, and the engine enforces it
C<< POST /plugins/pull >> takes the list of privileges the plugin demands
B<in its request body>, and the daemon compares that list against the one it
computes from the plugin's own config. They must match exactly -- same
length, same names, same values -- or the install fails with
C<incorrect privileges>. A plugin runs with the host access it asked for, so
the round trip exists to make somebody look at that access before granting
it.
L</privileges> is the first call, L</install> the second:
my $privileges = $docker->plugins->privileges('vieux/sshfs:latest');
# inspect $privileges here -- it is an ArrayRef of
# { Name => 'network', Description => '...', Value => ['host'] }
$docker->plugins->install('vieux/sshfs:latest', privileges => $privileges);
lib/API/Docker/API/Secrets.pm view on Meta::CPAN
$self->client->get('/secrets',
params => \%params,
%{ $self->_request_options },
) // []);
}
sub create {
my ($self, %spec) = @_;
croak __PACKAGE__ . '->create Name required'
unless defined $spec{Name} && length $spec{Name};
croak __PACKAGE__ . '->create Data required'
unless defined $spec{Data} && length $spec{Data};
$spec{Data} = $self->_encode_data('create', $spec{Data});
return $self->client->post('/secrets/create', \%spec);
}
sub inspect {
my ($self, $id) = @_;
croak __PACKAGE__ . '->inspect secret ID or name required'
unless defined $id && length $id;
return $self->_wrap('API::Docker::Type::Secret',
$self->client->get("/secrets/$id",
%{ $self->_request_options },
));
}
sub update {
my ($self, $id, $version, %spec) = @_;
croak __PACKAGE__ . '->update secret ID or name required'
unless defined $id && length $id;
croak __PACKAGE__ . '->update requires the current version as its second '
. 'argument: the Version.Index from inspect($id), which the daemon uses '
. 'as an optimistic-concurrency token and will not accept the update '
. 'without'
unless defined $version;
croak __PACKAGE__ . '->update version must be the numeric Version.Index '
. "from inspect(\$id), got '$version'"
unless $version =~ /\A[0-9]+\z/;
$spec{Data} = $self->_encode_data('update', $spec{Data})
if defined $spec{Data};
return $self->client->post("/secrets/$id/update", \%spec,
params => { version => $version });
}
sub remove {
my ($self, $id) = @_;
croak __PACKAGE__ . '->remove secret ID or name required'
unless defined $id && length $id;
return $self->client->delete_request("/secrets/$id",
%{ $self->_request_options },
);
}
1;
__END__
lib/API/Docker/Error/Truncated.pm view on Meta::CPAN
version 0.004
=head1 SYNOPSIS
# A tar the daemon stopped sending halfway is not a tar.
my $tar = eval { $docker->images->get_tar('busybox') };
if (my $err = $@) {
die $err unless ref $err
&& $err->isa('API::Docker::Error::Truncated');
warn 'got ' . length($err->partial) . ' of '
. $err->expected . ' bytes; retrying';
$tar = $docker->images->get_tar('busybox');
}
=head1 DESCRIPTION
L<API::Docker::Role::HTTP> croaks with an object of this class when the daemon
closed the connection in the middle of a response -- a status line with no
terminator, a header block with no blank line to close it, a body shorter than
its C<Content-Length>, a chunk shorter than its own header, a chunk header cut
in half, or a chunked body with no terminating zero chunk. It is raised in one
more place that is not a closed connection but has the same consequence: a
chunk size line that arrived in full and is not a hexadecimal number, which
would otherwise be misread as a zero chunk and end the body early (see
L</phase>).
It is a structural check, not a heuristic, and it asks one of two questions
depending on how the piece is delimited. Where the response announced a length
it compares what arrived against it. Where the framing is by terminator
instead -- the head, and the chunk headers -- it asks whether the terminator
came before the stream ended, which needs nothing to compare and is just as
decidable. Neither is a guess about content: a header block that never closed
is not a short one, it is an unfinished one.
A body delimited by nothing but the close -- C<attach>,
C<< logs(follow => 1) >>, C</exec/{id}/start>, the whole
C<application/vnd.docker.raw-stream> family -- announces no end and has no
terminator either, so there an EOF B<is> the end and this is never raised.
lib/API/Docker/Error/Truncated.pm view on Meta::CPAN
that arrived in full but is not an HTTP status line at all -- a proxy's
plain-text banner, an HTML error page -- which is no cut response, but is
refused here for the reason the non-hexadecimal chunk size below is: its second
word would otherwise be split out and read as the status
=item * C<'header-block'> - the stream ended inside a header line, or where
one belongs with the blank line that ends the field section never sent. The
second covers a head with no fields at all: RFC 9112 section 2.1 requires the
empty line whether there are twenty fields or none
=item * C<'content-length'> - fewer bytes arrived than the C<Content-Length>
header announced, or the header arrived in full but its value is not a number.
The second is no cut response either: left as it stood it would read as C<0>
and a response that had a body would come back empty, the same body-shaped lie
a truncation is
=item * C<'chunk-header'> - the stream ended inside a chunk size line, or at a
chunk boundary with no terminating zero chunk after it, or a chunk size line
that arrived in full but is not a hexadecimal number. The last is not a cut
response: the line is complete and terminated, but C<hex> would read its
garbage as C<0> -- the terminating zero chunk -- so the body would silently
lib/API/Docker/Error/Truncated.pm view on Meta::CPAN
=back
Informational rather than something to branch on: every value means the same
thing to a caller, which is that the response is incomplete. It is here
because "which of the four" is the first question when a real engine starts
raising this, and reading it off the object beats parsing L</message>.
=head2 expected
The byte count the framing announced for the piece that was cut short: the
C<Content-Length> for C<'content-length'>, the chunk's own size for
C<'chunk-data'>. C<undef> for the four phases with no announcement to fall
short of, which are the ones framed by a terminator instead.
=head2 received
How many of L</expected> arrived. C<undef> whenever L</expected> is.
Note that this counts the piece, not the response: on a chunked body it is the
bytes of the unfinished chunk, while L</partial> holds every chunk before it
as well.
lib/API/Docker/Role/Filters.pm view on Meta::CPAN
sub _normalise_filters {
my ($self, $filters) = @_;
croak __PACKAGE__ . '->_normalise_filters filters must be a HashRef of '
. 'filter name to value, e.g. { dangling => [\'true\'] }'
unless ref $filters eq 'HASH';
my %normalised;
for my $name (sort keys %$filters) {
croak __PACKAGE__ . '->_normalise_filters filter name must not be empty'
unless length $name;
my $value = $filters->{$name};
# A bare value is one value, not a mistake worth refusing -- the engine
# is the one that insists on the list.
my @values = ref $value eq 'ARRAY' ? @$value : ($value);
$normalised{$name} = [ map { $self->_normalise_filter_value($name, $_) } @values ];
}
return \%normalised;
}
lib/API/Docker/Role/Filters.pm view on Meta::CPAN
unless $$value eq '1' || $$value eq '0';
return $$value ? 'true' : 'false';
}
croak $where . 'has a ' . $ref . ' reference as a value; filter values are '
. 'strings, or an ArrayRef of them' if $ref;
croak $where . 'has an empty value; the engine rejects it. A Perl boolean '
. 'stringifies to \'\' when false -- the engine wants the string '
. '\'false\''
unless length $value;
# Stringify a copy: a scalar carrying a number would otherwise be
# JSON-encoded as one, and the engine's filter values are strings.
return "$value";
}
1;
__END__
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
return (%ssl, $self->_ssl_certificates);
}
# cert.pem, key.pem and ca.pem in one directory -- the layout the docker CLI
# writes and the one cert_path has always pointed at, whether or not anything
# read it.
sub _ssl_certificates {
my ($self) = @_;
my $dir = $self->cert_path;
return () unless defined $dir && length $dir;
# cert_path defaults from DOCKER_CERT_PATH, so it can arrive from a machine's
# environment rather than from this caller -- but it is only looked at once
# TLS was asked for, and at that point a path naming nothing is a mistake
# worth stopping on rather than quietly connecting without the certificates
# the caller believes are in use.
my $path = path($dir);
croak __PACKAGE__ . ": cert_path $dir is not a directory. TLS expects the "
. 'layout the docker CLI writes -- ca.pem, cert.pem and key.pem in one '
. 'directory -- and this names nothing that could hold it'
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
}
sub _croak_connect_timeout {
my ($self, $pending, $where) = @_;
my $endpoint = $pending->{endpoint};
# See _croak_timeout for why the object goes into a variable first and why
# the location is captured by hand.
my $error = API::Docker::Error::Timeout->new(
message => 'Docker API connect timeout'
. (defined $endpoint && length $endpoint ? ' (' . $endpoint . ')' : '')
. ': ' . $where . ' did not accept within ' . $pending->{timeout} . 's',
location => shortmess(''),
endpoint => defined $endpoint ? $endpoint : '',
timeout => $pending->{timeout},
phase => 'connect',
);
croak $error;
}
# undef for "no timeout", which is both the default and the explicit 0, so a
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
my ($self, $ctx, $partial) = @_;
$partial = '' unless defined $partial;
# Only ever set once a stream is past its status line, so an error body read
# whole on the way to a >= 400 croak is still reported in bytes.
my $summary = $ctx->{summary} ? $ctx->{summary}->() : undef;
my $after = $summary
? ' after ' . $summary->{delivered} . ' unit'
. ($summary->{delivered} == 1 ? '' : 's')
: length($partial)
? ' after ' . length($partial) . ' byte'
. (length($partial) == 1 ? '' : 's')
: ', nothing arrived at all';
# 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::Timeout->new(
message => 'Docker API read timeout (' . $ctx->{endpoint} . '): '
. $ctx->{timeout} . 's of silence' . $after,
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
# with no site having to know which it is.
sub _croak_truncated {
my ($self, $ctx, %what) = @_;
my $summary = $ctx->{summary} ? $ctx->{summary}->() : undef;
my $partial = $ctx->{partial} ? ${ $ctx->{partial} } : '';
my $arrived = $summary
? $summary->{delivered} . ' unit'
. ($summary->{delivered} == 1 ? '' : 's') . ' delivered'
: length($partial)
? length($partial) . ' byte'
. (length($partial) == 1 ? '' : 's') . ' arrived'
: 'nothing arrived at all';
# Empty when a reader is driven directly rather than through _request, which
# is how t/role_http.t drives them: an endpoint nobody named is left out of
# the message rather than interpolated as the empty string.
my $endpoint = defined $ctx->{endpoint} ? $ctx->{endpoint} : '';
# The two phases with an announced length say the same sentence about it, so
# they name the piece and let this write it; the two without pass the whole
# detail, there being no count to put in one.
my $detail = $what{detail};
unless (defined $detail) {
my $short = $what{expected} - $what{received};
$detail = $what{piece} . ' stopped ' . $short . ' byte'
. ($short == 1 ? '' : 's') . ' short of the ' . $what{expected}
. ' it announced';
}
# 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::Truncated->new(
message => 'Docker API response truncated'
. (length $endpoint ? ' (' . $endpoint . ')' : '') . ': '
. $detail . '; ' . $arrived,
location => shortmess(''),
endpoint => $endpoint,
phase => $what{phase},
expected => $what{expected},
received => $what{received},
partial => $partial,
summary => $summary,
);
croak $error;
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
$self->_croak_timeout($ctx, $ctx->{partial} ? ${ $ctx->{partial} } : '')
if $kind eq 'timeout';
last if $kind eq 'eof';
$idx = index($$buf, "\n");
}
return substr($$buf, 0, $idx + 1, '') if $idx >= 0;
# The stream ended. Whatever is left is a final line with no terminator,
# which is what readline hands back there as well; nothing left is undef.
return undef unless length $$buf;
return substr($$buf, 0, length($$buf), '');
}
# Returns (count, bytes) like the read() it replaces, so `last unless $n`
# still ends a loop at the end of the response. What changed is the count: it
# is now what had arrived, never more and no longer padded out by waiting.
# Every caller loops until it has what it needs, so a short count is a
# delivery rather than a truncation.
sub _read_bytes {
my ($self, $sock, $want, $ctx) = @_;
$ctx ||= {};
my $buf = $self->_read_buffer($sock);
if (!length $$buf) {
my $kind = $self->_pull($sock, $ctx);
# Nothing is lost with this exception, and unlike under read() nothing has
# to be rescued for it either. sysread does not hand back data and EAGAIN
# together the way PerlIO's read() did: the bytes of every successful pull
# are already in the accumulator or already through the callback by the
# time a later pull expires, and the pull that expires carries none.
$self->_croak_timeout($ctx, $ctx->{partial} ? ${ $ctx->{partial} } : '')
if $kind eq 'timeout';
return (0, '') if $kind eq 'eof';
}
my $take = $want < length($$buf) ? $want : length($$buf);
return ($take, substr($$buf, 0, $take, ''));
}
# _read_bytes for a length the response announced, which is the whole of the
# difference: it appends onto the accumulator until it has all of $want, and
# an end of stream before then is truncation rather than the end of the body.
#
# Every `last unless $n` in a buffered reader used to be both -- the loop
# ended and what had been collected was returned as the response. Nothing
# compared the two, so a daemon that closed mid-body handed back a short body
# that every return shape this role promises accepts (karr k64).
#
# $into is the same scalar the reader is accumulating into, so the bytes of
# the incomplete piece are in $ctx->{partial} by the time this croaks and go
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
$log->debugf("%s %s", $method, $url_path);
my $request = "$method $url_path HTTP/1.1\r\n";
$request .= "Host: localhost\r\n";
$request .= "Connection: close\r\n";
$request .= "User-Agent: API-Docker\r\n";
if (defined $body_content) {
$request .= "Content-Type: $content_type\r\n";
$request .= "Content-Length: " . length($body_content) . "\r\n";
}
if ($opts{headers}) {
for my $h (sort keys %{$opts{headers}}) {
# The name is validated before the value is even looked at: a name that
# cannot go on the wire is a caller bug whether or not the header ends
# up being sent.
$self->_assert_header_name($h);
my $v = $opts{headers}{$h};
next unless defined $v;
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
# A streamed request has handed every unit to the callback already and kept
# none of them, so there is no body left to decode and return. What the
# caller cannot know otherwise is how the stream ended, and that is what
# comes back instead.
return $summary if $summary;
# Zero bytes is a different answer in each shape a request can ask for, so
# the two options that promise one are answered before the empty-body check
# rather than after it. `raw` promises the response bytes and a body of no
# bytes is '', which a caller can take length() of; `ndjson` promises an
# ArrayRef of events even for a stream carrying a single object, so a stream
# that carried none is []. Returning undef for both broke each promise
# exactly where the engine legitimately says nothing.
$body = '' unless defined $body;
# 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};
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
for my $event (@$events) {
next unless ref $event eq 'HASH';
my $detail = $event->{errorDetail};
next unless defined $detail;
# errorDetail is a HashRef carrying the message. The engine sends a flat
# `error` next to it with the same text; that is the fallback, not the
# 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(
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
next if $status_code >= 100 && $status_code < 200;
return [$status_code, $status_text, \%headers];
}
}
# The two ways the head ends early, and neither of them has a byte count to
# compare either (karr k73).
#
# karr k64 left the head out on the grounds that nothing in a status line or a
# header block announces its own length, so there was no announcement to hold
# a short one against. True, and beside the point: an announcement is not what
# is being checked here, any more than it is in _assert_chunk_header one level
# down. HTTP/1.1 frames the head by terminating every line, and the field
# section by an empty line that is mandatory even when there are no fields at
# all (RFC 9112 section 2.1), so "the stream ended where the terminator
# belongs" is a complete test on its own. It is the same question that reader
# already asks about a chunk header, asked of the head.
#
# What it was worth. A head cut short is not just a bogus status: the response
# is then read with whichever headers happened to arrive, and a cut landing
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
# /exec/{id}/start answer with "HTTP/1.1 200 OK", one Content-Type line and
# the blank line, on Docker 29.7.2 and on rootless Podman 5.8.4 alike. So do
# 204, 304, HEAD, chunked and every other shape either of them produces.
sub _assert_status_line {
my ($self, $ctx, $line) = @_;
# Only the unterminated half: a status line that never started at all is the
# croak above, which says something better than this could.
$self->_croak_truncated($ctx, phase => 'status-line',
detail => 'the stream ended inside the status line, after '
. length($line) . ' byte' . (length($line) == 1 ? '' : 's') . ' of one')
unless $line =~ /\n\z/;
# Terminated, and now: is it an HTTP status line at all? RFC 9112 section 4:
# HTTP-version SP status-code SP [ reason-phrase ], with status-code exactly
# three digits. A line that arrived whole but is not this shape -- a proxy's
# plain-text banner, an ICY greeting, an HTML error page -- would otherwise
# be split on whitespace in _read_head and its second word run through the
# >= 400 comparison as the status. It is refused here rather than silently
# misread, the same way a non-hexadecimal chunk size is one level down.
my $stripped = $line =~ s/\r?\n\z//r;
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
sub _assert_header_line {
my ($self, $ctx, $line) = @_;
$self->_croak_truncated($ctx, phase => 'header-block',
detail => 'the stream ended where a header line belongs, with no blank '
. 'line to close the header block')
unless defined $line;
$self->_croak_truncated($ctx, phase => 'header-block',
detail => 'the stream ended inside a header line, after '
. length($line) . ' byte' . (length($line) == 1 ? '' : 's') . ' of one')
unless $line =~ /\n\z/;
return;
}
sub _read_body {
my ($self, $sock, $headers, $method, $ctx) = @_;
$ctx ||= {};
# A HEAD response repeats the header fields the equivalent GET would send --
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
# all. Every branch below would wait for bytes that never arrive: until the
# daemon closes the connection at best, and forever if it does not. So the
# body is not read for HEAD, whatever the headers promise.
return '' if defined $method && uc($method) eq 'HEAD';
if ($headers->{'transfer-encoding'}
&& lc($headers->{'transfer-encoding'}) eq 'chunked') {
return $self->_read_chunked($sock, $ctx);
}
if (defined $headers->{'content-length'}) {
my $len = $self->_assert_content_length($ctx, $headers->{'content-length'});
return '' unless $len > 0;
my $body = '';
# What a timeout hands over instead of dropping: see
# API::Docker::Error::Timeout/partial. localised so the context goes back
# to carrying nothing once this body is done with.
local $ctx->{partial} = \$body;
# An announced length is a promise, and a stream that ends before it is
# kept is truncation rather than the end of the body; see _read_exact.
$self->_read_exact($sock, $len, \$body, $ctx, 'content-length',
'the body');
return $body;
}
# Read until the daemon closes. This used to be a `local $/; <$sock>` slurp;
# it is a loop over the same primitive as the other two branches now, which
# is what karr k60 needed and what the timeout wanted anyway -- with $/ undef
# a whole body and a truncated one are both just bytes, so the slurp's own
# result could never say which it was. This is the path karr k52's hang is
# on, an attach whose buffered frames arrive and whose socket then never
# closes.
#
# And the one shape with no completeness check to make: the response
# announced no end, so the close IS the end (karr k64). Treating an EOF here
# as truncation would make every attach, every logs(follow) and every
# exec/start fail on the daemon hanging up, which is how all three finish.
my $body = '';
# What a timeout hands over instead of dropping; see the content-length
# branch above.
local $ctx->{partial} = \$body;
while (1) {
my ($n, $buf) = $self->_read_bytes($sock, $READ_SIZE, $ctx);
last unless $n;
$body .= $buf;
}
return $body;
}
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
expected => $chunk_size, received => $read)
if $more && $read < $chunk_size;
last unless $more;
# The CRLF that terminates the chunk data. Skipped when the caller
# stopped mid-chunk: the socket is closed straight after, and the
# remaining bytes of that chunk are still unread in front of it.
$self->_assert_chunk_terminator($ctx, $self->_read_line($sock, $ctx));
}
}
elsif (defined $headers->{'content-length'}) {
my $len = $self->_assert_content_length($ctx, $headers->{'content-length'});
my $read = 0;
while ($more && $read < $len) {
my $want = $len - $read;
$want = $READ_SIZE if $want > $READ_SIZE;
my ($n, $buf) = $self->_read_bytes($sock, $want, $ctx);
last unless $n;
$read += $n;
$more = $feed->($buf);
}
$self->_croak_truncated($ctx, phase => 'content-length',
piece => 'the body', expected => $len, received => $read)
if $more && $read < $len;
}
else {
while ($more) {
my ($n, $buf) = $self->_read_bytes($sock, $READ_SIZE, $ctx);
last unless $n;
$more = $feed->($buf);
}
}
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
return !$stopped;
};
my ($feed, $finish);
if ($option eq 'on_chunk') {
# No carry: the bytes as they arrive are the unit, so there is no boundary
# to reassemble across.
$feed = sub {
my ($bytes) = @_;
return 1 unless defined $bytes && length $bytes;
return $deliver->($bytes);
};
$finish = sub { return };
}
elsif ($option eq 'on_event') {
my $emit_line = sub {
my ($line) = @_;
$line =~ s/\r\z//;
return 1 unless $line =~ /\S/;
my $event = eval { decode_json($line) };
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
while ((my $idx = index($carry, "\n")) >= 0) {
my $line = substr($carry, 0, $idx, '');
substr($carry, 0, 1, '');
return 0 unless $emit_line->($line);
}
return 1;
};
$finish = sub {
# A last event with no trailing newline is a complete event, not a
# truncated one: the daemon closing is what ended it.
return unless length $carry;
my $line = $carry;
$carry = '';
$emit_line->($line);
return;
};
}
else {
$feed = sub {
my ($bytes) = @_;
$carry .= $bytes;
while (length($carry) >= 8) {
my ($type, $pad1, $pad2, $pad3, $size) = unpack 'C4 N', substr($carry, 0, 8);
croak __PACKAGE__ . '->_request on_frame: not a framed stream (header '
. 'byte 0 is ' . $type . ', bytes 1-3 are ' . $pad1 . '/' . $pad2
. '/' . $pad3 . '). A callback stream cannot sniff its own framing '
. 'the way the buffered path does -- that needs the whole body, '
. 'which is what is not being kept. Declare an unframed stream with '
. 'tty => 1'
if $type > $#STREAM_TYPE || $pad1 || $pad2 || $pad3;
# The header is complete but the payload is not yet: leave the whole
# frame in the carry and wait for the rest of it. This is the case a
# per-chunk reader gets wrong -- an 8-byte header can be split across
# two chunks just as easily as a payload can.
last if length($carry) < 8 + $size;
my $frame = {
stream => $STREAM_TYPE[$type],
data => substr($carry, 8, $size),
};
substr($carry, 0, 8 + $size, '');
return 0 unless $deliver->($frame);
}
return 1;
};
$finish = sub {
return unless length $carry;
croak __PACKAGE__ . '->_request on_frame: the daemon closed mid-frame, '
. 'leaving ' . length($carry) . ' bytes that do not complete one';
};
}
return {
feed => $feed,
finish => $finish,
stopped => sub { $stopped },
summary => sub { { delivered => $delivered, stopped => $stopped ? 1 : 0 } },
};
}
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
sub _assert_chunk_header {
my ($self, $ctx, $line) = @_;
$self->_croak_truncated($ctx, phase => 'chunk-header',
detail => 'the stream ended where a chunk header belongs, with no '
. 'terminating zero chunk')
unless defined $line;
$self->_croak_truncated($ctx, phase => 'chunk-header',
detail => 'the stream ended inside a chunk header, after '
. length($line) . ' byte' . (length($line) == 1 ? '' : 's') . ' of one')
unless $line =~ /\n\z/;
# The line arrived whole and terminated, and its size is not a hexadecimal
# number. Left to hex() that warns once and reads as 0 -- the terminating
# zero chunk -- so a 200 whose framing is corrupt used to come back as an
# empty body, the same body-shaped lie a truncation is. A chunk size is hex
# digits, optionally followed by a ';' extension (RFC 9112 section 7.1.1),
# which is read past and discarded; anything else is refused here rather than
# silently misread by the caller. The returned size is parsed from the same
# match, so hex() is called on nothing but hex digits and never has cause to
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
sub _assert_chunk_terminator {
my ($self, $ctx, $line) = @_;
$self->_croak_truncated($ctx, phase => 'chunk-terminator',
detail => 'the stream ended before the CRLF that terminates a chunk')
unless defined $line && $line =~ /\n\z/;
return;
}
# The declared body length, validated to be the digits RFC 9110 section 8.6
# requires before it is compared against or counted down (karr k113). The
# sibling of _assert_chunk_header's hex check: a Content-Length that is not a
# number -- 'abc', an empty value, a duplicated '11, 11', a leading space --
# left as it stood is run through `$len > 0`, which warns once ("isn't
# numeric") and reads as 0, so the body is taken to be empty and a response
# that had one comes back blank. Refused here rather than silently misread, so
# hex()'s sibling warning is never reached either.
sub _assert_content_length {
my ($self, $ctx, $value) = @_;
return $value if $value =~ /\A[0-9]+\z/;
$self->_croak_truncated($ctx, phase => 'content-length',
detail => "the Content-Length header '" . $value . "' is not a number");
}
sub _uri_encode {
my ($str) = @_;
# Escape a character string by its UTF-8 bytes ('ü' -> %C3%BC, not %FC), and
# a byte string as it stands. ord() on a character is not its wire byte: a
# name or tag typed under `use utf8`, or read through a :utf8 layer, arrives
# as characters and used to escape to a lone high byte or a bare codepoint
# (%FC, %4E2D) that is not UTF-8 at all. But the encoding cannot be
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
# anything not declared is required to be framed.
return $self->_request($method, $path, %opts,
$tty
? ( on_chunk => sub { $cb->({ stream => 'raw', data => $_[0] }, $_[1]) } )
: ( on_frame => $cb ),
);
}
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, {
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
can be split across two chunks or two reads, so partial ones are carried
forward rather than decoded early.
=head3 How often the callback is called
Once per unit the daemon has finished sending, as soon as the bytes that
complete it have arrived -- not once per read of a fixed size, and not once
at the end.
That is worth stating because it was not true before karr k60. The reads were
C<read()>, which is C<fread>-shaped: it loops until it has the length it was
asked for or the stream ends, rather than returning what has arrived. On the
raw-stream endpoints -- C<attach>, C<< logs(follow => 1) >>, C<exec/start>,
which carry neither a C<Content-Length> nor chunked encoding -- the reader
asks for 64K, so nothing reached the callback until 64K had accumulated or the
daemon hung up. On a stream that never ends, nothing reached it at all.
Measured on an C<AF_UNIX> socket pair with no daemon involved, a peer writing
three frames 0.15s apart and then closing:
before: 1 call at 0.45s (the moment it closed)
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
chunk header cut in half, a chunked body with no terminating zero chunk: each
of those is a response that ended before it was finished, and each croaks with
an L<API::Docker::Error::Truncated>.
my $tar = eval { $docker->images->get_tar('busybox') };
die $@ if $@ && !(ref $@
&& $@->isa('API::Docker::Error::Truncated'));
It is a structural check, so it needs no option, applies to every request, and
cannot fire on a response that is complete. Which question it asks depends on
how the piece is framed: where the response announced a length, what arrived
is compared against it; where the framing is by terminator instead -- the head
and the chunk headers -- it asks whether the terminator came before the stream
ended, which is decidable without anything to compare. The exception carries
what did arrive: C<< ->partial >> for a buffered request, C<< ->summary >> for
a streamed one, and C<< ->phase >> for which piece of the framing ran out.
This B<is> a behaviour change and not a bug fix in passing. Until it existed
every shape above was returned rather than raised, and none of them was
distinguishable from a complete response: C<ndjson> gave a shorter ArrayRef,
C<raw> gave fewer bytes, the default gave whatever the truncated bytes
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
my $stat = decode_json(decode_base64($res{headers}{'x-docker-container-path-stat'}));
Perform HTTP HEAD request. Always returns C<undef>: a HEAD response has no
body by definition, so everything it says is in the status line and the
headers, and C<response> is the only way to reach them.
The body is not read even when the response announces one. A HEAD response
repeats the header fields the equivalent GET would send, C<Content-Length>
among them, and then sends nothing -- reading it would block on bytes that
never arrive. Measured against Podman 5.4.2 (API 1.41),
C<< HEAD /containers/{id}/archive >> in fact announces no length at all, only
C<X-Docker-Container-Path-Stat> -- but an engine that does announce one is not
waited on either.
Options: C<params>, C<headers> and C<response> as for L</get>.
=head2 stream_frames
my $frames = $client->stream_frames('GET', "/containers/$id/logs", %opts);
Perform a request against one of the engine's framed endpoints
lib/API/Docker/Role/HTTP.pm view on Meta::CPAN
C<tty> is a declaration here rather than the hint it is on the buffered path.
Deciding framing from the bytes needs the whole body, which is precisely what
is not being kept; so an unframed stream must say so, and one that does not
and is not framed croaks instead of inventing frames from its payload.
=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
lib/API/Docker/Type/Address.pm view on Meta::CPAN
=head1 DESCRIPTION
Generated from the C<Address> definition of C<spec/v1.51.yaml>.
=head2 addr
IP address.
=head2 prefix_len
Mask length of the IP address.
=head1 SUPPORT
=head2 Issues
Please report bugs and feature requests on GitHub at
L<https://github.com/Getty/p5-api-docker/issues>.
=head1 CONTRIBUTING
lib/API/Docker/Type/EndpointSettings.pm view on Meta::CPAN
Gateway address for this network.
=head2 ip_address
IPv4 address. Serialised as C<IPAddress> -- spelled out, because deriving it
from the Perl name would produce C<IpAddress>.
=head2 ip_prefix_len
Mask length of the IPv4 address. Serialised as C<IPPrefixLen> -- spelled
out, because deriving it from the Perl name would produce C<IpPrefixLen>.
=head2 ipv6_gateway
IPv6 gateway address. Serialised as C<IPv6Gateway> -- spelled out, because
deriving it from the Perl name would produce C<Ipv6Gateway>.
=head2 global_ipv6_address
Global IPv6 address. Serialised as C<GlobalIPv6Address> -- spelled out,
because deriving it from the Perl name would produce C<GlobalIpv6Address>.
=head2 global_ipv6_prefix_len
Mask length of the global IPv6 address. Serialised as C<GlobalIPv6PrefixLen>
-- spelled out, because deriving it from the Perl name would produce
C<GlobalIpv6PrefixLen>.
=head2 dns_names
List of all DNS names an endpoint has on a specific network. This list is
based on the container name, network aliases, container short ID, and
hostname.
These DNS names are non-fully qualified but can contain several dots. You
lib/API/Docker/Type/Mount/TmpfsOptions.pm view on Meta::CPAN
=head2 mode
The permission mode for the tmpfs mount in an integer. The value must not be
in octal format (e.g. 755) but rather the decimal representation of the
octal value (e.g. 493).
=head2 options
The options to be passed to the tmpfs mount. An array of arrays. Flag
options should be provided as 1-length arrays. Other types should be
provided as as 2-length arrays, where the first item is the key and the
second the value. For example: C<< [["noexec"], ["size", "64m"]] >>.
=head1 SUPPORT
=head2 Issues
Please report bugs and feature requests on GitHub at
L<https://github.com/Getty/p5-api-docker/issues>.
=head1 CONTRIBUTING
lib/API/Docker/Type/NetworkContainer.pm view on Meta::CPAN
because deriving it from the Perl name would produce C<EndpointId>.
=head2 mac_address
Undocumented upstream. The endpoint's MAC address on this network, which the
swagger notes under L<API::Docker::Type::EndpointSettings/mac_address> a
network driver may ignore.
=head2 ipv4_address
Undocumented upstream. The address with its prefix length, C<172.19.0.2/16>
in the swagger's example -- not the bare address
L<API::Docker::Type::EndpointSettings/ip_address> carries. Serialised as
C<IPv4Address> -- spelled out, because deriving it from the Perl name would
produce C<Ipv4Address>.
=head2 ipv6_address
Undocumented upstream. The IPv6 counterpart of L</ipv4_address>, empty in
the swagger's example, where the network has none. Serialised as
C<IPv6Address> -- spelled out, because deriving it from the Perl name would
lib/API/Docker/Type/NetworkSettings.pm view on Meta::CPAN
=head2 link_local_ipv6_address
IPv6 unicast address using the link-local prefix.
Deprecated: This field is never set and will be removed in a future release.
Serialised as C<LinkLocalIPv6Address> -- spelled out, because deriving it
from the Perl name would produce C<LinkLocalIpv6Address>.
=head2 link_local_ipv6_prefix_len
Prefix length of the IPv6 unicast address.
Deprecated: This field is never set and will be removed in a future release.
Serialised as C<LinkLocalIPv6PrefixLen> -- spelled out, because deriving it
from the Perl name would produce C<LinkLocalIpv6PrefixLen>.
=head2 ports
PortMap describes the mapping of container ports to host ports, using the
container's port-number and protocol as key in the format C<<
<port>/<protocol> >>, for example, C<80/udp>.
lib/API/Docker/Type/NetworkSettings.pm view on Meta::CPAN
> B<Deprecated>: This field is only propagated when attached to the >
default "bridge" network. Use the information from the "bridge" > network
inside the C<Networks> map instead, which contains the same > information.
This field was deprecated in Docker 1.9 and is scheduled > to be removed in
Docker 17.12.0. Serialised as C<GlobalIPv6Address> -- spelled out, because
deriving it from the Perl name would produce C<GlobalIpv6Address>.
=head2 global_ipv6_prefix_len
Mask length of the global IPv6 address.
> B<Deprecated>: This field is only propagated when attached to the >
default "bridge" network. Use the information from the "bridge" > network
inside the C<Networks> map instead, which contains the same > information.
This field was deprecated in Docker 1.9 and is scheduled > to be removed in
Docker 17.12.0. Serialised as C<GlobalIPv6PrefixLen> -- spelled out, because
deriving it from the Perl name would produce C<GlobalIpv6PrefixLen>.
=head2 ip_address
lib/API/Docker/Type/NetworkSettings.pm view on Meta::CPAN
> B<Deprecated>: This field is only propagated when attached to the >
default "bridge" network. Use the information from the "bridge" > network
inside the C<Networks> map instead, which contains the same > information.
This field was deprecated in Docker 1.9 and is scheduled > to be removed in
Docker 17.12.0. Serialised as C<IPAddress> -- spelled out, because deriving
it from the Perl name would produce C<IpAddress>.
=head2 ip_prefix_len
Mask length of the IPv4 address.
> B<Deprecated>: This field is only propagated when attached to the >
default "bridge" network. Use the information from the "bridge" > network
inside the C<Networks> map instead, which contains the same > information.
This field was deprecated in Docker 1.9 and is scheduled > to be removed in
Docker 17.12.0. Serialised as C<IPPrefixLen> -- spelled out, because
deriving it from the Perl name would produce C<IpPrefixLen>.
=head2 ipv6_gateway
lib/API/Docker/Type/Resources.pm view on Meta::CPAN
Limit write rate (IO per second) to a device, in the form:
[{"Path": "device_path", "Rate": rate}]
See L<API::Docker::Type::ThrottleDevice>. Serialised as
C<BlkioDeviceWriteIOps> -- spelled out, because deriving it from the Perl
name would produce C<BlkioDeviceWriteIops>.
=head2 cpu_period
The length of a CPU period in microseconds.
=head2 cpu_quota
Microseconds of CPU time that the container can get in a CPU period.
=head2 cpu_realtime_period
The length of a CPU real-time period in microseconds. Set to 0 to allocate
no time allocated to real-time tasks.
=head2 cpu_realtime_runtime
The length of a CPU real-time runtime in microseconds. Set to 0 to allocate
no time allocated to real-time tasks.
=head2 cpuset_cpus
CPUs in which to allow execution (e.g., C<0-3>, C<0,1>).
=head2 cpuset_mems
Memory nodes (MEMs) in which to allow execution (0-3, 0,1). Only effective
on NUMA systems.
lib/API/Docker/Type/Service/Endpoint/VirtualIP.pm view on Meta::CPAN
=head2 network_id
Undocumented upstream. The network the address is on,
C<4qvuz4ko70xaltuqbt8956gd1> on both entries of the swagger's C<Service>
example. Serialised as C<NetworkID> -- spelled out, because deriving it from
the Perl name would produce C<NetworkId>.
=head2 addr
Undocumented upstream. The address with its prefix length, C<10.255.0.2/16>
in that example -- CIDR, the way
L<API::Docker::Type::NetworkContainer/ipv4_address> is and
L<API::Docker::Type::EndpointSettings/ip_address> is not.
=head1 SUPPORT
=head2 Issues
Please report bugs and feature requests on GitHub at
L<https://github.com/Getty/p5-api-docker/issues>.
t/containers_endpoints.t view on Meta::CPAN
# the file's real permissions (0644, not the guessed 0664). Block size (512),
# the two trailing all-zero blocks that end the archive, the ustar
# magic/version, and the empty prefix field were already right in the
# hand-built one.
#
# karr k62 re-measured the same archive live on Podman 5.8.4 (API 1.44):
# uname/gname now come back NUL rather than 'root', byte-identical to a
# Docker 29.7.2 capture of the same file -- Podman changed to match Docker
# 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
t/containers_endpoints.t view on Meta::CPAN
sub fake_client {
return Test::ContainersEndpoints::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
}
# ---------------------------------------------------------------------------
subtest 'the tar fixture really is a tar, so byte-exactness means something' => sub {
is length($TAR) % 512, 0, 'a whole number of 512-byte blocks';
is substr($TAR, 257, 5), 'ustar', 'ustar magic in the header block';
is unpack('Z100', $TAR), 'hostname', 'one member, named after the basename';
like $TAR, qr/\0/, 'carries NUL bytes -- it is not text';
};
# ===========================================================================
# karr k18 -- the archive endpoints
# ===========================================================================
subtest 'get_archive: asks for raw bytes and hands them back untouched' => sub {
t/containers_endpoints.t view on Meta::CPAN
},
);
my $out = $docker->containers->get_archive('deadbeef', path => '/etc/hostname');
ok $seen{raw}, 'the request asked the transport for raw bytes';
ok !$seen{ndjson}, 'and not for a decoded event stream';
is_deeply $seen{params}, { path => '/etc/hostname' },
'path is the only query parameter';
is $out, $TAR, 'the daemon bytes come back verbatim';
is length($out), length($TAR), 'no truncation';
};
subtest 'get_archive: raw bytes survive the real _request' => sub {
my $t = fake_client();
$t->canned([200, 'OK', { 'content-type' => 'application/x-tar' }, $TAR]);
my $out = $t->containers->get_archive('deadbeef', path => '/var/log/app.log');
is $out, $TAR, 'byte-exact through _request';
is $t->request_line,
t/containers_endpoints.t view on Meta::CPAN
subtest 'put_archive: the tar is the request body, the options are the query' => sub {
my $t = fake_client();
$t->canned([200, 'OK', {}, '']);
my $out = $t->containers->put_archive('deadbeef', $TAR, path => '/opt/app');
is $out, undef, 'a success carries no body, so there is nothing to return';
is $t->request_line, 'PUT /v1.41/containers/deadbeef/archive?path=/opt/app HTTP/1.1',
'PUT on the archive path';
like $t->written, qr{Content-Type: application/x-tar\r\n}, 'sent as a tar';
like $t->written, qr{Content-Length: @{[ length $TAR ]}\r\n}, 'the whole archive';
is $t->request_body, $TAR, 'the request body is the archive byte for byte';
};
subtest 'put_archive: noOverwriteDirNonDir and copyUIDGID' => sub {
my $t = fake_client();
$t->canned([200, 'OK', {}, '']);
$t->containers->put_archive('deadbeef', $TAR,
path => '/opt/app', noOverwriteDirNonDir => 1, copyUIDGID => 1);
is $t->request_line,
t/containers_endpoints.t view on Meta::CPAN
subtest 'export: raw tar bytes, never decoded' => sub {
my $t = fake_client();
$t->canned([200, 'OK', { 'content-type' => 'application/x-tar' }, $TAR]);
my $out = $t->containers->export('deadbeef');
is $t->request_line, 'GET /v1.41/containers/deadbeef/export HTTP/1.1',
'GET on the export path';
is $out, $TAR, 'byte-exact';
is length($out), length($TAR), 'no truncation';
$t->canned([200, 'OK', {}, '{"Id":"not really a tar"}']);
is ref $t->containers->export('deadbeef'), '',
'a JSON-shaped body is not decoded either';
my $err = do { local $@; eval { $t->containers->export }; $@ };
like $err, qr/Container ID required/, 'a missing id croaks';
};
subtest 'resize: form-identical to the one Exec already had' => sub {
t/containers_endpoints.t view on Meta::CPAN
if ($engine eq 'podman') {
ok $dir_stat->{isDir}, 'Podman: /etc is a directory';
}
else {
ok !exists $dir_stat->{isDir}, 'Docker: isDir is absent for a directory too';
}
my $tar = $docker->containers->get_archive($container->id,
path => '/etc/hostname');
ok defined $tar && length $tar, 'get_archive returned bytes';
is length($tar) % 512, 0, 'a whole number of tar blocks';
is substr($tar, 257, 5), 'ustar', 'ustar magic';
is unpack('Z100', $tar), 'hostname', 'the member is the basename';
};
subtest 'live write: stat_archive on a symlink diverges by engine (karr k36)' => sub {
plan skip_all => 'live only' unless is_live();
plan skip_all => 'write tests off' unless can_write();
my $docker = test_docker();
my ($base) = grep { $_->repo_tags && @{ $_->repo_tags } } @{ $docker->images->list };
'DELETE /images/nginx:latest' => [
{ Untagged => 'nginx:latest' },
{ Deleted => 'sha256:abc123' },
],
);
if (is_live()) {
my $dockerfile = "FROM alpine:latest\nRUN echo 'hello from api-docker-test'\n";
my $filename = 'Dockerfile';
my $size = length($dockerfile);
my $header = pack('a100', $filename);
$header .= pack('a8', sprintf('%07o', 0644));
$header .= pack('a8', sprintf('%07o', 0));
$header .= pack('a8', sprintf('%07o', 0));
$header .= pack('a12', sprintf('%011o', $size));
$header .= pack('a12', sprintf('%011o', time()));
$header .= ' ';
$header .= '0';
$header .= pack('a100', '');
$header .= pack('a6', 'ustar');
$header .= pack('a2', '00');
$header .= pack('a32', '');
$header .= pack('a32', '');
$header .= pack('a8', '');
$header .= pack('a8', '');
$header .= pack('a155', '');
$header .= "\0" x (512 - length($header));
my $checksum = 0;
$checksum += ord(substr($header, $_, 1)) for 0..511;
substr($header, 148, 8, sprintf('%06o', $checksum) . "\0 ");
my $tar = $header;
$tar .= $dockerfile;
$tar .= "\0" x (512 - ($size % 512)) if $size % 512;
$tar .= "\0" x 1024;
t/images_push_auth.t view on Meta::CPAN
# The engine decodes X-Registry-Auth with Go's base64.URLEncoding, which
# requires padding; RawURLEncoding is what accepts it without. Measured, not
# deduced: with the '=' stripped, a push against a local registry answers
# 400 'failed to parse "X-Registry-Auth" header ... unexpected EOF', and the
# anonymous case is the shortest and most certain to need a pad -- '{}'
# encodes to three characters plus one '='.
subtest 'the header carries its base64 padding' => sub {
my $hdr = $images->_registry_auth_header(undef);
is $hdr, 'e30=', 'anonymous auth is exactly the padded encoding of {}';
is length($hdr) % 4, 0, 'length is a multiple of four';
my $creds = $images->_registry_auth_header(
{ username => 'me', password => 'secret' });
is length($creds) % 4, 0, 'credentials are padded too';
};
subtest 'empty/undef auth -> base64url("{}")' => sub {
my $hdr = $images->_registry_auth_header(undef);
ok length($hdr), 'header is non-empty for undef';
is_deeply(decode_json(b64url_decode($hdr)), {},
'decodes to empty JSON object');
};
subtest 'hashref auth -> JSON-encoded credentials' => sub {
my $auth = {
username => 'me',
password => 'secret',
serveraddress => 'https://index.docker.io/v1/',
};
t/images_tar.t view on Meta::CPAN
sub fake_client {
return Test::ImagesTar::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
}
# ---------------------------------------------------------------------------
subtest 'the fixture really is a tar, so byte-exactness means something' => sub {
cmp_ok length($TAR), '>', 512, 'more than one tar block';
is length($TAR) % 512, 0, 'a whole number of 512-byte blocks';
is substr($TAR, 257, 5), 'ustar', 'ustar magic in the first header block';
like $TAR, qr/manifest\.json/, 'carries a manifest member';
like $TAR, qr/\0/, 'carries NUL bytes -- it is not text';
};
# ---------------------------------------------------------------------------
subtest 'get: asks for the export path and does not decode the answer' => sub {
plan skip_all => 'route assertions are fixture-only' if is_live();
my %seen;
t/images_tar.t view on Meta::CPAN
%seen = %opts;
return $TAR;
},
);
my $out = $docker->images->get('alpine:3');
ok $seen{raw}, 'the request asked the transport for raw bytes';
ok !$seen{ndjson}, 'and not for a decoded event stream';
is $out, $TAR, 'the daemon bytes come back verbatim';
is length($out), length($TAR), 'no truncation';
my $err = do { local $@; eval { $docker->images->get }; $@ };
like $err, qr/Image name required/, 'a missing name croaks';
};
# ---------------------------------------------------------------------------
subtest 'get: raw bytes survive the real _request' => sub {
my $t = fake_client();
$t->canned([200, 'OK', { 'content-type' => 'application/octet' }, $TAR]);
t/images_tar.t view on Meta::CPAN
my $events = $t->images->load($TAR);
is ref $events, 'ARRAY', 'an ArrayRef of events, as for build and pull';
is scalar @$events, 1, 'the captured stream carried one object';
like $events->[0]{stream}, qr/^Loaded image: /, 'and it names what was loaded';
my $req = $t->written;
like $req, qr{\APOST /v1\.41/images/load HTTP/1\.1\r\n},
'no query string when quiet was not asked for';
like $req, qr{Content-Type: application/x-tar\r\n}, 'sent as a tar';
like $req, qr{Content-Length: @{[ length $TAR ]}\r\n}, 'the whole archive';
my ($body) = $req =~ /\r\n\r\n(.*)\z/s;
is $body, $TAR, 'the request body is the archive byte for byte';
};
subtest 'load: takes a scalar ref too, the way build takes its context' => sub {
my $t = fake_client();
$t->canned([200, 'OK', {}, $STREAM]);
$t->images->load(\$TAR);
t/images_tar.t view on Meta::CPAN
plan skip_all => 'live only' unless is_live();
my $docker = test_docker();
my ($smallest) = sort { ($a->size // 0) <=> ($b->size // 0) }
grep { $_->repo_tags && @{ $_->repo_tags } } @{ $docker->images->list };
plan skip_all => 'no tagged image on the daemon' unless $smallest;
my $name = $smallest->repo_tags->[0];
my $tar = $docker->images->get($name);
ok defined $tar && length $tar, "exported $name";
is length($tar) % 512, 0, 'a whole number of tar blocks';
is substr($tar, 257, 5), 'ustar', 'ustar magic';
like $tar, qr/manifest\.json/, 'carries a manifest member';
};
done_testing;
t/mock_harness.t view on Meta::CPAN
# _mock_docker), so a route with a metacharacter has to be probed with a
# path that is close but not identical to reach it -- an exact hit never
# touches the regex at all.
check_live_access();
subtest 'a route key with a regex metacharacter is matched literally, not as a pattern' => sub {
plan skip_all => 'the mock route table is bypassed in live mode' if is_live();
# sha256:deadbeef.dead and sha256:deadbeefXdead differ only at the dot's
# position and are otherwise the same length. Unescaped,
# m{^sha256:deadbeef.dead$} reads the dot as "any character" and matches
# the second string too, even though only the first was ever registered.
my $docker = test_docker(
'GET /images/sha256:deadbeef.dead/json' => { Id => 'the-dotted-one' },
);
my $exact = $docker->images->inspect('sha256:deadbeef.dead');
is $exact->id, 'the-dotted-one',
'the literal path the route key names still matches -- this one is an '
. 'exact hash hit and never reaches the regex fallback at all';
my $err = do {
local $@;
eval { $docker->images->inspect('sha256:deadbeefXdead') };
$@;
};
like $err, qr/No mock route for/,
'a path that only accidentally resembles the route key -- same length, '
. 'differing at exactly the position the "." sat -- is refused rather '
. 'than matched. On the old, unescaped fallback this returned '
. 'the-dotted-one instead of croaking: a route for one image answering '
. 'for a different one';
};
done_testing;
t/plugins.t view on Meta::CPAN
'privileges normalises it to the empty list it means';
};
subtest 'privileges: auth is sent as padded base64url X-Registry-Auth' => sub {
my $c = fake_client('[]');
$c->plugins->privileges('private.example.com/p/sshfs',
auth => { username => 'me', password => 'secret' });
my ($hdr) = $c->written =~ /^X-Registry-Auth: (\S+)\r$/m;
ok defined $hdr, 'header present when auth was given';
is length($hdr) % 4, 0, 'padded, as Go base64.URLEncoding requires';
is_deeply decode_json(b64url_decode($hdr)),
{ username => 'me', password => 'secret' },
'header decodes to the credentials passed';
};
# ---------------------------------------------------------------------------
subtest 'install: the privilege list is the request body' => sub {
my $c = fake_client(qq({"status":"Pulling plugin"}\n));
my $events = $c->plugins->install('vieux/sshfs:latest',
privileges => $PRIVILEGES);
t/read_timeout.t view on Meta::CPAN
$! = $act->{timeout} ? Errno::EAGAIN()
: $act->{eintr} ? Errno::EINTR()
: 0;
}
return undef if $act->{timeout} || $act->{eintr} || $act->{fail};
my $data = defined $act->{line} ? $act->{line}
: defined $act->{bytes} ? $act->{bytes} : '';
$_[1] = $data;
# 0 is the clean end of the response, and is the only thing that means it.
return length $data;
}
sub CLOSE { 1 }
package main;
my $client = API::Docker->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
t/read_timeout.t view on Meta::CPAN
sub { $client->_read_head($_[0], $_[1]) },
raises => sub {
like $_[0], qr/No response from Docker daemon/,
'a daemon that closed without answering still says so, and says '
. 'something else than a timeout';
};
# The head, which karr k73 brought under the same check. These two sites used
# to be `returns` -- the first asserting that 'HTTP/1.1 20' came back as the
# status '20', the second that the headers arriving before the cut were kept
# -- on the reading that nothing in a head announces its own length, so there
# was no announcement to hold a short one against. What replaces that claim is
# not a softer version of it but its opposite: a head is framed by its
# terminators rather than by a length, so an end of stream where one belongs
# is decidable without anything to compare, exactly as it is for a chunk
# header one level down. Both are now `raises`.
#
# The claim these sites carry over is the one the file exists for, and it is
# untouched: the two meanings of the same empty read still come out
# differently, and only EAGAIN is a timeout. That distinction lives in _pull,
# below the new check, so nothing about it moved.
site_ok '_read_head: half a status line',
sub { scripted({ line => 'HTTP/1.1 20' }, { timeout => $_[0] }) },
sub { $client->_read_head($_[0], $_[1]) },
t/read_timeout.t view on Meta::CPAN
# _read_body -- the three shapes a buffered body comes in
# ---------------------------------------------------------------------------
# The four sites karr k64 changed. Each of them used to assert that the short
# read at a real end of response was RETURNED -- 'hello wor', 'hello' -- and
# named that as the silent loss the errno check could not prevent, the errno
# check being about a timeout and this being about a close. The claim that
# survives is the one those assertions were making about the timeout: the two
# meanings of an empty read still come out differently, and only one of them
# is a timeout. What is replaced is the other half of each pair, which is now
# an API::Docker::Error::Truncated instead of a value.
site_ok '_read_body: a content-length body stops short',
sub {
scripted({ bytes => 'hello ' }, { bytes => 'wor' },
{ timeout => $_[0] });
},
sub {
$client->_read_body($_[0], { 'content-length' => 11 }, 'GET', $_[1]);
},
raises => truncated_ok(phase => 'content-length', partial => 'hello wor');
# The one body shape where an EOF is the end and must stay one: nothing was
# announced, so there is nothing to be short of.
site_ok '_read_body: a close-delimited body stops short',
sub { scripted({ bytes => 'partial frames' }, { timeout => $_[0] }) },
sub { $client->_read_body($_[0], {}, 'GET', $_[1]) },
returns => sub {
my ($out) = @_;
is $out->[0], 'partial frames', 'the bytes are the body at a real close';
};
t/read_timeout.t view on Meta::CPAN
drive_stream(\@got),
raises => sub {
truncated_ok(phase => 'chunk-terminator', partial => '',
summary => { delivered => 1, stopped => 0 })->(@_);
is_deeply \@got, ['hello', 'hello'], 'the chunk was delivered';
};
}
{
my @got;
site_ok 'streaming, content-length: the body stops short',
sub {
scripted($HEAD_OK, { line => "Content-Length: 11\r\n" }, $BLANK,
{ bytes => 'hello ' }, { bytes => 'wor' }, { timeout => $_[0] });
},
drive_stream(\@got),
raises => sub {
truncated_ok(phase => 'content-length', partial => '',
summary => { delivered => 2, stopped => 0 })->(@_);
is_deeply \@got, ['hello ', 'wor', 'hello ', 'wor'],
'same on the content-length path: nothing that arrived is dropped '
. 'because the rest of it did not';
};
}
# And the streamed half of the one shape with no announcement to fall short
# of. This is the raw-stream path -- attach, logs(follow), exec/start -- where
# a close is how every one of them finishes.
{
my @got;
site_ok 'streaming, close-delimited: the body stops short',
t/read_timeout.t view on Meta::CPAN
'and on the raw-stream path, which is the one karr k52 hangs on and '
. 'where it matters most -- and where the two bursts are two calls '
. 'rather than one 64K read, which is karr k60';
};
}
# ---------------------------------------------------------------------------
# What the exception carries
# ---------------------------------------------------------------------------
subtest 'the bytes that did arrive come out with the exception' => sub {
subtest 'a content-length body' => sub {
my $fh = scripted({ bytes => 'hello ' }, { bytes => 'wor' },
{ timeout => 1 });
eval { $client->_read_body($fh, { 'content-length' => 11 }, 'GET', ctx()) };
my $err = $@;
isa_ok $err, 'API::Docker::Error::Timeout';
is attr($err, "partial"), 'hello wor',
'everything read so far, the bytes of the read that expired included';
is attr($err, "summary"), undef, 'no summary: nothing was streamed';
};
subtest 'a chunked body keeps the chunk it stalled inside' => sub {
my $fh = scripted({ line => "5\r\n" }, { bytes => 'hello' },
{ line => "\r\n" }, { line => "6\r\n" }, { bytes => ' wor' },
t/role_http.t view on Meta::CPAN
sub TIEHANDLE {
my ($class, $data, $step) = @_;
return bless { buf => $data, pos => 0, step => defined $step ? $step : 0 },
$class;
}
sub READ {
my $self = $_[0];
my $len = $_[2];
my $offset = $_[3] || 0;
my $avail = length($self->{buf}) - $self->{pos};
return 0 if $avail <= 0;
my $n = $len;
$n = $self->{step} if $self->{step} && $n > $self->{step};
$n = $avail if $n > $avail;
my $chunk = substr($self->{buf}, $self->{pos}, $n);
if ($offset) {
substr($_[1], $offset, $n) = $chunk;
}
else {
$_[1] = $chunk;
t/role_http.t view on Meta::CPAN
. "{}";
my $fh = string_handle($raw);
my $resp = $client->_read_response($fh);
my $headers = $resp->[2];
is $headers->{'content-type'}, 'application/json',
'an already-lowercase key is kept';
is $headers->{'x-mixed-case'}, 'value with spaces ',
'a mixed-case key is lowercased; leading space after the colon is '
. 'trimmed, the rest of the value is kept verbatim (trailing spaces too)';
is $headers->{'content-length'}, '2', 'colon splits key from value';
is $resp->[3], '{}', 'body still decoded via content-length';
};
subtest '_read_response: chunked body' => sub {
my $raw = "HTTP/1.1 200 OK\r\n"
. "Transfer-Encoding: chunked\r\n"
. "\r\n"
. "5\r\nhello\r\n"
. "6\r\n world\r\n"
. "0\r\n\r\n";
my $fh = string_handle($raw);
my $resp = $client->_read_response($fh);
is $resp->[3], 'hello world', 'chunks concatenated, chunk framing stripped';
};
subtest '_read_response: content-length body' => sub {
# Embedded CRLF and a NUL byte prove this is a byte-exact length read, not
# a line-oriented one.
my $body = "line1\r\nline2\x00tail";
my $raw = 'HTTP/1.1 200 OK' . "\r\n"
. 'Content-Length: ' . length($body) . "\r\n"
. "\r\n"
. $body;
my $fh = string_handle($raw);
my $resp = $client->_read_response($fh);
is $resp->[3], $body,
'exactly content-length bytes read, embedded CRLF/NUL preserved';
};
subtest '_read_response: read-to-EOF fallback' => sub {
# Neither Transfer-Encoding nor Content-Length -- the pre-HTTP/1.1-ish
# case where the body is "whatever remains until the connection closes".
my $raw = "HTTP/1.1 200 OK\r\n"
. "Connection: close\r\n"
. "\r\n"
. "no length header, read until eof";
my $fh = string_handle($raw);
my $resp = $client->_read_response($fh);
is $resp->[3], 'no length header, read until eof',
'falls back to slurping the rest of the socket';
};
# ---------------------------------------------------------------------------
# HTTP field values are case-insensitive (RFC 9110 section 5.6.2). A daemon or
# a proxy in front of it may write Transfer-Encoding in any case; the value is
# compared with lc() so that a body announced as chunked is dechunked whatever
# the spelling. Before this, a value that was not exactly 'chunked' fell
# through to the close-delimited branch and the raw chunk framing came back as
# the body.
t/role_http.t view on Meta::CPAN
is $client->_read_chunked($fh), '0123456789ABCDEFGHIJ',
'lowercase and uppercase hex chunk sizes both read correctly';
};
subtest '_read_chunked: a single zero-size chunk terminates immediately' => sub {
my $fh = string_handle("0\r\n\r\n");
is $client->_read_chunked($fh), '', 'empty body, no chunks';
};
subtest '_read_chunked: a chunk arriving in several reads' => sub {
my $data = "b\r\nhello world\r\n0\r\n\r\n"; # 'b' hex = 11 = length("hello world")
tie *FH, 'Test::RoleHTTP::PartialReader', $data, 3; # 3 bytes per read() call
my $body = $client->_read_chunked(\*FH);
is $body, 'hello world',
'chunk payload reassembled correctly across multiple short reads';
untie *FH;
};
# ---------------------------------------------------------------------------
subtest '_uri_encode: what it escapes and what it leaves alone' => sub {
# Called as a bare function everywhere in the module (see _request's
t/role_http.t view on Meta::CPAN
like $req, qr{\r\n\r\n\z}, 'request ends on the blank line, empty body';
};
subtest 'POST with a JSON body' => sub {
$t->_request('POST', '/containers/create', body => { Image => 'alpine:3' });
my $req = $t->written;
my $encoded = encode_json({ Image => 'alpine:3' });
like $req, qr{\APOST /v1\.41/containers/create HTTP/1\.1\r\n},
'request line for the POST';
like $req, qr{Content-Type: application/json\r\n}, 'JSON content type';
like $req, qr{Content-Length: @{[ length $encoded ]}\r\n},
'content-length matches the encoded body';
like $req, qr{\r\n\r\n\Q$encoded\E\z}, 'body follows the blank line verbatim';
};
subtest 'raw_body + content_type (tarball upload)' => sub {
my $tar = "fake tar bytes\0\0\0";
$t->_request('POST', '/build', raw_body => $tar, content_type => 'application/x-tar');
my $req = $t->written;
like $req, qr{Content-Type: application/x-tar\r\n},
'content type overridden for a raw body, not left as application/json';
like $req, qr{Content-Length: @{[ length $tar ]}\r\n},
'content-length matches the raw body, not a JSON encoding of it';
like $req, qr{\r\n\r\n\Q$tar\E\z}, 'raw bytes appended verbatim';
};
subtest 'params: sorted, hashref values JSON-encoded, then URI-encoded' => sub {
$t->_request('GET', '/images/json',
params => { all => 1, filters => { dangling => ['true'] } });
my $req = $t->written;
my ($request_line) = $req =~ /\A(GET [^\r\n]+)\r\n/;
my $expected_filters = API::Docker::Role::HTTP::_uri_encode(
encode_json({ dangling => ['true'] }));
t/role_http.t view on Meta::CPAN
# is exactly the bug, and a handle that simply hit EOF there would hide it (the
# old code returned '' there too, from a read that failed rather than from one
# it never made).
#
# Where "not consumed" is now read: since karr k60 the read-ahead past the
# header block sits in the transport's own buffer rather than in PerlIO's, so
# the question is asked of that buffer. The claim is unchanged -- these bytes
# were not taken as a body -- and it is now asked somewhere this code owns
# rather than of a buffer it could not see into.
subtest '_read_response: a HEAD response has no body, whatever it announces' => sub {
subtest 'an announced content-length is not read' => sub {
my $raw = "HTTP/1.1 200 OK\r\n"
. "Content-Length: 13\r\n"
. "X-Docker-Container-Path-Stat: e30=\r\n"
. "\r\n"
. 'NOT-THE-BODY!';
my $fh = string_handle($raw);
my $resp = $client->_read_response($fh, 'HEAD');
is $resp->[3], '', 'body is empty';
is $resp->[2]{'content-length'}, '13',
'the announced length is still collected as a header';
is $resp->[2]{'x-docker-container-path-stat'}, 'e30=',
'and so is the header a HEAD response carries its payload in';
is unconsumed($client, $fh), 'NOT-THE-BODY!',
'the bytes after the headers are still unconsumed, not swallowed as a '
. 'body that was never sent';
};
subtest 'a chunked announcement is not read either' => sub {
my $raw = "HTTP/1.1 200 OK\r\n"
. "Transfer-Encoding: chunked\r\n"
t/stream_error.t view on Meta::CPAN
};
}
# A minimal ustar archive holding one file, so the live build needs no
# Archive::Tar.
sub _tar_context {
my ($content) = @_;
my $name = 'Dockerfile';
my $header = pack 'a100 a8 a8 a8 a12 a12 A8 a1 a100 a255',
$name, '0000644', '0000000', '0000000',
sprintf('%011o', length $content), sprintf('%011o', time),
'', '0', '', '';
my $checksum = 0;
$checksum += $_ for unpack 'C*', $header;
substr($header, 148, 8) = sprintf('%06o', $checksum) . "\0 ";
my $body = $content . "\0" x ((512 - length($content) % 512) % 512);
return $header . $body . "\0" x 1024;
}
done_testing;
t/stream_frames.t view on Meta::CPAN
host => 'unix:///var/run/docker.sock',
api_version => '1.41',
);
my $MULTIPLEXED = load_fixture_raw('containers_logs_multiplexed.bin');
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, [
t/stream_incremental.t view on Meta::CPAN
my ($class, $bursts) = @_;
return bless { bursts => $bursts, i => 0 }, $class;
}
sub READ {
my $self = $_[0];
my $data = $self->{bursts}[ $self->{i}++ ];
return 0 unless defined $data; # the end of the response
$! = 0;
$_[1] = $data;
return length $data;
}
sub CLOSE { 1 }
package main;
sub burst_handle {
my (@bursts) = @_;
my $fh = \do { no warnings 'once'; local *HANDLE };
tie *$fh, 'Test::Incremental::Handle', \@bursts;
t/stream_incremental.t view on Meta::CPAN
api_version => '1.41',
);
my $HEAD = "HTTP/1.1 200 OK\r\n"
. "Content-Type: application/vnd.docker.raw-stream\r\n"
. "\r\n";
# One 8-byte-framed stdout frame, as logs and attach send them.
sub frame {
my ($text) = @_;
return pack('C4 N', 1, 0, 0, 0, length $text) . $text;
}
# ---------------------------------------------------------------------------
subtest 'a raw stream delivers one call per arrival, not one per 64K' => sub {
my @got;
my $fh = burst_handle($HEAD, 'first ', 'second ', 'third');
my $handler = $client->_stream_handler('GET /v1.41/probe', 'on_chunk',
sub { push @got, $_[0] }, 1);
$client->_read_streaming_response($fh, 'GET', $handler, {});
t/stream_incremental.t view on Meta::CPAN
};
subtest 'one byte per read: every structure boundary is fragmented' => sub {
# The cheapest way to say "no reader assumes anything arrives whole". At one
# byte per read the status line, each header line, the blank line, the chunk
# header, the chunk data, the CRLF after it and the terminating zero chunk
# are each split across several reads -- and the 8-byte frame header inside
# the payload is split eight ways.
my $wire = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n";
my $body = frame('one') . frame('two');
$wire .= sprintf("%x\r\n", length $body) . $body . "\r\n0\r\n\r\n";
my @got;
my $fh = burst_handle(split //, $wire);
my $handler = $client->_stream_handler('GET /v1.41/probe', 'on_frame',
sub { push @got, $_[0] }, 1);
$client->_read_streaming_response($fh, 'GET', $handler, {});
is_deeply \@got,
[ { stream => 'stdout', data => 'one' },
t/streaming_callback.t view on Meta::CPAN
sub _exhausted {
my ($self) = @_;
die "Test::Stream::Handle: the transport read past the end of the scripted "
. "stream\n" if $self->{at_end} eq 'die';
return;
}
sub READLINE {
my ($self) = @_;
if ($self->{pos} >= length $self->{buf}) {
$self->_exhausted;
return undef;
}
my $idx = index($self->{buf}, "\n", $self->{pos});
my $end = $idx == -1 ? length($self->{buf}) : $idx + 1;
my $line = substr($self->{buf}, $self->{pos}, $end - $self->{pos});
$self->{pos} = $end;
return $line;
}
sub READ {
my $self = $_[0];
my $len = $_[2];
my $offset = $_[3] || 0;
my $avail = length($self->{buf}) - $self->{pos};
if ($avail <= 0) {
$self->_exhausted;
return 0;
}
my $n = $len;
$n = $self->{step} if $self->{step} && $n > $self->{step};
$n = $avail if $n > $avail;
my $chunk = substr($self->{buf}, $self->{pos}, $n);
if ($offset) {
substr($_[1], $offset, $n) = $chunk;
}
else {
$_[1] = $chunk;
}
$self->{pos} += $n;
return $n;
}
sub EOF { my ($self) = @_; return $self->{pos} >= length $self->{buf} }
sub CLOSE { 1 }
# ---------------------------------------------------------------------------
package Test::Stream::Transport;
use Moo;
extends 'API::Docker';
has script => (is => 'rw', required => 1);
has step => (is => 'rw', default => sub { 0 });
has at_end => (is => 'rw', default => sub { 'eof' });
t/streaming_callback.t view on Meta::CPAN
}
# One HTTP chunk per element, and by default no terminating zero chunk: a
# stream the daemon has not finished. Pass a true $closed for one it has.
sub chunked {
my ($chunks, %args) = @_;
my $raw = "HTTP/1.1 200 OK\r\n"
. "Content-Type: application/json\r\n"
. "Transfer-Encoding: chunked\r\n"
. "\r\n";
$raw .= sprintf("%x\r\n%s\r\n", length($_), $_) for @$chunks;
$raw .= "0\r\n\r\n" if $args{closed};
return $raw;
}
sub sized {
my ($body, %args) = @_;
return "HTTP/1.1 " . ($args{status} // 200) . " " . ($args{reason} // 'OK') . "\r\n"
. "Content-Type: " . ($args{type} // 'application/json') . "\r\n"
. "Content-Length: " . length($body) . "\r\n"
. "\r\n"
. $body;
}
my @EVENT_LINES = map { encode_json($_) . "\n" } (
{ status => 'create', id => 'a' },
{ status => 'start', id => 'a' },
{ status => 'die', id => 'a' },
);
t/streaming_callback.t view on Meta::CPAN
subtest 'on_chunk: a Content-Length body streams too' => sub {
my $body = 'x' x 300;
my $client = transport(sized($body, type => 'application/x-tar'), step => 64);
my @got;
my $summary = $client->get('/images/x/get', on_chunk => sub { push @got, $_[0] });
is scalar(@got) > 1, 1, 'a sized body is read in slices, not in one gulp';
is join('', @got), $body, 'and arrives whole';
is $summary->{stopped}, 0, 'the announced length is what ended it';
};
subtest 'on_chunk: stopping leaves the rest of a sized body unread' => sub {
my $client = transport(sized('y' x 300, type => 'application/x-tar'),
step => 64, at_end => 'die');
my @got;
my $summary = $client->get('/images/x/get',
on_chunk => sub { my ($bytes, $stop) = @_; push @got, $bytes; $stop->() });
t/streaming_methods.t view on Meta::CPAN
sub _exhausted {
my ($self) = @_;
die "Test::Method::Stream::Handle: the transport read past the end of the "
. "scripted stream\n" if $self->{at_end} eq 'die';
return;
}
sub READLINE {
my ($self) = @_;
if ($self->{pos} >= length $self->{buf}) {
$self->_exhausted;
return undef;
}
my $idx = index($self->{buf}, "\n", $self->{pos});
my $end = $idx == -1 ? length($self->{buf}) : $idx + 1;
my $line = substr($self->{buf}, $self->{pos}, $end - $self->{pos});
$self->{pos} = $end;
return $line;
}
sub READ {
my $self = $_[0];
my $len = $_[2];
my $offset = $_[3] || 0;
my $avail = length($self->{buf}) - $self->{pos};
if ($avail <= 0) {
$self->_exhausted;
return 0;
}
my $n = $len;
$n = $self->{step} if $self->{step} && $n > $self->{step};
$n = $avail if $n > $avail;
my $chunk = substr($self->{buf}, $self->{pos}, $n);
if ($offset) {
substr($_[1], $offset, $n) = $chunk;
}
else {
$_[1] = $chunk;
}
$self->{pos} += $n;
return $n;
}
sub EOF { my ($self) = @_; return $self->{pos} >= length $self->{buf} }
sub CLOSE { 1 }
# ---------------------------------------------------------------------------
package Test::Method::Stream::Client;
use Moo;
extends 'API::Docker';
has script => (is => 'rw', required => 1);
has step => (is => 'rw', default => sub { 0 });
has at_end => (is => 'rw', default => sub { 'eof' });
t/streaming_methods.t view on Meta::CPAN
);
}
# One HTTP chunk per element, and by default no terminating zero chunk: a
# stream the daemon has not finished.
sub chunked {
my ($chunks, %args) = @_;
my $raw = "HTTP/1.1 200 OK\r\n"
. "Transfer-Encoding: chunked\r\n"
. "\r\n";
$raw .= sprintf("%x\r\n%s\r\n", length($_), $_) for @$chunks;
$raw .= "0\r\n\r\n" if $args{closed};
return $raw;
}
sub sized {
my ($body, %args) = @_;
return "HTTP/1.1 200 OK\r\n"
. "Content-Type: " . ($args{type} // 'application/json') . "\r\n"
. "Content-Length: " . length($body) . "\r\n"
. "\r\n"
. $body;
}
sub ndjson_lines {
my ($name) = @_;
return grep { /\S/ } split /(?<=\n)/, $FIXTURES->child($name)->slurp_raw;
}
# ---------------------------------------------------------------------------
t/streaming_methods.t view on Meta::CPAN
subtest 'images->get: an export that does not cost its own size in RAM' => sub {
my $tar = $FIXTURES->child('images_get.tar')->slurp_raw;
my $docker = client(sized($tar, type => 'application/x-tar'), step => 1024);
my $written = '';
my $summary = $docker->images->get('alpine:3',
on_chunk => sub { $written .= $_[0] });
ok $summary->{delivered} > 1,
'the archive arrived in pieces, not as one buffered body';
is length($written), length($tar), 'and all of it arrived';
is $written, $tar, 'byte for byte what the buffered call returns';
is $summary->{stopped}, 0, 'the announced length ended it, not the caller';
};
subtest 'images->get: the buffered call is unchanged' => sub {
my $tar = $FIXTURES->child('images_get.tar')->slurp_raw;
my $docker = client(sized($tar, type => 'application/x-tar'), step => 1024);
is $docker->images->get('alpine:3'), $tar,
'no callback: the whole archive as raw bytes';
};