API-Docker

 view release on metacpan or  search on metacpan

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

- **`prune` destroys, and `dangling => 0` destroys MORE, not less.** `POST
  /images/prune` with `filters => { dangling => ['false'] }` removes every
  unused *tagged* image on the engine, locally built ones included, and they
  are not recoverable. It reads like a narrowing filter and is the opposite.
  This has already cost a locally built image, during what its caller
  believed was a read-only probe. **No `prune` of any
  kind -- images, containers, networks, volumes, build cache -- and no
  `rm -a` or `system reset`, on either engine, ever, unless the user names
  the command.** Probing what an endpoint answers is not a reason: measure
  it against something you created yourself.
- **`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
  instruction.
- **Streaming endpoints block until the daemon closes, unless given a callback.**
  `_request` still buffers a whole response by default, so `system->events` or
  `containers->stats` without a bound and without `on_event`/`on_frame`/`on_chunk` never
  returns. Bound the window, pass a callback, or wrap a manual probe in `timeout` — a
  callback still needs `$stop->()` called from somewhere, or it runs until the daemon
  closes the connection on its own.
- **`../p5-dist-zilla-plugin-docker-api` consumes this API.** A public signature or
  return-shape change is a cross-repo change: verify that repo, or file a ticket on its

.gitignore  view on Meta::CPAN

API-Docker-*
.build

# Claude Code — commit: skills/, agents/, hooks/, settings.json
# Ignore: local overrides, credentials, session data
.claude/*.local.*
.claude/local/
.claude/.credentials.json
.claude/statsig/
.claude/todos/
.claude/projects/

# karr materialized task view -- never commit
tasks/
config.yml

CLAUDE.md  view on Meta::CPAN


2. **Use `mcp__firecrawl__firecrawl_scrape`** over `WebFetch` for fetching
   page content.

3. **Use `context7` for library docs** (CPAN, npm, etc.) — *except* this
   distribution itself. For `API::Docker` always read the local source
   under `lib/`, never context7.

4. **Untracked files that are not in `.gitignore` belong in the commit.**
   `.gitignore` is the source of truth. Only obvious secrets
   (`.env`, credentials) are excluded — and even then warn, don't silently
   drop them.

5. **Auto-Memory is for personal/user preferences only.** Project
   conventions belong in this `CLAUDE.md` or in a skill, never in
   auto-memory.

6. **Load the `getty-perl-core` skill before editing any Perl** in this
   workspace. It encodes Getty's house rules; the rules below are the
   TL;DR. The `api-docker-*` agents get it force-loaded — see
   [Delegation](#delegation).

Changes  view on Meta::CPAN

    percent-escaping, so a name or tag typed as characters (`ü`, `中`) goes
    out as valid UTF-8.
  - A request path outside the RFC 3986 origin-form character set is refused
    before it reaches the daemon, closing a request-line injection through a
    container name or image reference.
  - An ArrayRef query parameter expands into one repeated `k=v` pair per
    element (`names=a&names=b`), which some endpoints require.
  - A bare JSON scalar body (`null`, `true`, a number, a quoted string) is
    decoded rather than handed back as raw bytes; `raw` and `ndjson` return
    `''` and `[]` for a zero-byte body instead of `undef`.
  - Registry credentials reach `images->pull` (`auth`, sent as
    `X-Registry-Auth`) and `images->build` (`registry_config`, sent as
    `X-Registry-Config`), sent only when given. An already-base64 auth value
    in the standard alphabet is respelled URL-safe, which the engine
    requires.
  - `images->pull` no longer appends a default `tag` onto a reference that
    already carries a `:tag` or `@digest`.
  - New `images->get`, `->get_all` and `->load`: the image tar roundtrip in
    and out of a daemon without a registry. New `images->commit`
    (POST /commit) and `images->build_prune` (POST /build/prune, the
    BuildKit cache, a different store from the dangling images

Changes  view on Meta::CPAN

    as a reading.
  - New `API::Docker::API::Plugins` (`$docker->plugins`): `list`,
    `privileges`, `install`, `inspect`, `remove`, `enable`, `disable`,
    `upgrade`, `push` and `configure`. Needs a real Docker daemon; Podman
    serves no `/plugins`.
  - New `API::Docker::API::Secrets` and `API::Docker::API::Configs`: `list`,
    `create`, `inspect`, `update` and `remove`. `Data` is base64-encoded for
    the caller; `update` takes the current `Version.Index` as a mandatory
    concurrency token.
  - New `API::Docker::API::System::auth` (POST /auth): check registry
    credentials without pulling or pushing. A rejected credential croaks.
  - New `API::Docker::API::Distribution` (`inspect`/`exists`,
    GET /distribution/{name}/json): ask a registry for a manifest without
    pulling. `exists` answers `1`/`0` and tells a registry's own 404 apart
    from an engine that serves no such route.
  - Declare a minimum Perl of 5.014 (`s///r` in `Role::HTTP`) and add the
    core modules `Errno`, `IO::Handle`, `Scalar::Util` and `Socket` to
    `cpanfile`. Stop shipping `spec/` and `maint/` in the built dist.
  - Swarm (`/swarm`, `/nodes`, `/services`, `/tasks`) is documented as a
    permanent scope decision, not a gap: Podman implements none of it and no
    consumer needs it. `secrets` and `configs` stand on their own and stay

Changes  view on Meta::CPAN

    check is on by default for the transport's `ndjson` option and
    exempting an endpoint is deliberate (`croak_on_error => 0`), because
    the operation-shaped streaming endpoints are open-ended while the
    feed-shaped ones are `/events` and nothing else.
  - `tls => 1` now croaks with "not implemented" instead of being
    accepted and ignored. `tls` and `cert_path` were attributes no code
    read: `API::Docker::Role::HTTP` builds a plain IO::Socket::INET and
    speaks HTTP over it, so a `tcp://` daemon was always addressed in
    cleartext and a caller who asked for TLS got an unencrypted
    connection with no indication of it -- anyone passing the option was
    by definition sending credentials in the clear while believing
    otherwise. TLS is still not implemented; the croak names the reason
    and the way round it, which is to terminate TLS in front of the
    daemon (stunnel, socat, `ssh -N -L`) and point `host` at the local
    end. Both attributes are kept. `cert_path` on its own does not
    croak: it defaults from `DOCKER_CERT_PATH`, which is exported on
    plenty of machines that also run the docker CLI, so croaking on it
    would break constructions over a value the caller never passed, and
    on its own it transmits nothing and makes an unencrypted connection
    look no different. The POD called TLS "experimental", as though it
    partly worked; it never worked at all.

Changes  view on Meta::CPAN

    consulted, unlike the `docker` CLI, docker-java or Testcontainers.

0.002     2026-05-17 05:36:20Z
  - HTTP role: `_request` now accepts a `headers => {}` option to set
    extra HTTP request headers. Headers are sanitised against CR/LF
    injection. Used by `images->push` to send `X-Registry-Auth`, and
    available to any caller that needs custom headers.
  - `images->push` now always sends an `X-Registry-Auth` header — the
    Docker Engine refuses pushes without it (`HTTP 400: missing
    X-Registry-Auth: invalid X-Registry-Auth header: EOF`). A new `auth`
    option accepts a hashref of credentials (`username`, `password`,
    `serveraddress`, or `identitytoken`) which is JSON-encoded and
    base64url-wrapped per the Docker Engine spec. Without `auth` the
    header carries an empty JSON object so unauthenticated/public
    pushes succeed where they previously failed at the HTTP layer.

0.001     2026-04-29 00:40:43Z
    - Initial release as API::Docker
    - Docker Engine API client with Unix socket and TCP support
    - Auto-negotiate API version from daemon
    - Container, Image, Network, Volume, System, and Exec APIs

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

  is      => 'ro',
  default => 0,
);


sub BUILD {
  my ($self) = @_;

  # Both checks are here rather than at connect time so that a request for
  # encryption that cannot be honoured is refused before the caller has a
  # client to hand credentials to.
  croak __PACKAGE__ . '->new tls_insecure => 1 without tls => 1 does '
    . 'nothing: verification is only reachable on a connection that has TLS '
    . 'to verify. Set tls => 1 as well, or drop the option'
    if $self->tls_insecure && !$self->tls;

  return unless $self->tls;

  my $host = $self->host;
  croak __PACKAGE__ . '->new tls => 1 is only meaningful for a tcp:// host, '
    . 'and this one is ' . $host . '. A Unix socket is a file rather than a '

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


=head2 tls_insecure

Turn certificate verification off. Default C<0>. Only read when L</tls> is
set, and named for what it does.

C<< tls_insecure => 1 >> sets C<SSL_VERIFY_NONE> and drops the hostname check,
which leaves a connection encrypted against a passive listener and against
nothing else: whoever answers it chooses the certificate, so anyone able to
redirect the connection reads and rewrites everything on it -- registry
credentials, image contents, the commands containers are started with.

It exists for a self-signed daemon certificate whose CA is not to hand. The
better answer is nearly always L</cert_path>: a self-signed certificate is its
own CA and works as F<ca.pem> directly.

Setting it without L</tls> croaks, rather than being accepted and doing
nothing.

=head2 system

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


version 0.004

=head1 SYNOPSIS

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

    # Ask a registry about an image reference without pulling it
    my $descriptor = $docker->distribution->inspect('nginx:latest');

    # With registry credentials
    my $descriptor = $docker->distribution->inspect('private/app:1.0',
        auth => {
            username => 'someone',
            password => 'secret',
        },
    );

    # The same question as a predicate: is that tag already published?
    if ($docker->distribution->exists('myrepo/app:1.0', auth => $auth)) {
        die "refusing to overwrite a released tag";

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


    my %res;
    my $d = eval { $distribution->inspect($ref, response => \%res) };
    # $res{status} == 404 here means the registry said no *or* the engine
    # has no such route -- see L</exists>, which separates the two.

Options:

=over

=item * C<auth> - Registry credentials, in any shape
L<API::Docker::API::Images/push> accepts them: a HashRef of C<username> /
C<password> / C<serveraddress> / C<identitytoken>, or a pre-encoded base64
string. Sent as C<X-Registry-Auth>. Unlike C<push>, which always sends the
header, it is omitted entirely without this option -- the lookup is then
anonymous, which is what a public image needs

=item * C<response> - HashRef the status line and the response headers are
written into, as for L<API::Docker::Role::HTTP/get>

=back

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

  $params{shmsize}    = $opts{shmsize}    if defined $opts{shmsize};
  $params{networkmode} = $opts{networkmode} if defined $opts{networkmode};
  $params{platform}   = $opts{platform}   if defined $opts{platform};
  $params{target}     = $opts{target}     if defined $opts{target};

  $params{buildargs} = encode_json($opts{buildargs}) if $opts{buildargs};
  $params{labels}    = encode_json($opts{labels})    if $opts{labels};

  my $raw = ref $context eq 'SCALAR' ? $$context : $context;

  # A build's registry credentials ride in X-Registry-Config, not
  # X-Registry-Auth: the map lets `FROM private.registry/...` authenticate,
  # and a build may draw base images from several registries at once. Sent
  # only when given -- an anonymous build needs no header.
  my %headers;
  $headers{'X-Registry-Config'} =
    $self->_registry_config_header($opts{registry_config})
    if defined $opts{registry_config};

  # exists, not truth: an unset callback is a caller bug, and falling back to
  # the buffered path for it would hand a long build back as silence.

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

# reference carries neither -- a `:tag` in the segment after the last `/`, or an
# `@digest` anywhere. The colon in a registry `host:port/` is before that
# segment, so it is not mistaken for a tag.
sub _reference_has_tag_or_digest {
  my ($self, $ref) = @_;
  return 1 if $ref =~ /\@/;
  my ($last_segment) = $ref =~ m{([^/]*)\z};
  return $last_segment =~ /:/ ? 1 : 0;
}

# Only when credentials were given: an anonymous pull needs no header, and the
# engine reads X-Registry-Auth off /images/create only to reach a private
# registry. This is the plugins/distribution policy, not push's always-send --
# push must send even the anonymous {} because the engine rejects a push with
# no header at all.
sub _auth_headers {
  my ($self, $opts) = @_;
  return () unless defined $opts->{auth};
  return (headers => { 'X-Registry-Auth' => $self->_registry_auth_header($opts->{auth}) });
}

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

=item * C<cpuquota> - CPU CFS quota (microseconds)

=item * C<shmsize> - Size of /dev/shm in bytes

=item * C<networkmode> - Network mode during build

=item * C<platform> - Platform (e.g. C<linux/amd64>)

=item * C<target> - Multi-stage build target

=item * C<registry_config> - Registry credentials for the base images the build
pulls, sent as C<X-Registry-Config>. A HashRef mapping each registry hostname
to its AuthConfig --
C<< { 'registry.example:5000' => { username => 'me', password => 'secret' } } >>
-- so a C<FROM private.registry/...> can authenticate, and a build drawing from
several registries can carry all of them at once. A pre-encoded base64 string
is also accepted. Sent only when given. This is B<not> C<auth>/C<X-Registry-Auth>,
which carries a single AuthConfig; C</build> uses the map form. See
L<API::Docker::Role::RegistryAuth>

=item * C<on_event> - CodeRef called with each build event as it arrives,

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


Options:

=over

=item * C<fromImage> - Image name to pull (required)

=item * C<tag> - Tag to pull. Defaulted to C<latest> only when C<fromImage>
carries no tag or digest of its own; see above

=item * C<auth> - Registry credentials for pulling from a private registry,
sent as C<X-Registry-Auth>. A HashRef of the usual keys (C<username>,
C<password>, C<serveraddress>, or C<identitytoken>) or a pre-encoded base64
string, exactly as L</push> takes it. Unlike C<push>, the header is sent
B<only> when C<auth> is given -- an anonymous pull carries none, which the
engine reads as the anonymous case. See L<API::Docker::Role::RegistryAuth>

=item * C<on_event> - CodeRef called with each progress event as it arrives,
instead of the ArrayRef being collected and returned. The return value is then
the summary HashRef and a stream failure croaks one event in, exactly as for
L</build>; see L</"Progress as it arrives">

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

the transport's status handling croaks with an L<API::Docker::Error::HTTP> --
which is that same string to anything inspecting C<$@> as text -- before the
stream is ever decoded. That body carries no C<message> key, so the whole
JSON object ends up as the croak text.

Either way the failure is loud. Inspect C<$@> as a string rather than testing
for the exception class, which only the first route produces.

The Docker Engine requires an C<X-Registry-Auth> header on every push,
even for anonymous attempts; the header is always sent. Pass C<auth> as
a hashref of credentials (typical keys: C<username>, C<password>,
C<serveraddress>, or C<identitytoken>), or as a pre-encoded base64 string.
Without C<auth> the header carries an empty JSON object.

Options:

=over

=item * C<tag> - Tag to push

=item * C<auth> - Registry credentials, as above

=item * C<on_event> - CodeRef called with each progress event as it arrives --
layer by layer, rather than the whole upload in one silence -- instead of the
ArrayRef being collected and returned. The return value is then the summary
HashRef and a stream failure croaks one event in, exactly as for L</build>;
see L</"Progress as it arrives">

=back

=head2 tag

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

A plugin that demands nothing answers with an empty ArrayRef.

The C<remote> reference is normalised by the daemon, so C<vieux/sshfs> and
C<docker.io/vieux/sshfs:latest> name the same plugin; C<:latest> is the
default when no tag is given.

Options:

=over

=item * C<auth> - Registry credentials for a plugin in a private registry;
HashRef of C<username> / C<password> / C<serveraddress> / C<identitytoken>,
or a pre-encoded base64 string. Sent as C<X-Registry-Auth>. The Engine API
reference does not document this header on this endpoint, but the daemon
reads it here exactly as it does on the pull

=back

=head2 install

    my $privileges = $plugins->privileges('vieux/sshfs:latest');

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

=item * C<privileges> - ArrayRef of privilege HashRefs from L</privileges>.
Required, unless C<accept_privileges> is set

=item * C<accept_privileges> - Fetch the privileges and grant them, in one
call. A blanket grant: use it where the call site is allowed to trust the
plugin, and know that it reads as consent to whatever the plugin demands

=item * C<name> - Local name for the installed plugin, if it should differ
from C<remote>. A digest is not allowed here

=item * C<auth> - Registry credentials, as for L</privileges>

=item * C<on_event> - CodeRef called with each progress event as it arrives,
instead of the ArrayRef being collected and returned; see below

=back

Returns an ArrayRef of progress events, one per object in the engine's
newline-delimited JSON stream, C<[]> when the engine sent no progress
at all.

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


=item * C<privileges> - ArrayRef of privilege HashRefs. Required, unless
C<accept_privileges> is set

=item * C<accept_privileges> - Fetch the privileges for C<remote> and grant
them, in one call

=item * C<remote> - Remote reference to upgrade to. Defaults to C<$name>,
which is what you want unless the plugin was installed under a local name

=item * C<auth> - Registry credentials, as for L</privileges>

=item * C<on_event> - CodeRef called with each progress event as it arrives.
The return value is then the summary HashRef; see
L</"Progress as it arrives">

=back

Returns an ArrayRef of progress events, C<[]> when the engine sent no
progress. Failure is reported by the same two routes as L</install>.

=head2 push

    $plugins->push('myrepo/sshfs:v1', auth => {
        username      => 'me',
        password      => 'secret',
        serveraddress => 'https://index.docker.io/v1/',
    });

Push an installed plugin to a registry. B<This writes to a real registry>
under the credentials given.

Options:

=over

=item * C<auth> - Registry credentials; HashRef of C<username> / C<password> /
C<serveraddress> / C<identitytoken>, or a pre-encoded base64 string. Sent as
C<X-Registry-Auth>

=item * C<on_event> - CodeRef called with each progress event as it arrives --
layer by layer, rather than the whole upload in one silence. The return value
is then the summary HashRef; see L</"Progress as it arrives">

=back

Unlike L<API::Docker::API::Images/push>, which sends C<X-Registry-Auth> on

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


=item * L<API::Docker::Type::Plugin> - the fields L</list> and L</inspect>
return

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

=item * L<API::Docker::Role::RegistryAuth> - the C<X-Registry-Auth>
encoding used here, shared with the other registry-facing endpoints

=item * L<API::Docker::API::Images> - Image endpoints, whose C<push>
sends that header on every call rather than only when credentials were
given

=item * L<API::Docker::Error::Stream> - Raised for a failure reported inside
a 200 event stream by L</install>, L</upgrade> and L</push>

=back

=head1 SUPPORT

=head2 Issues

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

  );
}


sub auth {
  my ($self, %opts) = @_;

  # Two spellings of the same AuthConfig. The credential keys and the name
  # `auth` cannot collide -- the engine's AuthConfig has no `auth` field --
  # so both are accepted, but not at once: which one wins would be a silent
  # choice about someone's credentials.
  my @flat = grep { defined $opts{$_} }
    qw( username password email serveraddress identitytoken );
  croak __PACKAGE__ . '->auth takes either auth => $config or the credential '
    . 'keys themselves, not both (' . join(', ', @flat) . ' given beside auth)'
    if defined $opts{auth} && @flat;

  my $config = defined $opts{auth}
    ? $self->_registry_auth_config($opts{auth})
    : { map { $_ => $opts{$_} } @flat };

  # Stricter than the engine, deliberately. An empty AuthConfig is a valid
  # body -- Podman answers it 500 'getting username and password: cannot
  # prompt for username without stdin' -- but a credential check with no
  # credentials in it is a caller bug, and answering it with the engine's
  # message would hide that.
  croak __PACKAGE__ . '->auth requires credentials: pass username/password, '
    . 'identitytoken, or auth => $config' unless keys %$config;

  return $self->client->post('/auth', $config,
    %{ $self->_request_options },
    (exists $opts{response} ? (response => $opts{response}) : ()));
}



1;

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

    my $pong = $docker->system->ping;

    # Monitor events
    my $events = $docker->system->events(
        since => time() - 3600,
    );

    # Disk usage
    my $df = $docker->system->df;

    # Check registry credentials before doing the work that needs them
    my $login = $docker->system->auth(
        username      => 'me',
        password      => 'secret',
        serveraddress => 'ghcr.io',
    );
    say $login->{Status};   # Login Succeeded

=head1 DESCRIPTION

This module provides access to Docker system-level operations including daemon

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


    my $login = $system->auth(
        username      => 'me',
        password      => 'secret',
        serveraddress => 'ghcr.io',
    );

    # Or hand over the same auth argument images->push takes
    $system->auth(auth => $auth);

Check a set of registry credentials against the registry, without pulling or
pushing anything. Returns the decoded C<< POST /auth >> response, a HashRef
with C<Status> (C<Login Succeeded>) and, where the registry issues one,
C<IdentityToken>.

B<Bad credentials croak.> The engine answers a failed check with an error
status, and the transport croaks on any status at or above 400, so a
successful return I<is> the answer -- there is no false value to test. That
is what makes this useful as a pre-flight check: call it before building and
tagging an image, and a stale credential fails the run where it is cheap
rather than halfway through a push.

To tell one failure from another, eval and read the status:

    my %res;
    eval { $docker->system->auth(auth => $auth, response => \%res); 1 }
      or do {
        die "registry rejected the credentials" if $res{status} == 401;
        die "could not reach the registry: $@";
      };

Options -- the AuthConfig keys the engine defines, all optional
individually, but at least one is required:

=over

=item * C<username> - Registry account name

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

The boolean overload is explicit rather than derived from the string, so it
cannot be made false by its own message.

=head2 message

The reason on its own, without the location suffix: the request it belongs to,
the timeout that expired and how much had arrived before it did.

The request is named without its query string, for the same reason the
C<< >= 400 >> croak names it that way -- C</build> carries its C<buildargs>
there, which can hold credentials and have no business in an exception.

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

The request that timed out, as C<"GET /v1.47/containers/json"> -- method and

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

The boolean overload is explicit rather than derived from the string, so it
cannot be made false by its own message.

=head2 message

The reason on its own, without the location suffix: the request it belongs to,
where in the response framing the stream ended, and how much had arrived.

The request is named without its query string, for the same reason the
C<< >= 400 >> croak names it that way -- C</build> carries its C<buildargs>
there, which can hold credentials and have no business in an exception.

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

The request that was cut short, as C<"GET /v1.47/images/get"> -- method and

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

L<API::Docker::API::Plugins/upgrade>, and C<remote> defaults to
L<API::Docker::Type::Plugin/name> -- which is not what you want for a
plugin installed under a local name, hence
C<< ->plugin_reference >>.

=head2 push

    $plugin->push(auth => { username => 'me', password => 'secret' });

Push the plugin to a registry. B<This writes to a real registry> under the
credentials given.

C<push> shadows the Perl builtin inside this package, which is why
L<namespace::clean> is loaded. Always call it as a method.

=head1 SEE ALSO

=over

=item * L<API::Docker::API::Plugins> - the operations these forward to

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

  # SNI, sent whether or not the certificate is checked: a terminator serving
  # several names needs it to pick the right one, and that is true of an
  # unverified connection too.
  my %ssl = ( SSL_hostname => $addr );

  if ($self->tls_insecure) {
    # Everything below is off deliberately, and the attribute that got us here
    # says so in its name. Encryption without verification stops a passive
    # listener and nothing else: whoever answers the connection chooses the
    # certificate, so anyone able to redirect it reads and rewrites the
    # traffic -- credentials, image contents, container commands.
    $ssl{SSL_verify_mode}     = IO::Socket::SSL::SSL_VERIFY_NONE();
    $ssl{SSL_verifycn_scheme} = undef;
  }
  else {
    $ssl{SSL_verify_mode}     = IO::Socket::SSL::SSL_VERIFY_PEER();
    # The name is checked against the certificate as well as the chain: a
    # valid certificate for some other host is not this host.
    $ssl{SSL_verifycn_scheme} = 'http';
    $ssl{SSL_verifycn_name}   = $addr;
  }

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


  # Caller data -- a container name, an image reference -- is spliced straight
  # into the request line through $path, so it is validated before it can reach
  # the wire, exactly as a header name is. See _assert_request_path.
  $self->_assert_request_path($path);

  my $url_path = defined $version ? "/v$version$path" : $path;

  # Kept before the query string is appended: it names the request in an
  # error message, and the query string is where /build carries buildargs,
  # which can hold credentials and have no business in an exception.
  my $endpoint = $method . ' ' . $url_path;

  # Definedness, not truth. A raw_body of '' (an empty tar) or of the string
  # '0' is a body the caller asked to send, and testing it for truth dropped
  # both: they fell through to the body branch, and the request then went out
  # with no Content-Length, no Content-Type and no payload at all. Whether
  # there is a body is therefore tracked separately from what it says.
  my $body_content;
  my $content_type = 'application/json';
  if (defined $opts{raw_body}) {

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

not have, and such a connection fails with a verification error naming exactly
that -- which is the intended outcome, not a silent downgrade. Point
C<cert_path> at the directory holding its F<ca.pem> and it verifies.

=head3 Turning verification off

C<< tls_insecure => 1 >>, and the name is the whole of the warning. It sets
C<SSL_VERIFY_NONE> and switches the hostname check off, which leaves a
connection that is encrypted against a passive listener and against nothing
else: whoever answers chooses the certificate, so anyone able to redirect the
connection reads and rewrites everything on it -- registry credentials,
image contents, the commands containers are started with.

It exists for a self-signed daemon certificate whose CA is genuinely not to
hand. The better answer to that is nearly always F<ca.pem>: a self-signed
certificate is its own CA and can be used as the anchor directly.

=head3 The dependency

L<IO::Socket::SSL> is a B<recommended>, not a required, dependency, and it is
loaded at the moment the first TLS connection is opened. It brings in

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

    package API::Docker::API::Whatever;
    use Moo;
    with 'API::Docker::Role::RegistryAuth';

    # The header form: X-Registry-Auth on a registry-facing request
    my $header = $self->_registry_auth_header($opts{auth});

    # The map-header form: X-Registry-Config on /build, hostname -> AuthConfig
    my $cfg_header = $self->_registry_config_header($opts{registry_config});

    # The body form: the same credentials as a plain HashRef
    my $config = $self->_registry_auth_config($opts{auth});

=head1 DESCRIPTION

One AuthConfig, three carriers. The Docker Engine takes registry credentials
as a JSON object with the keys C<username>, C<password>, C<serveraddress>,
C<identitytoken> and C<email>, and moves it around in three shapes:

=over

=item * base64url-encoded in the C<X-Registry-Auth> request header, for
C<< POST /images/{name}/push >>, C<< POST /images/create >>,
C<< GET /distribution/{name}/json >> and the C</plugins> family

=item * as a base64url-encoded B<map> of registry hostname to AuthConfig in
the C<X-Registry-Config> request header, for C<< POST /build >> -- one build
may pull base images from several registries, so it carries a set of
credentials rather than one

=item * as the plain JSON request body of C<< POST /auth >>

=back

This role carries the conversion in both directions so every class that
speaks to a registry agrees on it, and so a caller can hand the same C<auth>
argument to any of them.

B<It carries the encoding, not the policy.> Whether a header is sent at all

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

through -- but respelled into the URL-safe alphabet, so a value pre-encoded in
standard base64 (with C<+> or C</>) reaches the wire as the C<->/C<_> the
engine's C<base64.URLEncoding> decoder expects rather than failing there.

C<_registry_config_header($map)> is the same encoding for C<X-Registry-Config>
on C<< POST /build >>. It takes the same shapes, but the HashRef it JSON-encodes
is a B<map> of registry hostname to AuthConfig
(C<< { 'registry.example:5000' => { username => ..., password => ... } } >>),
not a single AuthConfig.

C<_registry_auth_config($auth)> returns the same credentials as a plain
HashRef for a JSON request body. C<undef> gives C<undef> -- whether that is
an error is the endpoint's call, not this role's. A HashRef is copied, a JSON
object is decoded, and a base64url string is decoded back through both
layers. Anything that does not read as an AuthConfig croaks.

=head1 SEE ALSO

=over

=item * L<API::Docker::API::Images> - C<push>, which always sends the header

=item * L<API::Docker::API::System> - C<auth>, which sends the body form

=item * L<API::Docker::API::Distribution> - registry manifest lookups

=item * L<API::Docker::API::Plugins> - the plugin family, which sends the
header only when credentials were given

=back

=head1 SUPPORT

=head2 Issues

Please report bugs and feature requests on GitHub at
L<https://github.com/Getty/p5-api-docker/issues>.

t/images_push_auth.t  view on Meta::CPAN

# 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/',
    };
    my $hdr = $images->_registry_auth_header($auth);
    is_deeply(decode_json(b64url_decode($hdr)), $auth,
        'header roundtrips through base64url + JSON');
};

t/images_push_auth.t  view on Meta::CPAN

        tag  => 'user',
    );

    is $captured->{method}, 'POST', 'POST issued';
    like $captured->{path}, qr{^/images/raudssus/karr:user/push}, 'push path';
    ok exists $captured->{headers}{'X-Registry-Auth'},
        'X-Registry-Auth header present';
    is_deeply(
        decode_json(b64url_decode($captured->{headers}{'X-Registry-Auth'})),
        { username => 'u', password => 'p' },
        'header decodes to passed credentials',
    );
    is $captured->{params}{tag}, 'user', 'tag param present';
};

done_testing;

t/images_registry_auth.t  view on Meta::CPAN

subtest 'pull sends X-Registry-Auth only when auth is given' => sub {
  my $captured;
  my $docker = test_docker(
    'POST /images/create' => sub {
      my ($method, $path, %opts) = @_;
      $captured = \%opts;
      return '';
    },
  );

  # With credentials: header present, correctly encoded, no +// on the wire.
  $docker->images->pull(fromImage => 'private.example/app', tag => 'v1', auth => $CREDS);
  my $hdr = $captured->{headers}{'X-Registry-Auth'};
  ok defined $hdr, 'X-Registry-Auth present when auth given';
  unlike $hdr, qr{[+/]}, 'header uses the URL-safe alphabet only';
  is_deeply decode_json(b64url_decode($hdr)), $CREDS,
    'header decodes to the passed credentials';

  # Without credentials: no header at all -- the anonymous case.
  $captured = undef;
  $docker->images->pull(fromImage => 'alpine', tag => '3');
  ok !exists $captured->{headers}, 'no X-Registry-Auth header on an anonymous pull';
};

# ---------------------------------------------------------------------------
subtest 'build sends X-Registry-Config only when registry_config is given' => sub {
  my $captured;
  my $docker = test_docker(
    'POST /build' => sub {

t/plugins.t  view on Meta::CPAN

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

  like request_line($c->written), qr{\APOST /v1\.41/plugins/pull\?},
    'install is POST /plugins/pull';

t/plugins.t  view on Meta::CPAN

  # This never reaches a registry -- the socket is an in-memory sink. There
  # is no live variant of this subtest and there must not be one.
  my $c = fake_client(qq({"status":"Pushing"}\n));
  $c->plugins->push('myrepo/sshfs:v1', auth => { username => 'u', password => 'p' });

  is request_line($c->written),
    'POST /v1.41/plugins/myrepo/sshfs:v1/push HTTP/1.1',
    'POST /plugins/{name}/push, no query string';
  my ($hdr) = $c->written =~ /^X-Registry-Auth: (\S+)\r$/m;
  is_deeply decode_json(b64url_decode($hdr)), { username => 'u', password => 'p' },
    'credentials in X-Registry-Auth, which the reference does not document '
    . 'on this endpoint but the daemon reads';
};

subtest 'push: anonymous sends no auth header' => sub {
  my $c = fake_client(qq({"status":"Pushing"}\n));
  $c->plugins->push('myrepo/sshfs:v1');
  unlike $c->written, qr/X-Registry-Auth/i,
    'unlike images->push, which must always send one';
};

t/registry_auth.t  view on Meta::CPAN

# _build_registry_auth_header in Images.pm, called as a function, and a
# _registry_auth_header method in Plugins.pm with the same body.
#
# What the merge could break is not the encoding, which is pinned in
# t/images_push_auth.t, but the two *policies* around it. They differ on
# purpose:
#
#   /images/{name}/push       always sends X-Registry-Auth, because the
#                             engine rejects an image push without it, and
#                             an anonymous push sends the encoding of {}
#   /plugins/*                sends it only when credentials were given: the
#   /distribution/{name}/json plugin and distribution routers decode the
#                             header and discard the error ("Ignore invalid
#                             AuthConfig to increase compatibility with the
#                             existing API"), so no header is the anonymous
#                             case rather than a failure
#
# Nothing here opens a socket or reaches a daemon, in either mode: the
# daemon is faked below the socket so that _request assembles a real request
# and the assertions read what would have gone on the wire. Podman serves
# neither /plugins nor /distribution, so a live run of this file could only

t/registry_auth.t  view on Meta::CPAN

  );

  my %header;
  for my $c (@consumers) {
    my ($name, $obj) = @$c;
    ok $obj->does('API::Docker::Role::RegistryAuth'), "$name does the role";
    $header{$name} = $obj->_registry_auth_header($CREDS);
  }

  is scalar(keys %{ { map { $_ => 1 } values %header } }), 1,
    'all four classes produce byte-identical headers for the same credentials';

  # The bare sub is gone rather than kept as a wrapper: a second entry point
  # is how the two copies drifted apart in the first place.
  ok !API::Docker::API::Images->can('_build_registry_auth_header'),
    'Images no longer carries its own encoder';
};

# ---------------------------------------------------------------------------
subtest '_registry_auth_config reads back every shape the header accepts' => sub {
  my $docker = API::Docker->new(

t/registry_auth.t  view on Meta::CPAN


  is $system->_registry_auth_config(undef), undef,
    'undef stays undef -- whether that is an error is the endpoint\'s call';

  ok !eval { $system->_registry_auth_config('not base64 at all !!'); 1 },
    'a value that is not an AuthConfig croaks';
  like $@, qr/AuthConfig/, 'and says so';
};

# ---------------------------------------------------------------------------
subtest 'images->push sends X-Registry-Auth even with no credentials' => sub {
  my $c = fake_client('{"status":"done"}');
  $c->images->push('myrepo/app', tag => 'v1');

  my $hdr = auth_header($c->written);
  is $hdr, 'e30=',
    'the anonymous push carries the padded encoding of {} -- the engine '
    . 'rejects an image push with no header at all';

  my $with = fake_client('{"status":"done"}');
  $with->images->push('myrepo/app', tag => 'v1', auth => $CREDS);
  is_deeply decode_json(b64url_decode(auth_header($with->written))), $CREDS,
    'and the credentials when they were given';
};

# ---------------------------------------------------------------------------
subtest 'plugins send X-Registry-Auth only when credentials were given' => sub {
  my $none = fake_client('[]');
  $none->plugins->privileges('vieux/sshfs:latest');
  unlike $none->written, qr/X-Registry-Auth/i,
    'no header at all on an anonymous plugin lookup';

  my $with = fake_client('[]');
  $with->plugins->privileges('vieux/sshfs:latest', auth => $CREDS);
  is_deeply decode_json(b64url_decode(auth_header($with->written))), $CREDS,
    'the header appears once credentials are passed';
};

# ---------------------------------------------------------------------------
subtest 'distribution sends X-Registry-Auth only when credentials were given' => sub {
  my $none = fake_client('{"Descriptor":{}}');
  $none->distribution->inspect('nginx:latest');
  unlike $none->written, qr/X-Registry-Auth/i,
    'a public image is looked up anonymously, with no header';

  my $with = fake_client('{"Descriptor":{}}');
  $with->distribution->inspect('private/app:1.0', auth => $CREDS);
  is_deeply decode_json(b64url_decode(auth_header($with->written))), $CREDS,
    'the header appears once credentials are passed';
};

done_testing;

t/stream_error.t  view on Meta::CPAN

  like "$@", qr/flat error text/,
    'the flat error key is the fallback when errorDetail has no message';

  $body = encode_json({ errorDetail => {} }) . "\n";
  eval { transport($body)->_request('POST', '/build', ndjson => 1) };
  like "$@", qr/no message given/, 'and there is a last resort';
  isa_ok $@, 'API::Docker::Error::Stream', 'still the exception class';
};

subtest 'the query string stays out of the message' => sub {
  # /build carries buildargs in the query string, which can hold credentials.
  my $body = encode_json({ errorDetail => { message => 'nope' } }) . "\n";
  eval {
    transport($body)->_request('POST', '/build',
      ndjson => 1,
      params => { t => 'app:v1', buildargs => { NPM_TOKEN => 'sekrit' } },
    );
  };
  like "$@", qr{\QPOST /v1.41/build\E}, 'the endpoint is named';
  unlike "$@", qr/sekrit/, 'the build args are not';
};

t/system_auth.t  view on Meta::CPAN

use strict;
use warnings;
use Test::More;
use FindBin;
use lib "$FindBin::Bin/lib";
use JSON::MaybeXS qw( decode_json );
use API::Docker;
use Test::API::Docker::FakeTransport;

# POST /auth -- checking a set of registry credentials without pulling or
# pushing anything (karr k17).
#
# Nothing here performs a login, in either mode. No credential in this file
# is real and no request leaves the process: the daemon is faked below the
# socket so _request assembles a real request line, headers and body with
# nothing on the other end, and the assertions read what would have gone on
# the wire. That is the whole of what this method decides -- the outcome is
# the registry's to give.
#
# The canned success body is the Engine API reference's own example payload,
# and the canned failure bodies are captures from the rootless Podman socket
# (5.4.2, API 1.41), taken with an unreachable serveraddress so that no
# registry was ever asked about anyone's credentials:
#
#   POST /v1.41/auth {"username":"nobody","password":"nothing",
#                     "serveraddress":"127.0.0.1:1"}
#   -> 500 {"message":"login attempt to 127.0.0.1:1 failed with status:
#           authenticating creds for \"127.0.0.1:1\": pinging container
#           registry 127.0.0.1:1: Get \"https://127.0.0.1:1/v2/\": dial tcp
#           127.0.0.1:1: connect: connection refused"}
#
# Note the 500: Podman does not answer a failed check with Docker's 401. The
# croak is what both engines have in common, which is why this file asserts

t/system_auth.t  view on Meta::CPAN

  return $body;
}

my $CREDS = {
  username      => 'me',
  password      => 'secret',
  serveraddress => 'ghcr.io',
};

# ---------------------------------------------------------------------------
subtest 'the credentials go in the body, not in a header' => sub {
  my $c = fake_client();
  my $out = $c->system->auth(%$CREDS);

  is request_line($c->written), 'POST /v1.41/auth HTTP/1.1',
    'POST /auth, no query string';
  is_deeply decode_json(request_body($c->written)), $CREDS,
    'the AuthConfig is the JSON request body';

  # The same object push carries in X-Registry-Auth, but /auth is the one
  # endpoint that takes it unencoded -- a header here would be ignored.

t/system_auth.t  view on Meta::CPAN


  my $j = fake_client();
  $j->system->auth(auth => '{"identitytoken":"tok-123"}');
  is_deeply decode_json(request_body($j->written)), { identitytoken => 'tok-123' },
    'as is a raw JSON AuthConfig';
};

# ---------------------------------------------------------------------------
subtest 'the two spellings may not be mixed' => sub {
  # Not a style rule: picking one silently would be picking which of two sets
  # of credentials gets sent.
  my $c = fake_client();
  ok !eval { $c->system->auth(auth => $CREDS, username => 'other'); 1 },
    'auth => $config beside a credential key croaks';
  like $@, qr/not both/, 'and says which keys were seen';
  is $c->written, '', 'nothing was sent';
};

# ---------------------------------------------------------------------------
subtest 'a check with nothing to check croaks before the request' => sub {
  my $c = fake_client();
  ok !eval { $c->system->auth; 1 }, 'no arguments croaks';
  like $@, qr/requires credentials/, 'and says what is missing';
  is $c->written, '', 'nothing was sent';

  my $e = fake_client();
  ok !eval { $e->system->auth(auth => {}); 1 },
    'an empty AuthConfig croaks too';
  is $e->written, '', 'nothing was sent';

  # Stricter than the engine on purpose: Podman answers the empty body with
  # 500 'getting username and password: cannot prompt for username without
  # stdin', which describes the daemon's stdin rather than the caller's bug.



( run in 1.506 second using v1.01-cache-2.11-cpan-85d3896f969 )