API-Docker

 view release on metacpan or  search on metacpan

.claude/CLAUDE.md  view on Meta::CPAN


- **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/

CLAUDE.md  view on Meta::CPAN

   real runtime reason (lazy plugin loading, optional deps), not just to
   defer cost.

8. **`->instance` for `MooX::Singleton` / `MooseX::Singleton` classes.**
   `->new` for everything else.

9. **Never copy `$VERSION` from a Getty-authored repo into a cpanfile.**
   The repo version is the *next* unreleased version. Check
   `cpanm --info` for the actual released version when pinning.

10. **Pin every Getty-authored dependency** to its latest released CPAN
    version in `cpanfile`.

11. **The version in `lib/API/Docker.pm` is the NEXT release.** What's
    currently on CPAN is the previous tag. `dzil release` bumps the
    version automatically — never bump it by hand before a release.

12. **`{{$NEXT}}` in `Changes` is the placeholder for the upcoming
    release.** Add entries under it as you change behavior; `dzil
    release` replaces it with the version + timestamp.

CLAUDE.md  view on Meta::CPAN

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

By default tests are fixture-driven (no Docker daemon needed). Set
`API_DOCKER_TEST_HOST=unix:///var/run/docker.sock` to also exercise the
read-only live paths; add `API_DOCKER_TEST_WRITE=1` to enable mutating
tests (create/remove containers, etc.).

## API conventions

- **Resource accessors live under the client:** `$docker->images`,
  `$docker->containers`, etc. Each returns a `*::API::*` instance.
- **List/inspect endpoints return entity objects** (e.g.
  `$docker->images->list` returns `[API::Docker::Image, ...]`); raw
  endpoints (e.g. `tag`, `push`) return the raw daemon response.
- **`$docker->_request($method, $path, %opts)`** is the single transport
  entry point. Opts: `body` (auto-JSON-encoded), `raw_body` +

CLAUDE.md  view on Meta::CPAN

  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}}`.
- Update the POD on the affected class. POD lives next to the code
  (`=method`, `=attr`, `=head1 SYNOPSIS` ...) and is woven by the
  `@Author::GETTY` bundle.
- If you change a public method signature, check that callers in the
  workspace (notably `../p5-dist-zilla-plugin-docker-api`) still build
  and test green.

LICENSE  view on Meta::CPAN

OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.

                     END OF TERMS AND CONDITIONS

        Appendix: How to Apply These Terms to Your New Programs

  If you develop a new program, and you want it to be of the greatest
possible use to humanity, the best way to achieve this is to make it
free software which everyone can redistribute and change under these
terms.

  To do so, attach the following notices to the program.  It is safest to
attach them to the start of each source file to most effectively convey
the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.

    <one line to give the program's name and a brief idea of what it does.>

LICENSE  view on Meta::CPAN

fall under the restrictions of Paragraphs 3 and 4, provided that you do
not represent such an executable image as a Standard Version of this
Package.

7. C subroutines (or comparably compiled subroutines in other
languages) supplied by you and linked into this Package in order to
emulate subroutines and variables of the language defined by this
Package shall not be considered part of this Package, but are the
equivalent of input as in Paragraph 6, provided these subroutines do
not change the language in any way that would cause it to fail the
regression tests for the language.

8. Aggregation of this Package with a commercial distribution is always
permitted provided that the use of this Package is embedded; that is,
when no overt attempt is made to make this Package's interfaces visible
to the end user of the commercial distribution.  Such use shall not be
construed as a distribution of this Package.

9. The name of the Copyright Holder may not be used to endorse or promote
products derived from this software without specific prior written permission.

META.json  view on Meta::CPAN

         "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"

Makefile.PL  view on Meta::CPAN

    "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,

cpanfile  view on Meta::CPAN

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

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

        host => 'tcp://192.168.1.100:2375',
    );

    # System information
    my $info = $docker->system->info;
    my $version = $docker->system->version;

    # Container management
    my $containers = $docker->containers->list(all => 1);
    my $result = $docker->containers->create(
        Image => 'nginx:latest',
        name  => 'my-nginx',
    );
    $docker->containers->start($result->{Id});

    # Image operations
    $docker->images->pull(fromImage => 'nginx', tag => 'latest');
    my $images = $docker->images->list;

    # Network and volume management
    my $networks = $docker->networks->list;
    my $volumes = $docker->volumes->list;

=head1 DESCRIPTION

API::Docker is a Perl client for the Docker Engine API. It provides a clean
object-oriented interface to manage Docker containers, images, networks, and

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


    # List containers
    my $containers = $docker->containers->list(all => 1);
    for my $container (@$containers) {
        say $container->Id;
        say $container->Status;
    }

    # Create and start a container
    my $result = $docker->containers->create(
        Image => 'nginx:latest',
        name  => 'my-nginx',
        ExposedPorts => { '80/tcp' => {} },
    );
    $docker->containers->start($result->{Id});

    # Inspect container details
    my $container = $docker->containers->inspect($result->{Id});
    say $container->Name;

    # Stop and remove

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


=item * C<size> - Include size information

=item * C<filters> - Hashref of filters

=back

=head2 create

    my $result = $containers->create(
        Image => 'nginx:latest',
        name  => 'my-nginx',
        Cmd   => ['/bin/sh'],
        Env   => ['FOO=bar'],
    );

Create a new container. Returns hashref with C<Id> and C<Warnings>.

The C<name> parameter is extracted and passed as query parameter. All other
parameters are Docker container configuration (see Docker API documentation).

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

    params       => \%params,
  );
}


sub pull {
  my ($self, %opts) = @_;
  croak "fromImage required" unless $opts{fromImage};
  my %params;
  $params{fromImage} = $opts{fromImage};
  $params{tag}       = $opts{tag} // 'latest';
  return $self->client->post('/images/create', undef, params => \%params);
}


sub inspect {
  my ($self, $name) = @_;
  croak "Image name required" unless $name;
  my $result = $self->client->get("/images/$name/json");
  return $self->_wrap($result);
}

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


version 0.002

=head1 SYNOPSIS

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

    # Build an image from a tar context
    use Path::Tiny;
    my $tar = path('context.tar')->slurp_raw;
    $docker->images->build(context => $tar, t => 'myapp:latest');

    # Pull an image
    $docker->images->pull(fromImage => 'nginx', tag => 'latest');

    # List images
    my $images = $docker->images->list;
    for my $image (@$images) {
        say $image->Id;
        say join ', ', @{$image->RepoTags};
    }

    # Inspect image details
    my $image = $docker->images->inspect('nginx:latest');

    # Tag and push
    $docker->images->tag('nginx:latest', repo => 'myrepo/nginx', tag => 'v1');
    $docker->images->push('myrepo/nginx', tag => 'v1');

    # Remove image
    $docker->images->remove('nginx:latest', force => 1);

=head1 DESCRIPTION

This module provides methods for managing Docker images including pulling,
listing, tagging, pushing to registries, and removal.

All C<list> and C<inspect> methods return L<API::Docker::Image> objects.

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

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

=item * C<filters> - Hashref of filters

=back

=head2 build

    # Build from a tar archive
    my $tar_data = path('context.tar')->slurp_raw;
    my $result = $docker->images->build(
        context    => $tar_data,
        t          => 'myimage:latest',
        dockerfile => 'Dockerfile',
    );

    # Build with build args
    my $result = $docker->images->build(
        context   => $tar_data,
        t         => 'myapp:v1',
        buildargs => { APP_VERSION => '1.0' },
        nocache   => 1,
    );

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

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

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

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

=back

=head2 pull

    $images->pull(fromImage => 'nginx', tag => 'latest');

Pull an image from a registry. C<tag> defaults to C<latest>.

=head2 inspect

    my $image = $images->inspect('nginx:latest');

Get detailed information about an image. Returns L<API::Docker::Image> object.

=head2 history

    my $history = $images->history('nginx:latest');

Get image history (layers). Returns ArrayRef of layer information.

=head2 push

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

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

Push an image to a registry. Optionally specify C<tag>.

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.

=head2 tag

    $images->tag('nginx:latest', repo => 'myrepo/nginx', tag => 'v1');

Tag an image with a new repository and/or tag name.

=head2 remove

    $images->remove('nginx:latest', force => 1);

Remove an image.

Options:

=over

=item * C<force> - Force removal

=item * C<noprune> - Do not delete untagged parents

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

=head2 client

Reference to L<API::Docker> client.

=head2 Id

Image ID (usually sha256:... hash).

=head2 RepoTags

ArrayRef of repository tags (e.g., C<["nginx:latest", "nginx:1.21"]>).

=head2 Size

Image size in bytes.

=head2 inspect

    my $updated = $image->inspect;

Get fresh image information.

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

t/basic.t  view on Meta::CPAN

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) {
    isa_ok($containers->[0], 'API::Docker::Container');
    ok($containers->[0]->Id, 'has Id');
  }

  unless (is_live()) {
    is(scalar @$containers, 2, 'two containers');

    my $first = $containers->[0];
    is($first->Id, 'abc123def456', 'container id');
    is_deeply($first->Names, ['/my-container'], 'container names');
    is($first->Image, 'nginx:latest', 'container image');
    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'],
      Processes => [
        ['root', '12345', '1', '0', '08:00', '?', '00:00:00', 'sleep'],
      ],
    },
    'GET /containers/mock123/stats'   => {
      cpu_stats    => { cpu_usage => { total_usage => 1000 } },
      memory_stats => { usage => 50000000 },
    },
    'POST /containers/mock123/pause'   => undef,
    'POST /containers/mock123/unpause' => undef,
    'POST /containers/mock123/stop'    => undef,
    'DELETE /containers/mock123'       => undef,
  );

  my $name = 'api-docker-test-' . $$;
  my $created = $docker->containers->create(
    name  => $name,
    Image => 'alpine:latest',
    Cmd   => ['sleep', '10'],
  );
  ok($created->{Id}, 'created container has Id');
  my $id = is_live() ? $created->{Id} : 'mock123';

  register_cleanup(sub { $docker->containers->remove($id, force => 1) }) if is_live();

  $docker->containers->start($id);
  pass('container started');

t/containers.t  view on Meta::CPAN


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

  eval { $docker->containers->stop(undef) };
  like($@, qr/Container ID required/, 'croak on missing ID for stop');
};

done_testing;

t/fixtures/container_inspect.json  view on Meta::CPAN

      "MaximumRetryCount": 0
    }
  },
  "Config": {
    "Hostname": "abc123def456",
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "NGINX_VERSION=1.25.3"
    ],
    "Cmd": ["nginx", "-g", "daemon off;"],
    "Image": "nginx:latest",
    "Labels": {
      "maintainer": "NGINX"
    }
  },
  "NetworkSettings": {
    "Bridge": "",
    "Ports": {
      "80/tcp": [
        {
          "HostIp": "0.0.0.0",

t/fixtures/containers_list.json  view on Meta::CPAN

[
  {
    "Id": "abc123def456",
    "Names": ["/my-container"],
    "Image": "nginx:latest",
    "ImageID": "sha256:abc123",
    "Command": "nginx -g 'daemon off;'",
    "Created": 1705300000,
    "State": "running",
    "Status": "Up 2 hours",
    "Ports": [
      {
        "IP": "0.0.0.0",
        "PrivatePort": 80,
        "PublicPort": 8080,

t/fixtures/images_list.json  view on Meta::CPAN

[
  {
    "Id": "sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd",
    "ParentId": "",
    "RepoTags": ["nginx:latest", "nginx:1.25"],
    "RepoDigests": ["nginx@sha256:abc123"],
    "Created": 1705300000,
    "Size": 187654321,
    "SharedSize": 75000000,
    "VirtualSize": 187654321,
    "Labels": {
      "maintainer": "NGINX"
    },
    "Containers": 2
  },

t/fixtures/networks_list.json  view on Meta::CPAN

          "Subnet": "172.18.0.0/16",
          "Gateway": "172.18.0.1"
        }
      ]
    },
    "Internal": false,
    "Attachable": false,
    "Ingress": false,
    "Options": {},
    "Labels": {
      "project": "test"
    }
  }
]

t/fixtures/system_info.json  view on Meta::CPAN

  "Images": 25,
  "Driver": "overlay2",
  "DriverStatus": [
    ["Backing Filesystem", "extfs"],
    ["Supports d_type", "true"],
    ["Using metacopy", "false"],
    ["Native Overlay Diff", "true"]
  ],
  "DockerRootDir": "/var/lib/docker",
  "SystemTime": "2025-01-15T10:30:00.000000000Z",
  "Name": "test-host",
  "ServerVersion": "27.4.1",
  "OperatingSystem": "Debian GNU/Linux 12 (bookworm)",
  "OSType": "linux",
  "Architecture": "x86_64",
  "NCPU": 4,
  "MemTotal": 8370917376,
  "KernelVersion": "6.1.0-9-amd64",
  "HttpProxy": "",
  "HttpsProxy": "",
  "NoProxy": "",

t/fixtures/volumes_list.json  view on Meta::CPAN

{
  "Volumes": [
    {
      "Name": "my-data",
      "Driver": "local",
      "Mountpoint": "/var/lib/docker/volumes/my-data/_data",
      "CreatedAt": "2025-01-10T08:00:00Z",
      "Status": {},
      "Labels": {
        "project": "test"
      },
      "Scope": "local",
      "Options": {}
    },
    {
      "Name": "postgres-data",
      "Driver": "local",
      "Mountpoint": "/var/lib/docker/volumes/postgres-data/_data",
      "CreatedAt": "2025-01-11T09:00:00Z",
      "Status": {},

t/images.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 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) {
    isa_ok($images->[0], 'API::Docker::Image');
    ok($images->[0]->Id, 'has Id');
  }

  unless (is_live()) {
    is(scalar @$images, 2, 'two images');

    my $first = $images->[0];
    like($first->Id, qr/^sha256:abc123/, 'image id');
    is_deeply($first->RepoTags, ['nginx:latest', 'nginx:1.25'], 'repo tags');
    is($first->Size, 187654321, 'image size');
    is($first->Containers, 2, 'container count');
  }
};

subtest 'inspect image' => sub {
  my $docker = test_docker(
    'GET /images/nginx:latest/json' => {
      Id           => 'sha256:abc123',
      RepoTags     => ['nginx:latest'],
      Architecture => 'amd64',
      Os           => 'linux',
      Size         => 187654321,
      Config       => {
        Cmd => ['nginx', '-g', 'daemon off;'],
      },
    },
  );

  my $image;
  if (is_live()) {
    my $images = $docker->images->list;
    if (@$images) {
      my $name = $images->[0]->RepoTags ? $images->[0]->RepoTags->[0] : $images->[0]->Id;
      $image = $docker->images->inspect($name);
    } else {
      plan skip_all => 'No images available for inspect test';
      return;
    }
  } else {
    $image = $docker->images->inspect('nginx:latest');
  }

  isa_ok($image, 'API::Docker::Image');
  ok($image->Id, 'has Id');

  unless (is_live()) {
    is($image->Id, 'sha256:abc123', 'image id');
    is($image->Architecture, 'amd64', 'architecture');
    is($image->Os, 'linux', 'os');
  }
};

subtest 'image history' => sub {
  my $docker = test_docker(
    'GET /images/nginx:latest/history' => [
      {
        Id        => 'sha256:abc123',
        Created   => 1705300000,
        CreatedBy => '/bin/sh -c #(nop) CMD ["nginx" "-g" "daemon off;"]',
        Size      => 0,
      },
      {
        Id        => 'sha256:def456',
        Created   => 1705299000,
        CreatedBy => '/bin/sh -c apt-get update',

t/images.t  view on Meta::CPAN

    ],
  );

  my $history;
  if (is_live()) {
    my $images = $docker->images->list;
    if (@$images) {
      my $name = $images->[0]->RepoTags ? $images->[0]->RepoTags->[0] : $images->[0]->Id;
      $history = $docker->images->history($name);
    } else {
      plan skip_all => 'No images available for history test';
      return;
    }
  } else {
    $history = $docker->images->history('nginx:latest');
  }

  is(ref $history, 'ARRAY', 'history is array');

  unless (is_live()) {
    is(scalar @$history, 2, 'two history entries');
  }
};

subtest 'search images' => sub {
  my $docker = test_docker(
    'GET /images/search' => [
      {
        name         => 'nginx',
        description  => 'Official nginx image',
        star_count   => 19000,
        is_official  => 1,
        is_automated => 0,
      },
    ],
  );

t/images.t  view on Meta::CPAN


  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' };
    },
    'POST /images/create' => sub {
      my ($method, $path, %opts) = @_;
      return '';
    },
    'POST /images/nginx:latest/tag'  => undef,
    'DELETE /images/nginx:latest'    => [
      { Untagged => 'nginx:latest' },
      { Deleted  => 'sha256:abc123' },
    ],
  );

  if (is_live()) {
    my $dockerfile = "FROM alpine:latest\nRUN echo 'hello from api-docker-test'\n";

    my $filename = 'Dockerfile';
    my $size = length($dockerfile);

    my $header = pack('a100', $filename);
    $header .= pack('a8', sprintf('%07o', 0644));
    $header .= pack('a8', sprintf('%07o', 0));
    $header .= pack('a8', sprintf('%07o', 0));
    $header .= pack('a12', sprintf('%011o', $size));
    $header .= pack('a12', sprintf('%011o', time()));

t/images.t  view on Meta::CPAN


    my $checksum = 0;
    $checksum += ord(substr($header, $_, 1)) for 0..511;
    substr($header, 148, 8, sprintf('%06o', $checksum) . "\0 ");

    my $tar = $header;
    $tar .= $dockerfile;
    $tar .= "\0" x (512 - ($size % 512)) if $size % 512;
    $tar .= "\0" x 1024;

    my $tag = 'api-docker-test-build:latest';
    my $result = $docker->images->build(context => $tar, t => $tag, q => 1);
    ok($result, 'build returned result');
    register_cleanup(sub { eval { $docker->images->remove($tag, force => 1) } });
  } else {
    my $result = $docker->images->build(
      context    => 'fake-tar-data',
      t          => 'myapp:latest',
      dockerfile => 'Dockerfile',
    );
    ok($result, 'build returned a result');
    like($result->{stream}, qr/Successfully built/, 'build output contains success');

    $docker->images->pull(fromImage => 'nginx', tag => 'latest');
    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();

  eval { $docker->images->inspect(undef) };
  like($@, qr/Image name required/, 'croak on missing name for inspect');

  eval { $docker->images->remove(undef) };
  like($@, qr/Image name required/, 'croak on missing name for remove');
};

done_testing;

t/images_push_auth.t  view on Meta::CPAN

use API::Docker::API::Images;

sub b64url_decode {
    my ($s) = @_;
    $s =~ tr{-_}{+/};
    my $pad = (4 - length($s) % 4) % 4;
    $s .= '=' x $pad;
    return decode_base64($s);
}

subtest 'empty/undef auth -> base64url("{}")' => sub {
    my $hdr = API::Docker::API::Images::_build_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 = API::Docker::API::Images::_build_registry_auth_header($auth);
    is_deeply(decode_json(b64url_decode($hdr)), $auth,
        'header roundtrips through base64url + JSON');
};

subtest 'identitytoken auth' => sub {
    my $auth = { identitytoken => 'tok-123', serveraddress => 'ghcr.io' };
    my $hdr = API::Docker::API::Images::_build_registry_auth_header($auth);
    is_deeply(decode_json(b64url_decode($hdr)), $auth,
        'identitytoken roundtrips');
};

subtest 'pre-encoded base64-like string passes through' => sub {
    my $pre = 'eyJ1IjoibWUifQ';
    is API::Docker::API::Images::_build_registry_auth_header($pre), $pre,
        'string passed through unchanged';
};

subtest 'push() sends X-Registry-Auth via _request' => sub {
    require API::Docker;
    my $docker = API::Docker->new(
        host        => 'unix:///dev/null',
        api_version => '1.47',
    );

    my $captured;
    my $mock = sub {
        my ($self, $method, $path, %opts) = @_;
        $captured = { method => $method, path => $path, %opts };

t/images_push_auth.t  view on Meta::CPAN

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

my $FIXTURES_DIR = path(__FILE__)->parent->parent->parent->parent->parent->child('fixtures');

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

sub is_live {
  return !!$ENV{API_DOCKER_TEST_HOST};
}

sub can_write {
  return is_live() && !!$ENV{API_DOCKER_TEST_WRITE};
}

sub skip_unless_write {
  if (is_live() && !can_write()) {
    plan skip_all => 'Write tests skipped (set API_DOCKER_TEST_WRITE=1 to enable)';
  }
}

sub check_live_access {
  return unless is_live();

  my $host = $ENV{API_DOCKER_TEST_HOST};
  if ($host =~ m{^unix://(.+)$}) {
    unless (-S $1) {
      plan skip_all => "Docker socket $1 not available";

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

}

sub _run_cleanups {
  for my $cleanup (reverse @_cleanups) {
    eval { $cleanup->() };
    warn "Cleanup failed: $@" if $@;
  }
  @_cleanups = ();
}

sub test_docker {
  my (%routes) = @_;

  if (is_live()) {
    require API::Docker;
    return API::Docker->new(host => $ENV{API_DOCKER_TEST_HOST});
  }

  return _mock_docker(%routes);
}

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) {
    isa_ok($networks->[0], 'API::Docker::Network');
    ok($networks->[0]->Name, 'has Name');
  }

t/networks.t  view on Meta::CPAN

    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 => '' };
    },
    'GET /networks/mock-net-123'             => {
      Name   => 'test-net',
      Id     => 'mock-net-123',
      Driver => 'bridge',
      Scope  => 'local',
      Labels => {},
    },
    'POST /networks/mock-net-123/connect'    => undef,
    'POST /networks/mock-net-123/disconnect' => undef,
    'DELETE /networks/mock-net-123'          => undef,
  );

  my $name = 'api-docker-test-net-' . $$;
  my $result = $docker->networks->create(
    Name   => is_live() ? $name : 'test-net',
    Driver => 'bridge',
  );
  ok($result->{Id}, 'created network has Id');
  my $id = is_live() ? $result->{Id} : 'mock-net-123';

  register_cleanup(sub { eval { $docker->networks->remove($id) } }) if is_live();

  my $network = $docker->networks->inspect($id);
  isa_ok($network, 'API::Docker::Network');
  ok($network->Name, 'has Name');

t/networks.t  view on Meta::CPAN

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

subtest 'connect requires container' => sub {
  my $docker = test_docker();

  eval { $docker->networks->connect('net1') };
  like($@, qr/Container required/, 'croak on missing container for connect');
};

done_testing;

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;

    ok(_get_changes($newver), "$changes_file has content for $newver");
}

done_testing;

sub _get_changes
{
    my $newver = shift;

    # parse changelog to find commit message
    open(my $fh, '<', $changes_file) or die "cannot open $changes_file: $!";
    my $changelog = join('', <$fh>);
    if ($encoding) {
        require Encode;

t/system.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 'system info' => sub {
  my $docker = test_docker(
    'GET /info' => load_fixture('system_info'),
  );

  my $info = $docker->system->info;

  ok(defined $info->{Containers}, 'has Containers');
  ok(defined $info->{Images}, 'has Images');
  ok($info->{ServerVersion}, 'has ServerVersion');
  ok($info->{OperatingSystem}, 'has OperatingSystem');
  ok($info->{Architecture}, 'has Architecture');

  unless (is_live()) {
    is($info->{Containers}, 14, 'container count');
    is($info->{ContainersRunning}, 3, 'running containers');
    is($info->{ContainersPaused}, 1, 'paused containers');
    is($info->{ContainersStopped}, 10, 'stopped containers');
    is($info->{Images}, 25, 'image count');
    is($info->{Driver}, 'overlay2', 'storage driver');
    is($info->{Name}, 'test-host', 'hostname');
    is($info->{ServerVersion}, '27.4.1', 'server version');
    is($info->{OperatingSystem}, 'Debian GNU/Linux 12 (bookworm)', 'os');
    is($info->{Architecture}, 'x86_64', 'architecture');
    is($info->{NCPU}, 4, 'cpu count');
  }
};

subtest 'system version' => sub {
  my $docker = test_docker(
    'GET /version' => load_fixture('system_version'),
  );

  my $version = $docker->system->version;

  ok($version->{Version}, 'has Version');
  ok($version->{ApiVersion}, 'has ApiVersion');
  ok($version->{Os}, 'has Os');
  ok($version->{Arch}, 'has Arch');

  unless (is_live()) {
    is($version->{Version}, '27.4.1', 'docker version');
    is($version->{ApiVersion}, '1.47', 'api version');
    is($version->{MinAPIVersion}, '1.24', 'min api version');
    is($version->{Os}, 'linux', 'os');
    is($version->{Arch}, 'amd64', 'arch');
  }
};

subtest 'ping' => sub {
  my $docker = test_docker(
    'GET /_ping' => 'OK',
  );

  my $result = $docker->system->ping;
  is($result, 'OK', 'ping returns OK');
};

subtest 'system df' => sub {
  my $docker = test_docker(
    'GET /system/df' => {
      LayersSize => 1000000000,
      Images     => [
        { Id => 'sha256:abc', Size => 500000000, SharedSize => 200000000 },
      ],
      Containers => [
        { Id => 'abc123', SizeRw => 10000, SizeRootFs => 500000000 },
      ],
      Volumes => [
        { Name => 'my-data', UsageData => { Size => 100000000 } },

t/system.t  view on Meta::CPAN

  is(ref $df->{Volumes}, 'ARRAY', 'has Volumes array');

  unless (is_live()) {
    is($df->{LayersSize}, 1000000000, 'layers size');
    is(scalar @{$df->{Images}}, 1, 'one image');
    is(scalar @{$df->{Containers}}, 1, 'one container');
    is(scalar @{$df->{Volumes}}, 1, 'one volume');
  }
};

subtest 'events' => sub {
  my $docker = test_docker(
    'GET /events' => [
      {
        Type   => 'container',
        Action => 'start',
        Actor  => { ID => 'abc123' },
        time   => 1705300000,
      },
    ],
  );

  my $events = $docker->system->events(since => 1705290000, until => 1705310000);

  is(ref $events, 'ARRAY', 'events is array');

  unless (is_live()) {
    is($events->[0]{Type}, 'container', 'event type');
    is($events->[0]{Action}, 'start', 'event action');
  }
};

done_testing;

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;

  ok($version->{ApiVersion}, 'has ApiVersion');
  ok($version->{Version}, 'has Version');
  ok($version->{Os}, 'has Os');
  ok($version->{Arch}, 'has Arch');

  unless (is_live()) {
    is($version->{ApiVersion}, '1.47', 'ApiVersion correct');
    is($version->{Version}, '27.4.1', 'Version correct');
    is($version->{Os}, 'linux', 'Os correct');
    is($version->{Arch}, 'amd64', 'Arch correct');
    is($version->{GoVersion}, 'go1.22.10', 'GoVersion correct');
    is($version->{MinAPIVersion}, '1.24', 'MinAPIVersion correct');
  }
};

subtest 'explicit version skips negotiation' => sub {
  my $docker = API::Docker->new(api_version => '1.45');
  is($docker->api_version, '1.45', 'explicit version preserved');
};

subtest 'auto-negotiate version' => sub {
  if (is_live()) {
    my $docker = API::Docker->new(host => $ENV{API_DOCKER_TEST_HOST});
    $docker->negotiate_version;
    ok(defined $docker->api_version, 'api_version negotiated');
    like($docker->api_version, qr/^\d+\.\d+$/, 'version looks valid');
  } else {
    my $docker = test_docker(
      'GET /version' => load_fixture('system_version'),
    );
    is($docker->api_version, '1.47', 'api_version matches fixture');
  }
};

done_testing;

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) {
    isa_ok($volumes->[0], 'API::Docker::Volume');
    ok($volumes->[0]->Name, 'has Name');
  }

  unless (is_live()) {
    is(scalar @$volumes, 2, 'two volumes');

    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',
        Driver     => 'local',
        Mountpoint => '/var/lib/docker/volumes/test-vol/_data',
        CreatedAt  => '2025-01-15T12:00:00Z',
        Labels     => {},
        Scope      => 'local',
        Options    => {},
      };
    },
    'GET /volumes/test-vol' => {
      Name       => 'test-vol',
      Driver     => 'local',
      Mountpoint => '/var/lib/docker/volumes/test-vol/_data',
      CreatedAt  => '2025-01-10T08:00:00Z',
      Labels     => {},
      Scope      => 'local',
      Options    => {},
    },
    'DELETE /volumes/test-vol' => undef,
  );

  my $name = is_live() ? 'api-docker-test-vol-' . $$ : 'test-vol';
  my $volume = $docker->volumes->create(Name => $name);
  isa_ok($volume, 'API::Docker::Volume');
  ok($volume->Name, 'created volume has Name');

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

done_testing;



( run in 1.837 second using v1.01-cache-2.11-cpan-7fcb06a456a )