view release on metacpan or search on metacpan
.claude/agents/api-docker-test-writer.md view on Meta::CPAN
---
name: api-docker-test-writer
description: "Write API::Docker tests with Test::More and the Test::API::Docker::Mock route table. The default suite never touches a Docker daemon or the network; live paths stay gated on is_live()/can_write(). Use for test additions, regression scaf...
model: sonnet
allowed-tools: Read, Edit, Write, Bash, Glob, Grep
briefing:
skills:
- api-docker-core
- docker-engine-api
- getty-perl-core
- getty-perl-moo
- kanban-issues-karr-cli
---
.claude/agents/api-docker-test-writer.md view on Meta::CPAN
it existed to catch. Never normalise the value under test on the way into the
assertion.
- **Live and mock must both be able to pass, or the assertion is gated.** `test_docker`
ignores the route table entirely under `API_DOCKER_TEST_HOST` â an assertion tied to
fixture contents runs against a real daemon's data otherwise.
New fixtures are captured from a real daemon into `t/fixtures/*.json`, never hand-rolled
â and that includes wire formats. A test for the multiplexed log stream asserts against
bytes the engine actually produced, not against a frame header written from memory; the
Engine API reference above tells you what to expect, the socket tells you what is true.
Test filenames follow the existing flat, topical naming (`t/images.t`,
`t/images_push_auth.t`), one file per resource or per defect.
A test asserts intent: it must be able to fail when the logic changes. Reproduce a bug
before fixing it and leave the regression behind. Verify with `prove -lr t/`; a single
file with `prove -lv t/NN.t`.
.claude/rules/api-docker-rules.md view on Meta::CPAN
1. **Think before coding** â state assumptions; when uncertain, ask rather than guess.
Push back when a simpler approach exists.
2. **Simplicity first** â minimum code that solves the problem. Nothing speculative.
3. **Surgical changes** â touch only what you must. Match existing style.
4. **Goal-driven execution** â define success criteria, loop until verified.
5. **Surface conflicts, don't average them** â pick one (more recent / more tested), flag
the other for cleanup. Don't blend.
6. **Read before you write** â `Role::HTTP` is the single seam every resource API and
entity class hangs off. A change to `_request`'s options, return shape or error
handling reaches every module in `lib/` and the mock harness at once.
7. **Tests verify intent, not just behavior** â a test that can't fail when the logic
changes is wrong, and a helper that normalises its input before asserting is that
test. Reproduce a bug before fixing it; leave the regression behind.
8. **Checkpoint after every significant step** â summarize: done / verified / left.
9. **Match conventions** â conformance > taste. Surface a harmful convention; don't fork
silently.
10. **Fail loud** â "Done" is wrong if anything was skipped. "Tests pass" is wrong if any
were skipped â and in this repo a skip is the default failure mode, see below.
11. **A red test is a claim before it is a failure** â before changing code to turn a
test green, say what the test asserts and whether your fix keeps that claim or
replaces it. If the claim is wrong, fix the claim and say so.
## Delegation
This rule depends on whether the Agent/Task tool is available to you.
- **You can spawn subagents** (orchestrating main agent): Do NOT touch behavior-relevant
.claude/skills/api-docker-core/SKILL.md view on Meta::CPAN
resource method goes through it (usually via `get`/`post`/`put`/
`delete_request`). A new endpoint never opens its own socket.
Options: `body` (JSON-encoded), `raw_body` + `content_type` (tarballs for
`/build`), `params` (query string), `headers` (extra request headers),
`on_event`/`on_frame`/`on_chunk` (streaming callbacks, see below).
`_request` prefixes the path with `/v$api_version`. `around _request` in
`API::Docker` triggers `negotiate_version` on the first call that is not
`/version` â so a mock that replaces `_request` must strip the `/vX.YZ` prefix
itself, which `Test::API::Docker::Mock` does.
## Invariants
- **`list` and `inspect` return generated `API::Docker::Type::*` objects**
(via `_wrap`/`_wrap_list`), everything else returns the raw daemon
response. Which class depends on the resource: `Networks`, `Volumes`,
`Plugins`, `Secrets` and `Configs` answer both calls with the same class
(one swagger definition each â `API::Docker::Type::Network`, etc.), while
`Containers` and `Images` each have two, with different fields â
`ContainerSummary`/`ContainerInspectResponse`,
.claude/skills/api-docker-core/SKILL.md view on Meta::CPAN
same everything above it. `cert_path` names a directory in the `docker`
CLI's own layout (`ca.pem` as the trust anchor, `cert.pem`+`key.pem` as
this client's identity), defaulting from `$ENV{DOCKER_CERT_PATH}`;
`tls_insecure => 1` turns verification off. `IO::Socket::SSL` is a
recommended, not required, dependency, loaded only once a TLS connection is
actually opened. Detail: `API::Docker::Role::HTTP`'s "TLS on a tcp://
connection".
- **No connection reuse.** Each `_request` calls `_reconnect` and closes
afterwards, streamed or not.
## Tests â `Test::API::Docker::Mock`
`test_docker('GET /images/json' => $fixture_or_coderef, ...)` returns a client
whose `_request` dispatches against the route table (exact key first, then the
key matched as a literal path -- it is `\Q..\E`-escaped, not a regex, so a
metacharacter in a route key means itself). A `GET /version` route is injected
when none is given.
**In live mode `test_docker` ignores the routes entirely** and returns a real
client against `$ENV{API_DOCKER_TEST_HOST}`. An assertion that only holds for
the fixture must sit behind `is_live()`; mutating tests behind `can_write()` /
.claude/skills/perl-release-dist-ini/SKILL.md view on Meta::CPAN
| `[@Bundle]` | Plugin bundle |
| `[Plugin]` | Individual plugin |
## Plugin Loading Order
1. `[GatherDir]` - Collects files
2. `[PruneCruft]` - Removes unwanted files
3. `[Prereqs]` / `cpanfile` - Dependencies
4. `[Version plugins]` - PkgVersion, AutoVersion, etc.
5. `[Meta plugins]` - MetaJSON, MetaYAML
6. `[Test plugins]` - Tests
7. `[Release plugins]` - UploadToCPAN, etc.
8. `[VCS plugins]` - Git::Commit, Git::Tag, etc.
## Key Questions
1. Which plugin bundle is used?
2. Are there custom plugins configured?
3. Are prereqs in dist.ini or cpanfile?
4. What release mechanism is configured?
5. What is the version in dist.ini â and does the bundle auto-bump it post-release or is it manual?
lib/API/Docker/Error/Timeout.pm # croaked when a read_timeout or connect_timeout runs out
lib/API/Docker/Error/Truncated.pm # croaked when the daemon closed before its announced response was complete
maint/spec-to-type.pl # generates lib/API/Docker/Type/*.pm from spec/ -- never overwrites
maint/spec-drift-check.pl # diffs spec/ against the registry, and spec against spec
maint/spec-common.pl # the spec loader shared by the two scripts above
maint/spec-to-type-names.yaml # inline-class naming exceptions the generator and checker share
maint/spec-to-type-prose.yaml # hand-written POD for fields/classes the swagger describes poorly
maint/spec-drift-exceptions.yaml # deliberate deviations the drift checker accepts
spec/v1.41.yaml, v1.44.yaml, v1.51.yaml # Docker's own swagger, checked in; generation runs against v1.51
t/ # tests (prove -lr t/)
t/lib/Test/API/Docker/Mock.pm # fixture-driven mock helper
t/fixtures/*.json # captured daemon responses
.claude/agents/ # the api-docker-* subagents
.claude/rules/api-docker-rules.md # house rules, auto-loaded every turn
.claude/skills/ # briefed skills (hardlinked + owned)
```
## Build and test
```bash
prove -lr t/ # canonical â recursive; plain `prove -l t/` skips subdirs
The test that covered this could not have caught it. Its decode
helper computed the missing padding and appended it before decoding,
so the assertions passed either way. It now decodes what the engine
would get, and a separate case pins the exact padded header.
- Document that this client speaks the Docker Engine HTTP API over a
socket and never shells out to the `docker` binary, so any engine
serving that API works -- Podman's rootless socket needs nothing
but `DOCKER_HOST`. The new CONTAINER ENGINES section also states
what socket discovery deliberately does not do: Docker contexts
(`currentContext`, `~/.docker/contexts/meta/*/meta.json`) are never
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`,
t/fixtures/system_version.json
t/fixtures/volumes_list.json
t/images.t
t/images_build_prune.t
t/images_commit.t
t/images_push_auth.t
t/images_registry_auth.t
t/images_tar.t
t/json_body_booleans.t
t/legacy_stubs.t
t/lib/Test/API/Docker/FakeTransport.pm
t/lib/Test/API/Docker/Mock.pm
t/mock_harness.t
t/networks.t
t/plugins.t
t/read_timeout.t
t/registry_auth.t
t/release-changes_has_content.t
t/role_http.t
t/secrets_configs.t
t/spec_to_type.t
t/stream_error.t
"configure" : {
"requires" : {
"ExtUtils::MakeMaker" : "0"
}
},
"develop" : {
"recommends" : {
"Dist::Zilla::PluginBundle::Git::VersionManager" : "0.007"
},
"requires" : {
"Test::Pod" : "1.41",
"YAML::XS" : "0"
}
},
"runtime" : {
"recommends" : {
"IO::Socket::SSL" : "0"
},
"requires" : {
"Carp" : "0",
"Errno" : "0",
"Types::Standard" : "0",
"namespace::clean" : "0",
"overload" : "0",
"perl" : "5.014"
}
},
"test" : {
"requires" : {
"Exporter" : "0",
"Path::Tiny" : "0",
"Test::More" : "0"
}
}
},
"provides" : {
"API::Docker" : {
"file" : "lib/API/Docker.pm",
"version" : "0.004"
},
"API::Docker::API::Configs" : {
"file" : "lib/API/Docker/API/Configs.pm",
"class" : "Dist::Zilla::Plugin::MetaYAML",
"name" : "@Author::GETTY/@Filter/MetaYAML",
"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::MetaProvides::Package",
"config" : {
"Dist::Zilla::Plugin::MetaProvides::Package" : {
"finder_objects" : [
{
"class" : "Dist::Zilla::Plugin::FinderCode",
"name" : "@Author::GETTY/MetaProvides::Package/AUTOVIV/:InstallModulesPM",
"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:
Exporter: '0'
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
provides:
version: '6.037'
-
class: Dist::Zilla::Plugin::MetaYAML
name: '@Author::GETTY/@Filter/MetaYAML'
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'
-
version: '0.002'
-
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::MetaProvides::Package
config:
Dist::Zilla::Plugin::MetaProvides::Package:
finder_objects:
-
class: Dist::Zilla::Plugin::FinderCode
name: '@Author::GETTY/MetaProvides::Package/AUTOVIV/:InstallModulesPM'
version: '6.037'
-
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
"Path::Tiny" => 0,
"Scalar::Util" => 0,
"Socket" => 0,
"Types::Standard" => 0,
"namespace::clean" => 0,
"overload" => 0
},
"TEST_REQUIRES" => {
"Exporter" => 0,
"Path::Tiny" => 0,
"Test::More" => 0
},
"VERSION" => "0.004",
"test" => {
"TESTS" => "t/*.t"
}
);
my %FallbackPrereqs = (
"Carp" => 0,
Makefile.PL view on Meta::CPAN
"Import::Into" => 0,
"JSON::MaybeXS" => 0,
"Log::Any" => 0,
"MIME::Base64" => 0,
"Module::Runtime" => 0,
"Moo" => 0,
"Package::Stash" => 0,
"Path::Tiny" => 0,
"Scalar::Util" => 0,
"Socket" => 0,
"Test::More" => 0,
"Types::Standard" => 0,
"namespace::clean" => 0,
"overload" => 0
);
unless ( eval { ExtUtils::MakeMaker->VERSION(6.63_03) } ) {
delete $WriteMakefileArgs{TEST_REQUIRES};
delete $WriteMakefileArgs{BUILD_REQUIRES};
$WriteMakefileArgs{PREREQ_PM} = \%FallbackPrereqs;
requires 'Types::Standard';
# Only the tcp:// transport with tls => 1 loads this, and it is loaded at the
# moment that connection is opened. It brings in Net::SSLeay, which is XS
# compiled against libssl; requiring it would make this client unbuildable
# where there are no OpenSSL headers, for the sake of a transport that the
# unix:// default -- local Docker, rootless Podman -- never uses.
recommends 'IO::Socket::SSL';
on test => sub {
requires 'Test::More';
requires 'Path::Tiny';
requires 'Exporter';
};
# The drift checker under maint/ reads Docker's swagger from spec/ and the
# exceptions file beside itself. YAML::XS rather than YAML::PP is not a
# preference: YAML::PP 0.41 will not parse the file Docker publishes -- see
# the comment at the top of maint/spec-drift-check.pl. Nothing under lib/
# loads a YAML parser.
on develop => sub {
lib/API/Docker.pm view on Meta::CPAN
L</host> resolves in two steps and no more: C<$ENV{DOCKER_HOST}>, then
C<unix:///var/run/docker.sock>. It deliberately does B<not> read Docker
contexts. C<currentContext> in F<~/.docker/config.json> and the matching
F<~/.docker/contexts/meta/*/meta.json> are ignored, so if you switch daemons
with C<docker context use>, that choice is not picked up here. Set
C<DOCKER_HOST> explicitly instead.
Other clients sit at different points on that scale. The C<docker> CLI and
docker-java resolve contexts, with C<DOCKER_HOST> outranking them when set.
docker-py's C<from_env()> reads C<DOCKER_HOST> and otherwise falls back to the
default socket, leaving contexts to a separate API. Testcontainers layers its
own F<~/.testcontainers.properties> and a rootless probe list
(C<$XDG_RUNTIME_DIR/docker.sock>, F<~/.docker/run/docker.sock>,
F<~/.docker/desktop/docker.sock>, C</run/user/$UID/docker.sock>) on top.
What none of them do is guess Podman's socket path: that probe list is for
rootless Docker, not for Podman. Every one of those projects documents
C<DOCKER_HOST> as the way to reach Podman, which is the same answer given
above.
=head1 ENVIRONMENT VARIABLES
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::API::Distribution');
use_ok('API::Docker::API::Plugins');
use_ok('API::Docker::Role::Entity');
use_ok('API::Docker::Role::Entity::Container');
use_ok('API::Docker::Role::Entity::Image');
use_ok('API::Docker::Role::Entity::Network');
use_ok('API::Docker::Role::Entity::Volume');
use_ok('API::Docker::Role::Entity::Plugin');
use_ok('API::Docker::Role::Entity::Secret');
use_ok('API::Docker::Role::Entity::Config');
# Test default construction. host resolves DOCKER_HOST before the socket path,
# so the socket default is only observable with that variable gone -- any
# machine pointing at rootless Podman has it set, and this test used to read it.
my $docker;
{
delete local $ENV{DOCKER_HOST};
$docker = API::Docker->new(api_version => '1.47');
}
isa_ok($docker, 'API::Docker');
is($docker->host, 'unix:///var/run/docker.sock', 'default host');
local $ENV{DOCKER_HOST} = 'tcp://from-env:2375';
is(
API::Docker->new(api_version => '1.47')->host,
'tcp://from-env:2375',
'DOCKER_HOST outranks the socket default',
);
}
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
distribution secrets configs plugins
));
# 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');
isa_ok($docker->distribution, 'API::Docker::API::Distribution');
isa_ok($docker->secrets, 'API::Docker::API::Secrets');
isa_ok($docker->configs, 'API::Docker::API::Configs');
isa_ok($docker->plugins, 'API::Docker::API::Plugins');
t/connect_timeout.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use Errno;
use File::Temp qw( tempdir );
use IO::Socket::UNIX;
use Time::HiRes qw( time );
use API::Docker;
use API::Docker::Error::Timeout;
# The connect timeout of API::Docker::Role::HTTP (karr k61).
#
# read_timeout (karr k59) bounds reading and nothing else, so a daemon that
t/connect_timeout.t view on Meta::CPAN
# not. connect_timeout is the other half.
#
# Nothing here reaches a Docker daemon and nothing here touches the network:
# the one end-to-end assertion runs against a local Unix listener whose backlog
# has been filled, which is the only way an AF_UNIX connect blocks at all. So
# there is no is_live()/can_write() gating -- it is unconditionally safe with
# no engine installed.
# A client that records what _build__socket was handed instead of connecting,
# so the per-request resolution can be asserted at the seam it has to cross.
package Test::ConnectTimeout::Probe;
use Moo;
extends 'API::Docker';
has seen => (is => 'ro', required => 1);
has pending => (is => 'ro', default => sub { [] });
sub _build__socket {
my ($self) = @_;
my $pending = $self->_pending_connect;
push @{ $self->seen }, $pending ? $pending->{timeout} : 'no pending';
t/connect_timeout.t view on Meta::CPAN
close $_ for @held;
close $srv;
};
# ---------------------------------------------------------------------------
subtest 'the per-request option overrides the attribute either way' => sub {
# What reached _build__socket, read where the constructors read it. Nothing
# about this can be seen from the outside -- the connect either blocks or it
# does not -- so the assertion is made at the seam the value has to cross.
my @seen;
my $probe = Test::ConnectTimeout::Probe->new(
host => 'unix:///nonexistent-api-docker-61.sock',
api_version => '1.41',
connect_timeout => 5,
seen => \@seen,
);
eval { $probe->get('/probe') };
eval { $probe->get('/probe', connect_timeout => 2) };
# Resolved with exists rather than truth, so 0 is "wait as long as it takes"
# rather than "no opinion", which is how a client-wide default is turned off
t/containers.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
use API::Docker::Role::Entity::Container;
use JSON::MaybeXS qw( encode_json );
check_live_access();
# --- Read Tests (always run) ---
# containers_list/container_inspect (karr k101 follow-up): a real capture,
# taken 2026-08-29 against Docker 29.7.2 (API 1.55) from a disposable
# apidocker-fixture-probe-<random> container (alpine:3, running `sleep 300`)
# created for the purpose and removed again once both endpoints were
# captured -- see the header of t/type_fixture_passthrough.t. Both fixtures
# describe the same container, so their Id fields agree; the list endpoint
# was filtered to that one container so the fixture stays small and
# deterministic, which is why there is only one to assert on here (a second,
# non-running container's is_running shape is covered directly, without a
t/containers.t view on Meta::CPAN
'b20ac7508d80182ba3cd1cbd006ac10c8a15f4f7590fa89c2078d146caf96555',
'container id');
is_deeply($first->names, ['/apidocker-fixture-probe-39a25e34'],
'container names');
is($first->image, 'alpine:3', 'container image');
is($first->state, 'running', 'container state');
ok($first->is_running, 'is_running returns true for running container');
}
};
# --- Write Tests (mock always, live only with WRITE) ---
subtest 'container lifecycle' => sub {
skip_unless_write();
my $name = 'api-docker-test-' . $$;
my $docker = test_docker(
'POST /containers/create' => sub {
my ($method, $path, %opts) = @_;
# containers->create moves `name` out of the config and into the
t/containers.t view on Meta::CPAN
},
);
my $result = $docker->containers->prune(filters => { until => ['24h'] });
is_deeply($result, { ContainersDeleted => ['abc'], SpaceReclaimed => 100 },
'the daemon response is returned unwrapped');
is_deeply($params->{filters}, { until => ['24h'] },
'the filters reached the query string, shape-normalised');
};
# --- 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');
t/containers_endpoints.t view on Meta::CPAN
#!/usr/bin/env perl
use strict;
use warnings;
use Test::More;
use FindBin;
use lib "$FindBin::Bin/lib";
use Test::API::Docker::Mock;
use JSON::MaybeXS qw( encode_json );
use MIME::Base64 qw( encode_base64 );
use API::Docker;
# The container endpoints this client did not expose:
#
# karr k18 GET/PUT/HEAD /containers/{id}/archive -- what docker cp is
# karr k19 POST /containers/{id}/attach -- the one-way variant
# karr k23 changes, export, resize
#
t/containers_endpoints.t view on Meta::CPAN
mtime => '2026-08-27T15:36:51.589296398Z',
linkTarget => '/etc/hostname',
);
my $STAT_HEADER = encode_base64(encode_json(\%STAT), '');
# ---------------------------------------------------------------------------
# A client whose socket is an in-memory sink and whose response is canned, so
# the real _request runs -- and with it raw => 1, raw_body, the query string
# and the verb. Same pattern as t/images_tar.t and t/streaming_shape.t; the
# mock harness replaces _request wholesale and can reach none of it.
package Test::ContainersEndpoints::FakeTransport;
use Moo;
extends 'API::Docker';
has canned => (is => 'rw', default => sub { [200, 'OK', {}, ''] });
has _sink => (is => 'rw');
sub _build__socket {
my ($self) = @_;
my $sink = '';
$self->_sink(\$sink);
t/containers_endpoints.t view on Meta::CPAN
}
sub request_body {
my ($body) = $_[0]->written =~ /\r\n\r\n(.*)\z/s;
return $body;
}
package main;
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';
t/containers_stats_error.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
use API::Docker;
use API::Docker::Error::HTTP;
# Regression coverage for karr k45: GET /containers/{id}/stats for a container
# that is not running is answered by Podman with an error object inside a
# response already committed to 200 --
#
# {"cause":"container is stopped","message":"container is stopped",
# "response":500}
#
t/containers_stats_error.t view on Meta::CPAN
#
# The rule is endpoint-local and deliberately narrow; this file exists to keep
# it that way. The counter-examples below are the point of it as much as the
# positive cases are: POST /containers/{id}/wait answers its SUCCESS case with
# a top-level Error key, so a case-insensitive rule would turn every
# successful wait into a failure, and Podman's own GET /plugins 404 carries
# the same three keys with response => 0.
#
# Measured 2026-08-27 against rootless Podman 5.4.2 (API 1.41) and Docker
# 29.7.2 (API 1.55) on one machine. Nothing here opens a socket: the route
# table of Test::API::Docker::Mock stands in for the daemon, and the live
# subtest at the end runs only with API_DOCKER_TEST_WRITE=1.
check_live_access();
my $STOPPED = {
cause => 'container is stopped',
message => 'container is stopped',
response => 500,
};
t/dist_source.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use Path::Tiny;
use Cwd qw( abs_path );
use Module::Runtime qw( require_module );
# k91: `prove -l` PREPENDS "lib" to @INC, it does not replace it -- an
# installed copy of this distribution stays reachable right behind the
# checkout's own lib/. That is how a module k84 deleted from
# lib/API/Docker/Image.pm kept "loading": t/basic.t's use_ok stayed green
# because API::Docker::Image resolved from
# a local::lib copy (~/perl5/lib/perl5/API/Docker/Image.pm) instead. The installed
t/dist_source.t view on Meta::CPAN
# everything compile" -- that is each module's own t/*.t -- only "whatever
# DID load, did it come from here".
my $dist_root = path(__FILE__)->parent->parent->realpath;
my $lib_dir = $dist_root->child('lib');
my @acceptable_roots = ($lib_dir->stringify);
# `dzil test` builds and runs from a fresh temporary directory under
# <checkout>/.build/<random> -- created new for every run, so it can never
# hold a leftover -- and its TestRunner loads modules from that tree's
# blib/lib, not its lib/. `dzil release`'s [@Filter/TestRelease] instead
# builds the tarball and EXTRACTS it, so this test then runs one level
# deeper still: <checkout>/.build/<random>/<Dist-Version>/, with the
# extracted distribution's own directory as an extra ancestor between
# $dist_root and .build. Checking only the immediate parent (as this used
# to) catches the first shape and misses the second, so every release-time
# run of this test failed even though blib/lib held exactly the right
# modules. Recognise both shapes by walking $dist_root's ancestors for one
# named .build, and only that signal, as what makes blib/lib a second
# acceptable root -- so a blib/ sitting directly in a real, persistent
# checkout (the actual "leftover blib" failure mode, which has no .build
t/distribution.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;
use Test::API::Docker::Mock;
# GET /distribution/{name}/json -- asking a registry for an image manifest
# without pulling it (karr k15).
#
# Nothing here opens a socket or reaches a daemon, and nothing is gated on
# is_live() except the one subtest that says so. Test::API::Docker::Mock is
# deliberately not used for the bulk of this file: under API_DOCKER_TEST_HOST
# it ignores its route table and returns a real client, and the only engine
# reachable here is Podman, which serves no route for this endpoint at all --
# measured, rootless Podman 5.4.2 (API 1.41):
#
# GET /v1.41/distribution/nginx:latest/json
# -> 404 {"cause":"","message":"Path /v1.41/distribution/nginx:latest/json
# is not supported","response":0}
#
# and the same for a bare name and for a percent-escaped reference. That
t/distribution.t view on Meta::CPAN
# does. It is skipped live for the same reason as the rest of this file would
# be if it used Mock: no engine reachable here serves the route to check it
# against.
my $DESCRIPTOR = <<'JSON';
{"Descriptor":{"MediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96","size":3987},"Platforms":[{"architecture":"amd64","os":"linux"}]}
JSON
sub fake_client {
my ($body, $status) = @_;
return Test::API::Docker::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
canned => [$status // 200, 'Not Found', {}, $body // $DESCRIPTOR],
);
}
sub request_line {
my ($raw) = @_;
my ($line) = $raw =~ /\A([^\r\n]*)/;
return $line;
t/entity_container.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
use API::Docker::Role::Entity::Container;
# karr k79 step 6, for containers: the convenience methods live in
# API::Docker::Role::Entity::Container and are composed into the two generated
# classes the daemon answers container requests with. Nothing may be written
# into those classes by hand -- maint/spec-to-type.pl --verify compares them
# against the swagger byte for byte (t/spec_to_type.t) -- so this file is what
# proves the methods arrive anyway, and on both shapes.
#
# Fixture-only throughout: none of it needs a daemon, and the assertions are
t/entity_roles.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
use API::Docker::Role::Entity::Config;
use API::Docker::Role::Entity::Image;
use API::Docker::Role::Entity::Network;
use API::Docker::Role::Entity::Plugin;
use API::Docker::Role::Entity::Secret;
use API::Docker::Role::Entity::Volume;
# karr k84, the six resources k79 step 6/7 still owed after containers:
# Image, Network, Volume, Plugin, Secret, Config. The convenience methods live
# in API::Docker::Role::Entity::<Resource> and are composed into the generated
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
# API::Docker::API::Exec had no per-resource test file of its own (karr
# k109): create's boolean normalisation is covered in t/json_body_booleans.t
# (k100), and resize's query string is covered at the wire level in
# t/containers_endpoints.t, but create's full request shape and inspect's
# forwarding were never asserted anywhere -- t/timeout_forwarding.t only
# proves that a read_timeout/connect_timeout bound reaches inspect's
# request, not that inspect does anything sensible with what it gets back.
check_live_access();
t/filters.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use JSON::MaybeXS;
use API::Docker;
# What the `filters` query parameter looks like on the wire, and what shapes
# API::Docker::Role::Filters refuses to put there.
#
# Nothing here opens a socket or reaches a daemon, in either mode.
# Test::API::Docker::Mock is deliberately not used: under API_DOCKER_TEST_HOST
# it ignores its route table and hands back a real client, and almost
# everything asserted below is a property of the *outgoing* request, which no
# response can show. The daemon is faked under the socket instead, so the
# assertions hold on a machine with a daemon and on one without.
#
# The shapes come from measuring rootless Podman 5.4.2 (API 1.41) on
# 2026-08-27, GET /v1.41/images/json with a hand-built query string:
#
# {"dangling":["true"]} 200, the dangling images
# {"dangling":"true"} 500 json: cannot unmarshal string into Go value
t/filters.t view on Meta::CPAN
# {"dangling":[null]} 500 non-boolean value ... strconv.ParseBool:
# parsing "" -- Go reads a JSON null into a
# string as ""
# {"dangling":[""]} 500, the same message
# {"dangling":["1"]} 200, and so do "0", "true" and "false"
# ---------------------------------------------------------------------------
# A client whose socket is an in-memory sink and whose response is canned, so
# _request assembles a real request line and query string with nothing on the
# other end. Same pattern as t/plugins.t and t/role_http.t.
package Test::Filters::FakeTransport;
use Moo;
extends 'API::Docker';
has canned => (is => 'rw', default => sub { [200, 'OK', {}, '[]'] });
has _sink => (is => 'rw');
sub _build__socket {
my ($self) = @_;
my $sink = '';
$self->_sink(\$sink);
t/filters.t view on Meta::CPAN
sub written {
my ($self) = @_;
my $sink = $self->_sink;
return defined $sink ? $$sink : '';
}
package main;
sub fake_client {
my ($body) = @_;
return Test::Filters::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
canned => [200, 'OK', {}, $body // '[]'],
);
}
sub query_param {
my ($raw, $name) = @_;
my ($line) = $raw =~ /\A([^\r\n]*)/;
my ($qs) = $line =~ /\?([^ ]*) HTTP/;
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
use JSON::MaybeXS qw( decode_json );
# The role, not the type class: it is what composes the entity methods onto
# the generated classes, and constructing one before that has happened inlines
# its constructor and makes the composition impossible ("has been inlined and
# cannot be updated"). Loading API::Docker gets there too, through
# API::Docker::API::Images -- but the first subtest below builds an
# ImageSummary before any client exists.
use API::Docker::Role::Entity::Image;
# The engine's build stream, as captured from a real daemon. build/pull/push
'a tagged image still wins when it is already first',
);
is(
_live_image_name([ $untagged_1, $untagged_2 ]),
'sha256:untagged1',
q{falls back to the first image's id when nothing in the store is tagged},
);
};
# --- Read Tests (always run) ---
# Captured 2026-08-28 (karr k101) against Podman 5.8.4 (Docker-compat API
# 1.44): GET /images/json on this host's real engine, unmodified -- five
# entries because that engine had five images at capture time, two of them
# untagged buildah layers with no RepoTags. See
# t/type_fixture_passthrough.t's "unknown-field regression" subtest for what
# this engine sends beyond the swagger (Digest/History/Names/Dangling).
subtest 'list images' => sub {
my $docker = test_docker(
'GET /images/json' => load_fixture('images_list'),
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 ($pull_params, $tag_params);
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');
$docker->images->tag('nginx:latest', repo => 'myrepo/nginx', tag => 'v1');
is_deeply($tag_params, { repo => 'myrepo/nginx', tag => 'v1' },
'tag sent repo and tag as query params, on the nginx:latest path');
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_build_prune.t view on Meta::CPAN
#!/usr/bin/env perl
use strict;
use warnings;
use Test::More;
use FindBin;
use lib "$FindBin::Bin/lib";
use Test::API::Docker::Mock;
use API::Docker;
# karr k25 -- images->build_prune, POST /build/prune. Clears the BuildKit
# build cache, which is a different store from the dangling images
# images->prune deletes.
#
# The parameter that needs watching is keep-storage: the engine spells it
# with a hyphen, and the transport's _uri_encode must leave that alone rather
# than percent-encoding it into keep%2Dstorage. An unquoted
# `keep-storage => $n` is not even valid Perl -- the fat comma only quotes a
t/images_build_prune.t view on Meta::CPAN
#
# Measured against the rootless Podman socket (5.4.2, API 1.41): Podman does
# not implement the endpoint at all. POST /build/prune answers 404 Not Found
# with a text/plain body of "Not Found" -- not the JSON {"message":...} shape
# its other errors use -- at /build/prune, /v1.41/build/prune,
# /v1.47/build/prune and /v4.0.0/libpod/build/prune alike.
check_live_access();
# See t/images_tar.t for why this exists.
package Test::BuildPrune::FakeTransport;
use Moo;
extends 'API::Docker';
has canned => (is => 'rw', default => sub {
[200, 'OK', {}, '{"CachesDeleted":["abc"],"SpaceReclaimed":1024}'] });
has _sink => (is => 'rw');
sub _build__socket {
my ($self) = @_;
my $sink = '';
t/images_build_prune.t view on Meta::CPAN
return $fh;
}
sub _read_response { return $_[0]->canned }
sub written { return ${ $_[0]->_sink } }
package main;
sub fake_client {
return Test::BuildPrune::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
}
sub request_line {
my ($t) = @_;
my ($line) = $t->written =~ /\A(\S+ [^\r\n]+) HTTP\/1\.1\r\n/;
return $line;
}
t/images_commit.t view on Meta::CPAN
#!/usr/bin/env perl
use strict;
use warnings;
use Test::More;
use FindBin;
use lib "$FindBin::Bin/lib";
use Test::API::Docker::Mock;
use JSON::MaybeXS qw( decode_json );
use API::Docker;
# karr k22 -- images->commit, POST /commit. Turns a container into an image:
# the one image-producing path that does not go through a build context.
#
# Everything asserted about the engine here was measured against the rootless
# Podman socket (5.4.2, API 1.41): 201 Created with {"Id":"<64 hex>"} and no
# sha256: prefix, `changes` accepted both newline-joined and as repeated
# query pairs with the same result, and a ContainerConfig body whose Labels
# and Cmd replace the container's while Env is merged onto the inherited one.
check_live_access();
# ---------------------------------------------------------------------------
# See t/images_tar.t for why this exists: the mock harness replaces _request
# wholesale, so the request line and body it assembles are only reachable
# through a client whose socket is an in-memory sink.
package Test::ImagesCommit::FakeTransport;
use Moo;
extends 'API::Docker';
has canned => (is => 'rw', default => sub { [201, 'Created', {}, '{"Id":"deadbeef"}'] });
has _sink => (is => 'rw');
sub _build__socket {
my ($self) = @_;
my $sink = '';
$self->_sink(\$sink);
t/images_commit.t view on Meta::CPAN
return $fh;
}
sub _read_response { return $_[0]->canned }
sub written { return ${ $_[0]->_sink } }
package main;
sub fake_client {
return Test::ImagesCommit::FakeTransport->new(
host => 'unix:///nonexistent.sock',
api_version => '1.41',
);
}
sub request_line {
my ($t) = @_;
my ($line) = $t->written =~ /\A(\S+ [^\r\n]+) HTTP\/1\.1\r\n/;
return $line;
}
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 );
use API::Docker;
# The encoder is API::Docker::Role::RegistryAuth::_registry_auth_header,
# composed into API::Docker::API::Images -- it used to be a bare sub in
# Images.pm called as a function, and Plugins.pm carried a copy. It is
# exercised here through the class that consumes it, which is what a caller
# reaches, and the shared role itself is covered in t/registry_auth.t.
t/images_registry_auth.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use Test::API::Docker::Mock;
use JSON::MaybeXS qw( decode_json encode_json );
use MIME::Base64 qw( encode_base64 decode_base64 );
use API::Docker;
# pull carries X-Registry-Auth (single AuthConfig), build carries
# X-Registry-Config (a base64url map of registry hostname -> AuthConfig), and
# both go through the one encoder in API::Docker::Role::RegistryAuth. This file
# proves the two headers reach the wire correctly encoded, and pins the
# base64url-alphabet fix: a value pre-encoded in *standard* base64 must be
t/images_tar.t view on Meta::CPAN
#!/usr/bin/env perl
use strict;
use warnings;
use Test::More;
use FindBin;
use lib "$FindBin::Bin/lib";
use Test::API::Docker::Mock;
use API::Docker;
# karr k20 -- the image tar roundtrip: GET /images/{name}/get,
# GET /images/get?names= and POST /images/load. The air-gapped path: export
# here, carry the tar over, load there.
#
# t/fixtures/images_get.tar is a real export from the rootless Podman socket
# (5.4.2, API 1.41) -- a one-layer image imported through
# /images/create?fromSrc=- for the purpose, exported, then deleted again.
# t/fixtures/images_load_stream.ndjson is the body loading it back produced.
t/images_tar.t view on Meta::CPAN
my $TAR = load_fixture_raw('images_get.tar');
my $STREAM = load_fixture_raw('images_load_stream.ndjson');
# ---------------------------------------------------------------------------
# A client whose socket is a captured in-memory sink and whose response is
# canned, so the real _request -- and therefore `raw => 1` and the ndjson
# decode -- runs without a daemon. Mirrors the FakeTransport pattern in
# t/streaming_shape.t and t/role_http.t; the mock harness replaces _request
# wholesale and cannot reach any of it.
package Test::ImagesTar::FakeTransport;
use Moo;
extends 'API::Docker';
has canned => (is => 'rw', default => sub { [200, 'OK', {}, ''] });
has _sink => (is => 'rw');
sub _build__socket {
my ($self) = @_;
my $sink = '';
$self->_sink(\$sink);
t/images_tar.t view on Meta::CPAN
return $fh;
}
sub _read_response { return $_[0]->canned }
sub written { return ${ $_[0]->_sink } }
package main;
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';