view release on metacpan or search on metacpan
.claude/CLAUDE.md view on Meta::CPAN
Perl client for the Docker Engine API.
## Docker Engine API
- **Unix Socket**: Default transport via `/var/run/docker.sock`
- **TCP**: Remote Docker daemons via `tcp://host:port`
- **TLS**: Optional TLS for secure remote connections
- **Auto-Negotiate**: Detects highest API version from daemon
## Build & Test
```bash
dzil build
dzil test
prove -lv t/
```
## Test Architecture
Unified mock/live tests controlled by environment variables:
```bash
# Mock mode (default):
prove -l t/
# Read tests against real Docker:
API_DOCKER_TEST_HOST=unix:///var/run/docker.sock prove -l t/
# Full live mode (read + write):
API_DOCKER_TEST_HOST=unix:///var/run/docker.sock API_DOCKER_TEST_WRITE=1 prove -l t/
```
| Env Var | Effect |
|---------|--------|
| (none) | All tests run with mocks |
| `API_DOCKER_TEST_HOST` | Read tests live, write tests skip |
| `API_DOCKER_TEST_HOST` + `API_DOCKER_TEST_WRITE=1` | All tests live |
Test helper: `t/lib/Test/API/Docker/Mock.pm` exports `test_docker`, `is_live`, `can_write`, `skip_unless_write`, `check_live_access`, `register_cleanup`, `load_fixture`.
## Structure
```
lib/API/
âââ Docker.pm # Main entry point + auto-negotiate
âââ Docker/
âââ Role/
â âââ HTTP.pm # HTTP over Unix Socket / TCP
âââ API/
lib/API/Docker.pm # main client, version negotiation
lib/API/Docker/Role/HTTP.pm # HTTP/1.1 transport (unix:// + tcp://)
lib/API/Docker/API/System.pm # /version, /info, /_ping
lib/API/Docker/API/Containers.pm # container endpoints
lib/API/Docker/API/Images.pm # image endpoints (build, pull, push, ...)
lib/API/Docker/API/Networks.pm # network endpoints
lib/API/Docker/API/Volumes.pm # volume endpoints
lib/API/Docker/API/Exec.pm # exec endpoints
lib/API/Docker/{Container,Image,Network,Volume}.pm # entity classes
t/ # tests (prove -l t/)
t/lib/Test/API/Docker/Mock.pm # fixture-driven mock helper
t/fixtures/*.json # captured daemon responses
```
## Build and test
```bash
dzil build # build the dist
dzil test # full test suite
prove -lv t/images.t # single test
cpanm --installdeps . # install deps from cpanfile
`headers` (extra HTTP headers â used by push for `X-Registry-Auth`).
- **`/build`, `/images/create`, `/images/.../push`** are streaming
endpoints. `_request` parses newline-delimited JSON and returns an
arrayref of events; callers iterate and look for `errorDetail`,
`progress`, `aux`, etc.
- **`X-Registry-Auth` is required on every push** by the Docker Engine â
even anonymous attempts. `images->push` always sends the header; pass
`auth => { username, password, serveraddress, identitytoken }` to
authenticate, omit it for the empty-`{}` form.
## Testing notes
- New tests should use the `Test::API::Docker::Mock` helper. Pass a
`'METHOD /path' => $fixture_or_coderef` route table; the helper
monkey-patches `_request` to dispatch against it.
- Don't add network-dependent assertions to default test runs. Gate them
on `is_live()` / `can_write()` from the mock helper.
- Fixtures live in `t/fixtures/*.json`. Capture them from a real daemon
rather than hand-rolling â keeps drift detectable.
## When changing behavior
- Add a `Changes` entry under `{{$NEXT}}`.
t/containers.t
t/fixtures/container_inspect.json
t/fixtures/containers_list.json
t/fixtures/images_list.json
t/fixtures/networks_list.json
t/fixtures/system_info.json
t/fixtures/system_version.json
t/fixtures/volumes_list.json
t/images.t
t/images_push_auth.t
t/lib/Test/API/Docker/Mock.pm
t/networks.t
t/release-changes_has_content.t
t/system.t
t/version.t
t/volumes.t
"configure" : {
"requires" : {
"ExtUtils::MakeMaker" : "0"
}
},
"develop" : {
"recommends" : {
"Dist::Zilla::PluginBundle::Git::VersionManager" : "0.007"
},
"requires" : {
"Test::Pod" : "1.41"
}
},
"runtime" : {
"requires" : {
"IO::Socket::UNIX" : "0",
"JSON::MaybeXS" : "0",
"Log::Any" : "0",
"MIME::Base64" : "0",
"Moo" : "0",
"URI" : "0",
"namespace::clean" : "0"
}
},
"test" : {
"requires" : {
"Path::Tiny" : "0",
"Test::More" : "0"
}
}
},
"release_status" : "stable",
"resources" : {
"bugtracker" : {
"web" : "https://github.com/Getty/p5-api-docker/issues"
},
"homepage" : "https://github.com/Getty/p5-api-docker",
"repository" : {
"class" : "Dist::Zilla::Plugin::License",
"name" : "@Author::GETTY/@Filter/License",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::Readme",
"name" : "@Author::GETTY/@Filter/Readme",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::ExtraTests",
"name" : "@Author::GETTY/@Filter/ExtraTests",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::ExecDir",
"name" : "@Author::GETTY/@Filter/ExecDir",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::ShareDir",
"name" : "@Author::GETTY/@Filter/ShareDir",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::MakeMaker",
"config" : {
"Dist::Zilla::Role::TestRunner" : {
"default_jobs" : 1
}
},
"name" : "@Author::GETTY/@Filter/MakeMaker",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::Manifest",
"name" : "@Author::GETTY/@Filter/Manifest",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::TestRelease",
"name" : "@Author::GETTY/@Filter/TestRelease",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::ConfirmRelease",
"name" : "@Author::GETTY/@Filter/ConfirmRelease",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::UploadToCPAN",
"name" : "@Author::GETTY/@Filter/UploadToCPAN",
"class" : "Dist::Zilla::Plugin::MetaConfig",
"name" : "@Author::GETTY/MetaConfig",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::MetaJSON",
"name" : "@Author::GETTY/MetaJSON",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::PodSyntaxTests",
"name" : "@Author::GETTY/PodSyntaxTests",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::Test::ChangesHasContent",
"name" : "@Author::GETTY/Test::ChangesHasContent",
"version" : "0.011"
},
{
"class" : "Dist::Zilla::Plugin::GithubMeta",
"name" : "@Author::GETTY/GithubMeta",
"version" : "0.58"
},
{
"class" : "Dist::Zilla::Plugin::InstallRelease",
"name" : "@Author::GETTY/InstallRelease",
"name" : ":InstallModules",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::FinderCode",
"name" : ":IncModules",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::FinderCode",
"name" : ":TestFiles",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::FinderCode",
"name" : ":ExtraTestFiles",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::FinderCode",
"name" : ":ExecFiles",
"version" : "6.037"
},
{
"class" : "Dist::Zilla::Plugin::FinderCode",
"name" : ":PerlExecFiles",
---
abstract: 'Perl client for the Docker Engine API'
author:
- 'Torsten Raudssus <getty@cpan.org>'
build_requires:
Path::Tiny: '0'
Test::More: '0'
configure_requires:
ExtUtils::MakeMaker: '0'
dynamic_config: 0
generated_by: 'Dist::Zilla version 6.037, CPAN::Meta::Converter version 2.150010'
license: perl
meta-spec:
url: http://module-build.sourceforge.net/META-spec-v1.4.html
version: '1.4'
name: API-Docker
requires:
version: '6.037'
-
class: Dist::Zilla::Plugin::License
name: '@Author::GETTY/@Filter/License'
version: '6.037'
-
class: Dist::Zilla::Plugin::Readme
name: '@Author::GETTY/@Filter/Readme'
version: '6.037'
-
class: Dist::Zilla::Plugin::ExtraTests
name: '@Author::GETTY/@Filter/ExtraTests'
version: '6.037'
-
class: Dist::Zilla::Plugin::ExecDir
name: '@Author::GETTY/@Filter/ExecDir'
version: '6.037'
-
class: Dist::Zilla::Plugin::ShareDir
name: '@Author::GETTY/@Filter/ShareDir'
version: '6.037'
-
class: Dist::Zilla::Plugin::MakeMaker
config:
Dist::Zilla::Role::TestRunner:
default_jobs: 1
name: '@Author::GETTY/@Filter/MakeMaker'
version: '6.037'
-
class: Dist::Zilla::Plugin::Manifest
name: '@Author::GETTY/@Filter/Manifest'
version: '6.037'
-
class: Dist::Zilla::Plugin::TestRelease
name: '@Author::GETTY/@Filter/TestRelease'
version: '6.037'
-
class: Dist::Zilla::Plugin::ConfirmRelease
name: '@Author::GETTY/@Filter/ConfirmRelease'
version: '6.037'
-
class: Dist::Zilla::Plugin::UploadToCPAN
name: '@Author::GETTY/@Filter/UploadToCPAN'
version: '6.037'
-
class: Dist::Zilla::Plugin::MetaConfig
name: '@Author::GETTY/MetaConfig'
version: '6.037'
-
class: Dist::Zilla::Plugin::MetaJSON
name: '@Author::GETTY/MetaJSON'
version: '6.037'
-
class: Dist::Zilla::Plugin::PodSyntaxTests
name: '@Author::GETTY/PodSyntaxTests'
version: '6.037'
-
class: Dist::Zilla::Plugin::Test::ChangesHasContent
name: '@Author::GETTY/Test::ChangesHasContent'
version: '0.011'
-
class: Dist::Zilla::Plugin::GithubMeta
name: '@Author::GETTY/GithubMeta'
version: '0.58'
-
class: Dist::Zilla::Plugin::InstallRelease
name: '@Author::GETTY/InstallRelease'
version: '0.008'
-
-
class: Dist::Zilla::Plugin::FinderCode
name: ':InstallModules'
version: '6.037'
-
class: Dist::Zilla::Plugin::FinderCode
name: ':IncModules'
version: '6.037'
-
class: Dist::Zilla::Plugin::FinderCode
name: ':TestFiles'
version: '6.037'
-
class: Dist::Zilla::Plugin::FinderCode
name: ':ExtraTestFiles'
version: '6.037'
-
class: Dist::Zilla::Plugin::FinderCode
name: ':ExecFiles'
version: '6.037'
-
class: Dist::Zilla::Plugin::FinderCode
name: ':PerlExecFiles'
version: '6.037'
-
Makefile.PL view on Meta::CPAN
"IO::Socket::UNIX" => 0,
"JSON::MaybeXS" => 0,
"Log::Any" => 0,
"MIME::Base64" => 0,
"Moo" => 0,
"URI" => 0,
"namespace::clean" => 0
},
"TEST_REQUIRES" => {
"Path::Tiny" => 0,
"Test::More" => 0
},
"VERSION" => "0.002",
"test" => {
"TESTS" => "t/*.t"
}
);
my %FallbackPrereqs = (
"IO::Socket::UNIX" => 0,
"JSON::MaybeXS" => 0,
"Log::Any" => 0,
"MIME::Base64" => 0,
"Moo" => 0,
"Path::Tiny" => 0,
"Test::More" => 0,
"URI" => 0,
"namespace::clean" => 0
);
unless ( eval { ExtUtils::MakeMaker->VERSION(6.63_03) } ) {
delete $WriteMakefileArgs{TEST_REQUIRES};
delete $WriteMakefileArgs{BUILD_REQUIRES};
$WriteMakefileArgs{PREREQ_PM} = \%FallbackPrereqs;
}
requires 'Moo';
requires 'JSON::MaybeXS';
requires 'MIME::Base64';
requires 'IO::Socket::UNIX';
requires 'URI';
requires 'namespace::clean';
requires 'Log::Any';
on test => sub {
requires 'Test::More';
requires 'Path::Tiny';
};
t/author-pod-syntax.t view on Meta::CPAN
#!perl
BEGIN {
unless ($ENV{AUTHOR_TESTING}) {
print qq{1..0 # SKIP these tests are for testing by the author\n};
exit
}
}
# This file was automatically generated by Dist::Zilla::Plugin::PodSyntaxTests
use strict; use warnings;
use Test::More;
use Test::Pod 1.41;
all_pod_files_ok();
use strict;
use warnings;
use Test::More;
use_ok('API::Docker');
use_ok('API::Docker::Role::HTTP');
use_ok('API::Docker::API::System');
use_ok('API::Docker::API::Containers');
use_ok('API::Docker::API::Images');
use_ok('API::Docker::API::Networks');
use_ok('API::Docker::API::Volumes');
use_ok('API::Docker::API::Exec');
use_ok('API::Docker::Container');
use_ok('API::Docker::Image');
use_ok('API::Docker::Network');
use_ok('API::Docker::Volume');
# Test default construction
my $docker = API::Docker->new(api_version => '1.47');
isa_ok($docker, 'API::Docker');
is($docker->host, 'unix:///var/run/docker.sock', 'default host');
is($docker->api_version, '1.47', 'api_version set');
is($docker->tls, 0, 'tls off by default');
# Test custom host
my $docker_tcp = API::Docker->new(
host => 'tcp://remote:2375',
api_version => '1.47',
);
is($docker_tcp->host, 'tcp://remote:2375', 'custom host');
# Test API accessors exist
can_ok($docker, qw(system containers images networks volumes exec));
# Test API accessor types
isa_ok($docker->system, 'API::Docker::API::System');
isa_ok($docker->containers, 'API::Docker::API::Containers');
isa_ok($docker->images, 'API::Docker::API::Images');
isa_ok($docker->networks, 'API::Docker::API::Networks');
isa_ok($docker->volumes, 'API::Docker::API::Volumes');
isa_ok($docker->exec, 'API::Docker::API::Exec');
done_testing;
t/containers.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
check_live_access();
# --- Read Tests (always run) ---
subtest 'list containers' => sub {
my $docker = test_docker(
'GET /containers/json' => load_fixture('containers_list'),
);
my $containers = $docker->containers->list(all => 1);
is(ref $containers, 'ARRAY', 'returns array');
if (@$containers) {
t/containers.t view on Meta::CPAN
is($first->State, 'running', 'container state');
ok($first->is_running, 'is_running returns true for running container');
my $second = $containers->[1];
is($second->Id, 'def789ghi012', 'second container id');
is($second->State, 'exited', 'second container state');
ok(!$second->is_running, 'is_running returns false for exited container');
}
};
# --- Write Tests (mock always, live only with WRITE) ---
subtest 'container lifecycle' => sub {
skip_unless_write();
my $docker = test_docker(
'POST /containers/create' => { Id => 'mock123', Warnings => [] },
'POST /containers/mock123/start' => undef,
'GET /containers/mock123/json' => load_fixture('container_inspect'),
'GET /containers/mock123/top' => {
Titles => ['UID', 'PID', 'PPID', 'C', 'STIME', 'TTY', 'TIME', 'CMD'],
t/containers.t view on Meta::CPAN
$docker->containers->unpause($id);
pass('container unpaused');
$docker->containers->stop($id, timeout => 3);
pass('container stopped');
$docker->containers->remove($id);
pass('container removed');
};
# --- Validation Tests (always run, no Docker needed) ---
subtest 'container ID required' => sub {
my $docker = test_docker();
eval { $docker->containers->inspect(undef) };
like($@, qr/Container ID required/, 'croak on missing ID for inspect');
eval { $docker->containers->start(undef) };
like($@, qr/Container ID required/, 'croak on missing ID for start');
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
check_live_access();
# --- Read Tests (always run) ---
subtest 'list images' => sub {
my $docker = test_docker(
'GET /images/json' => load_fixture('images_list'),
);
my $images = $docker->images->list;
is(ref $images, 'ARRAY', 'returns array');
if (@$images) {
my $results = $docker->images->search('nginx');
is(ref $results, 'ARRAY', 'search returns array');
unless (is_live()) {
is($results->[0]{name}, 'nginx', 'found nginx');
}
};
# --- Write Tests (mock always, live only with WRITE) ---
subtest 'image build and pull lifecycle' => sub {
skip_unless_write();
my $docker = test_docker(
'POST /build' => sub {
my ($method, $path, %opts) = @_;
ok(defined $opts{raw_body}, 'raw_body present in request');
is($opts{content_type}, 'application/x-tar', 'content type is tar');
return { stream => 'Successfully built abc123def456' };
pass('pull completed');
$docker->images->tag('nginx:latest', repo => 'myrepo/nginx', tag => 'v1');
pass('tag completed');
my $removed = $docker->images->remove('nginx:latest');
is(ref $removed, 'ARRAY', 'remove returns array of actions');
}
};
# --- Validation Tests (always run, no Docker needed) ---
subtest 'build requires context' => sub {
my $docker = test_docker();
eval { $docker->images->build(t => 'myapp:latest') };
like($@, qr/Build context required/, 'croak on missing context');
};
subtest 'image name required' => sub {
my $docker = test_docker();
t/images_push_auth.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use JSON::MaybeXS qw( decode_json );
use MIME::Base64 qw( decode_base64 decode_base64url );
use API::Docker::API::Images;
sub b64url_decode {
my ($s) = @_;
$s =~ tr{-_}{+/};
my $pad = (4 - length($s) % 4) % 4;
$s .= '=' x $pad;
t/lib/Test/API/Docker/Mock.pm view on Meta::CPAN
package Test::API::Docker::Mock;
use strict;
use warnings;
use JSON::MaybeXS qw( decode_json encode_json );
use Path::Tiny;
use Carp qw( croak );
use Test::More;
use Exporter 'import';
our @EXPORT = qw(
test_docker
load_fixture
is_live
can_write
skip_unless_write
check_live_access
register_cleanup
t/networks.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
check_live_access();
# --- Read Tests (always run) ---
subtest 'list networks' => sub {
my $docker = test_docker(
'GET /networks' => load_fixture('networks_list'),
);
my $networks = $docker->networks->list;
is(ref $networks, 'ARRAY', 'returns array');
if (@$networks) {
t/networks.t view on Meta::CPAN
is(scalar @$networks, 2, 'two networks');
my $first = $networks->[0];
is($first->Name, 'bridge', 'network name');
is($first->Driver, 'bridge', 'network driver');
is($first->Scope, 'local', 'network scope');
ok(!$first->Internal, 'not internal');
}
};
# --- Write Tests (mock always, live only with WRITE) ---
subtest 'network lifecycle' => sub {
skip_unless_write();
my $docker = test_docker(
'POST /networks/create' => sub {
my ($method, $path, %opts) = @_;
is($opts{body}{Name}, 'test-net', 'network name in body') unless is_live();
return { Id => 'mock-net-123', Warning => '' };
},
t/networks.t view on Meta::CPAN
pass('connect completed');
$docker->networks->disconnect($id, Container => 'abc123');
pass('disconnect completed');
}
$docker->networks->remove($id);
pass('network removed');
};
# --- Validation Tests (always run, no Docker needed) ---
subtest 'network ID required' => sub {
my $docker = test_docker();
eval { $docker->networks->inspect(undef) };
like($@, qr/Network ID required/, 'croak on missing ID for inspect');
eval { $docker->networks->remove(undef) };
like($@, qr/Network ID required/, 'croak on missing ID for remove');
};
t/release-changes_has_content.t view on Meta::CPAN
BEGIN {
unless ($ENV{RELEASE_TESTING}) {
print qq{1..0 # SKIP these tests are for release candidate testing\n};
exit
}
}
use Test::More tests => 2;
note 'Checking Changes';
my $changes_file = 'Changes';
my $newver = '0.002';
my $trial_token = '-TRIAL';
my $encoding = 'UTF-8';
SKIP: {
ok(-e $changes_file, "$changes_file file exists")
or skip 'Changes is missing', 1;
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
check_live_access();
subtest 'system info' => sub {
my $docker = test_docker(
'GET /info' => load_fixture('system_info'),
);
my $info = $docker->system->info;
t/version.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
check_live_access();
subtest 'version info' => sub {
my $docker = test_docker(
'GET /version' => load_fixture('system_version'),
);
my $version = $docker->system->version;
t/volumes.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
check_live_access();
# --- Read Tests (always run) ---
subtest 'list volumes' => sub {
my $docker = test_docker(
'GET /volumes' => load_fixture('volumes_list'),
);
my $volumes = $docker->volumes->list;
is(ref $volumes, 'ARRAY', 'returns array');
if (@$volumes) {
t/volumes.t view on Meta::CPAN
my $first = $volumes->[0];
is($first->Name, 'my-data', 'volume name');
is($first->Driver, 'local', 'volume driver');
is($first->Scope, 'local', 'volume scope');
is_deeply($first->Labels, { project => 'test' }, 'volume labels');
like($first->Mountpoint, qr{/var/lib/docker/volumes/my-data}, 'mountpoint');
}
};
# --- Write Tests (mock always, live only with WRITE) ---
subtest 'volume lifecycle' => sub {
skip_unless_write();
my $docker = test_docker(
'POST /volumes/create' => sub {
my ($method, $path, %opts) = @_;
is($opts{body}{Name}, 'test-vol', 'volume name in body') unless is_live();
return {
Name => 'test-vol',
t/volumes.t view on Meta::CPAN
register_cleanup(sub { eval { $docker->volumes->remove($name, force => 1) } }) if is_live();
my $inspected = $docker->volumes->inspect($name);
isa_ok($inspected, 'API::Docker::Volume');
is($inspected->Driver, 'local', 'volume driver is local');
$docker->volumes->remove($name);
pass('volume removed');
};
# --- Validation Tests (always run, no Docker needed) ---
subtest 'volume name required' => sub {
my $docker = test_docker();
eval { $docker->volumes->inspect(undef) };
like($@, qr/Volume name required/, 'croak on missing name for inspect');
eval { $docker->volumes->remove(undef) };
like($@, qr/Volume name required/, 'croak on missing name for remove');
};