API-Docker

 view release on metacpan or  search on metacpan

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

# API-Docker House Rules

Apply to every task in this distribution unless explicitly overridden. Bias: caution over
speed on non-trivial work; use judgment on trivial tasks. Loaded automatically at launch
(same priority as `CLAUDE.md`). Subagents get their discipline from the skills
force-loaded via `briefing.skills` — this file is for the orchestrating agent.

## Engineering discipline

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

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

  |---|---|
  | Anything turning on what the daemon does or expects — endpoints, wire formats, filters, registry auth, version gating | `api-docker-engine-worker` |
  | The Perl side — Moo, transport internals, entity classes, refactoring, cpanfile | `api-docker-worker` (default) |
  | Write/extend tests, add fixtures | `api-docker-test-writer` |
  | The generated type model, the `API::Docker::Type` DSL, the drift checker, `spec/` | `api-docker-type-writer` |
  | Pre-release audit | `api-docker-release-checker` |
  | POD and README | `api-docker-doc-writer` |

  The two workers split by *question*, not by file: "what does the engine answer here?"
  is the engine-worker's, "how is this distribution built?" is the plain worker's. Only
  the engine-worker carries the Engine API reference — the other one guessing at daemon
  behavior is how a wrong assumption gets cemented.

- **You cannot spawn subagents** (you ARE an `api-docker-*` agent): the delegation lock
  does not apply to you — implement, refactor, debug, and test per these rules.

Behavior-relevant = the HTTP transport and everything it returns, the resource API method
surface, entity wrappers, request/response encoding, error handling, `cpanfile`, and
tests. Pure prose docs and `Changes` notes are not.

## Parallel fan-out — isolate the working tree

.claude/skills/api-docker-type-model/SKILL.md  view on Meta::CPAN

the spec's own `description`. Docker's definitions are flat, so a quoted
class name is a short name under `API::Docker::Type::` — there is no prefix
map. `docker_extends` is how the swagger's `allOf` is expressed.

## The rules that are not obvious

**The Perl name is derived from the spec's spelling, never the reverse.**
`PortBindings` → `port_bindings` works; going back does not, for any name
with a run of capitals — `CPUShares`, `OOMKillDisable`, `ID`, `NanoCPUs`. So
those names live in `spec-to-type-names.yaml` as a curated map, and the
generator refuses to guess: an unlisted capital-run name stops the run. The
round-trip guard does not save you here, because `DeviceIDs → device_i_ds`
derives back correctly and still reads wrong.

**Some keys are the caller's data and must never be translated.** Where the
swagger says `additionalProperties`, the *keys* come from the user —
`Labels`, `Annotations`, `ExposedPorts`, `PortBindings`, `Volumes`,
`StorageOpt`, `Tmpfs`, `Sysctls`, `DriverOpts`, `Options` and a good many
more. Such a field is typed `{ Str, $value_type }` and the DSL passes its
keys through byte for byte. Getting this wrong turns a label
`com.example.Some-Label` into something the caller never wrote, and it is the

.claude/skills/getty-perl-core/SKILL.md  view on Meta::CPAN

- **One topic, one bullet, one to three lines** — touching an area again rewrites the bullet that is already there instead of adding a second. Wording and length: `getty-git-commit-style`.
- **Skip pure dev-tooling noise** — skill hardlinks, editor config, internal CI refactors. A CI fix that unbreaks the build for everyone IS worth a line.
- **Never hand-edit the version line or timestamp** — `[NextRelease]` owns those.

## Forbidden

❌ `require Foo` inside a method to "speed up startup" · ❌ a Getty repo's `$VERSION` as a cpanfile requirement · ❌ `'0'` or `'>= x'` as a version argument · ❌ `default => sub {...}` for a non-trivial attribute default · ❌ 4-space indent ...

## When in doubt

Grep hand-written Getty code for how the pattern is used there — the reference is an older repo with no AI commits in its history. Newer repos may show an agent's guess rather than the house rule.

Changes  view on Meta::CPAN

  - `images->build`, `->pull` and `->push` now always return an ArrayRef
    of events. `_request` used to try `decode_json` on the whole body
    first and only fall back to line-by-line parsing, so a stream that
    carried exactly one JSON object came back as a HashRef while a
    multi-event stream came back as an ArrayRef, and every caller had to
    check `ref` before iterating. Measured on Podman: `POST /build?q=1`
    emits exactly one object, which is the case that used to change
    shape. The ordinary single-JSON-object endpoints (`/version`,
    `/containers/{id}/json`, ...) are untouched and still return a
    HashRef -- the streaming behaviour is now requested explicitly with
    the new `ndjson => 1` transport option rather than guessed from the
    body. The option is named for the format and not `stream`, which is
    already a query parameter of `/events` and
    `/containers/{id}/stats`.
    `system->events` takes the same option. It was reaching an ArrayRef
    only through the implicit fallback that has now gone, so without it
    the endpoint would have quietly started returning an undecoded
    string. Measured on Podman for one container create/init/start/
    died/remove cycle: five newline-delimited objects, and the body is
    not valid JSON as a whole. Its POD now also says to always pass
    `until`, since the transport buffers the whole response and an

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

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

=over

=item C<DOCKER_HOST>

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

    $spec{Labels} = { app => 'web', tier => 'edge' };

    $configs->update($id, $config->version_index, %spec);
    $config->update(%spec);                   # the same call, via the entity

Update a config. Returns nothing on success -- the daemon answers 200 with an
empty body.

C<$version> is mandatory and is the C<Version.Index> from L</inspect>; see
L</"update takes the current version, and it is mandatory"> for why it cannot
be guessed and why the whole spec goes back.
L<API::Docker::Role::Entity::Config/update> fills it in from the entity it
was called on.

A C<Data> passed here is treated like L</create>'s: raw bytes, encoded on the
way out. The C<Data> in a spec from L</inspect> is already base64, so drop
it, or replace it with
L<API::Docker::Role::Entity::Config/decoded_data>, before handing that spec
back -- otherwise it gets encoded twice.

=head2 remove

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

#   2. the decoded value is a HashRef
#   3. it carries all three of cause, message and response, exactly
#      lower-cased. Never case-insensitively, and this is the counter-example
#      that fixes it: POST /containers/{id}/wait answers its SUCCESS case with
#      a top-level `Error` key -- Podman sends "Error":null on every wait --
#      so a rule matching /error/i would turn every successful wait into a
#      failure. Measured over fifteen read endpoints per engine and every
#      fixture in t/fixtures: no 2xx body on either engine carries even one of
#      these three lower-cased at the top level
#   4. `response` is a non-ref scalar reading as an integer >= 400. That is
#      what makes the rule self-evidencing rather than a guess about prose:
#      the object is an error because Podman says so inside it. Known miss:
#      Podman's GET /plugins answers {"cause":"","message":"Path ... is not
#      supported","response":0}, which clause 4 rejects -- but it arrives with
#      404 on the status line and the transport croaks it long before this
#      runs, so the miss goes in the conservative direction and costs nothing
#
# A bare {message => ...} deliberately does not trigger: that is the ordinary
# Docker error body, and treating one inside a 2xx as a failure would be a
# guess about prose rather than a reading of what the engine said.
sub _podman_error_object {
  my ($self, $value) = @_;

  return unless ref $value eq 'HASH';
  return unless exists $value->{cause}
    && exists $value->{message}
    && exists $value->{response};

  my $response = $value->{response};
  return if ref $response;

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

    $spec{Labels} = { env => 'staging' };

    $secrets->update($id, $secret->version_index, %spec);
    $secret->update(%spec);                   # the same call, via the entity

Update a secret. Returns nothing on success -- the daemon answers 200 with an
empty body.

C<$version> is mandatory and is the C<Version.Index> from L</inspect>; see
L</"update takes the current version, and it is mandatory"> for why it cannot
be guessed and why the whole spec goes back.
L<API::Docker::Role::Entity::Secret/update> fills it in from the entity it
was called on. Podman does not implement this
endpoint and answers 501.

=head2 remove

    $secrets->remove($id);

Remove a secret by ID or name. The daemon answers 204 with no body, so this
returns nothing; a secret that is not there is a 404 and croaks.

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

more place that is not a closed connection but has the same consequence: a
chunk size line that arrived in full and is not a hexadecimal number, which
would otherwise be misread as a zero chunk and end the body early (see
L</phase>).

It is a structural check, not a heuristic, and it asks one of two questions
depending on how the piece is delimited. Where the response announced a length
it compares what arrived against it. Where the framing is by terminator
instead -- the head, and the chunk headers -- it asks whether the terminator
came before the stream ended, which needs nothing to compare and is just as
decidable. Neither is a guess about content: a header block that never closed
is not a short one, it is an unfinished one.

A body delimited by nothing but the close -- C<attach>,
C<< logs(follow => 1) >>, C</exec/{id}/start>, the whole
C<application/vnd.docker.raw-stream> family -- announces no end and has no
terminator either, so there an EOF B<is> the end and this is never raised.
Its B<head> is framed like any other, and is checked like any other.

=head2 Why it is fatal

t/containers_endpoints.t  view on Meta::CPAN

# See the fixture-loading comments below for what each measurement found.

check_live_access();

# GET /containers/{id}/archive?path=/etc/hostname, captured from a running
# apidocker-fixture-archive container on Podman 5.4.2 (API 1.41) -- karr k36
# replaced the hand-built ustar that stood in here before. Measured
# differences from the hand-built version: uname/gname were populated
# ('root'/'root', not empty) on that 5.4.2 socket, devmajor/devminor are the
# ASCII string '0000000' rather than left as raw NUL bytes, and mode reflects
# the file's real permissions (0644, not the guessed 0664). Block size (512),
# the two trailing all-zero blocks that end the archive, the ustar
# magic/version, and the empty prefix field were already right in the
# hand-built one.
#
# karr k62 re-measured the same archive live on Podman 5.8.4 (API 1.44):
# uname/gname now come back NUL rather than 'root', byte-identical to a
# Docker 29.7.2 capture of the same file -- Podman changed to match Docker
# here, so this is no longer a difference between the two engines. The
# fixture below is kept as the 5.4.2 capture rather than recaptured: nothing
# in this file asserts uname/gname (only length, the ustar magic, the member

t/containers_endpoints.t  view on Meta::CPAN

# Podman quirk but behaviour the reference leaves unspecified for both.
#
# The live subtests below still never call attach: the transport buffers, and
# an explicit stream => 1 is still a hang waiting to happen.
my $FRAMES = load_fixture_raw('containers_logs_multiplexed.bin');

# X-Docker-Container-Path-Stat for /etc/hostname, decoded from a real header
# captured alongside the archive above (karr k36) -- against the Podman
# socket, so this models Podman's shape specifically, not "the" shape. A
# later side-by-side against a real Docker daemon (29.7.2, API 1.55) on the
# same file confirmed what had only been a guess here: Podman's key names
# match the Docker Engine API reference for five of them (name, size, mode,
# mtime, linkTarget); the sixth, isDir, is Podman's own addition -- Docker
# never sends it, not even for a directory. Two more measured differences
# from Docker: linkTarget is populated here even for a plain regular file
# (Podman echoes the resolved path rather than leaving it empty, which is
# what Docker does), and mode is Go's os.FileMode, not a POSIX stat.st_mode
# word -- for this regular file the two are numerically identical (0644, no
# type bits), but they diverge for a directory. See the live subtest below
# for the Docker-side numbers next to these, and for the case that tells
# FileMode and st_mode apart.

t/containers_endpoints.t  view on Meta::CPAN

    'POST /v1.41/containers/deadbeef/attach?logs=0&stderr=0&stdin=1&stdout=0&stream=1 HTTP/1.1',
    'every one of the five is sent as asked, false as 0';

  $t->containers->attach('deadbeef', stdin => 0, require_running => 0);
  is $t->request_line,
    'POST /v1.41/containers/deadbeef/attach?logs=1&stderr=1&stdin=0&stdout=1&stream=0 HTTP/1.1',
    'stdin appears only when named; a false one is still sent';

  # logs => 0 alone leaves both flags off, which the engine refuses outright:
  # Podman answers 400 "at least one of Logs or Stream must be set". The
  # client passes it through rather than second-guessing it.
  $t->containers->attach('deadbeef', logs => 0, require_running => 0);
  is $t->request_line,
    'POST /v1.41/containers/deadbeef/attach?logs=0&stderr=1&stdout=1&stream=0 HTTP/1.1',
    'logs => 0 alone is sent as asked -- the both-off 400 is the engine\'s call';

  # require_running is a client-side option and must not reach the engine as
  # one: the engine has no such query parameter and would ignore it silently.
  unlike $t->request_line, qr/require_running/,
    'require_running is consumed here, never sent as a query parameter';

t/containers_endpoints.t  view on Meta::CPAN


  # What both engines agree on for a plain regular file: name is the
  # basename and the low nine bits of mode are the POSIX permission bits.
  is $stat->{name}, 'hostname', 'name is the basename, not the full path';
  is $stat->{mode} & 0777, 0644, 'the permission bits are the low nine of mode';

  # The key set and isDir/linkTarget do not: side-by-side measurement against
  # both engines (karr k36, later re-verified against a real Docker daemon --
  # 29.7.2, API 1.55 -- next to Podman 5.4.2, API 1.41, same container, same
  # file) found isDir is Podman's own addition, confirmed rather than
  # guessed: Docker never sends it, not even for a directory. linkTarget
  # differs too -- Docker leaves it empty for a plain file, matching the
  # Docker Engine API reference; Podman echoes the resolved path instead.
  if ($engine eq 'podman') {
    is_deeply [ sort keys %$stat ], [qw( isDir linkTarget mode mtime name size )],
      'Podman: six keys, isDir alongside the five the reference documents';
    is $stat->{linkTarget}, '/etc/hostname',
      'Podman: linkTarget is populated even for a plain regular file -- it '
      . 'echoes the resolved path here rather than leaving it empty';
    ok !$stat->{isDir}, 'Podman: /etc/hostname is not a directory';
  }

t/spec_to_type.t  view on Meta::CPAN

  # zero here means the spec grew a definition and nobody noticed -- which is
  # the drift checker's report, arrived at from the other side.
  my $out = qx{$^X \Q$SCRIPT\E --stage \Q$stage\E/nothing 2>&1};
  like $out, qr/rendered\s+0 class\(es\)/,
    'no class in the spec is missing from lib/';
  unlike $out, qr/NEEDS A/,
    'and nothing is blocked waiting for a name or an abstract';
};

subtest 'a name with a run of capitals must be in the map' => sub {
  # Silently guessing is how `device_i_ds` would reach a hundred classes at
  # once: the derivation produces it, and it survives the round-trip check
  # that catches every other bad name.
  my $names = File::Spec->catfile($stage, 'names.yaml');
  open my $in, '<', File::Spec->catfile($ROOT, 'maint', 'spec-to-type-names.yaml')
    or die $!;
  open my $out, '>', $names or die $!;
  while (my $line = <$in>) { print $out $line unless $line =~ /\AEndpointID:/ }
  close $out;
  close $in;
  my $report = qx{$^X \Q$SCRIPT\E --verify \Q$stage\E/names --names \Q$names\E --only '^API::Docker::Type::EndpointSettings\$' 2>&1};

t/streaming_callback.t  view on Meta::CPAN

  my $client = transport(chunked([], closed => 1));

  my $called = 0;
  my $summary = $client->get('/events',
    croak_on_error => 0,
    on_event       => sub { $called++ },
  );

  is $called, 0, 'nothing to deliver';
  is_deeply $summary, { delivered => 0, stopped => 0 },
    'and the caller is told that, rather than getting undef and guessing';
};

# ---------------------------------------------------------------------------
subtest 'the mock harness speaks the same contract' => sub {
  plan skip_all => 'live mode ignores the route table' if is_live();

  my $docker = test_docker(
    'GET /events' => [
      { status => 'create' },
      { status => 'start'  },

t/tls.t  view on Meta::CPAN

  delete local $ENV{DOCKER_TLS_VERIFY};
  is client()->tls, 0, 'the default is still 0: a tcp:// host is plaintext '
    . 'unless TLS is asked for';
};

# ===========================================================================
# DOCKER_TLS_VERIFY (karr k42)
# ===========================================================================

subtest 'the default is DOCKER_TLS_VERIFY, on the docker CLI rule' => sub {
  # Measured against docker/cli rather than guessed. cli/flags/options.go:
  #
  #     dockerTLSVerify = os.Getenv(client.EnvTLSVerify) != ""
  #
  # so the test is "non-empty", not "true", and non-empty means TLS on *and*
  # verification on (InsecureSkipVerify = !o.TLSVerify). The one place Perl
  # truthiness and that rule disagree is the string '0' -- which is exactly
  # what a user types for "off".
  {
    local $ENV{DOCKER_TLS_VERIFY} = '1';
    is client()->tls, 1, "'1' turns TLS on";

t/truncated_response.t  view on Meta::CPAN

  is $@, '', 'nothing raised' or diag "raised: $@";
  is $got, "\x01\x00\x00\x00\x00\x00\x00\x05hello",
    'the bytes up to the close are the body';
  close $c->peer;
};

subtest 'a close-delimited body cut mid-frame is still not truncation' => sub {
  # Half a frame is a real problem, and it is not this one: nothing announced
  # eight bytes of header plus five of payload, so the transport has no
  # statement to compare it against. stream_frames says so at the framing
  # level; the transport must not guess at it.
  my $c = client_for("HTTP/1.1 200 OK\r\n\r\n\x01\x00\x00\x00\x00\x00\x00\x05he");
  my $got = eval { $c->get('/containers/abc/attach', raw => 1) };
  is $@, '', 'the transport raises nothing' or diag "raised: $@";
  is length($got), 10, 'and hands over the bytes it got';
  close $c->peer;
};

# ---------------------------------------------------------------------------
# Streaming: the caller already has the units, and still has to be told
# ---------------------------------------------------------------------------

t/type.t  view on Meta::CPAN

  ok(!exists $unset->TO_JSON->{ReadOnly},
    'a Bool that was never set is absent, not false');
  my $explicit = API::Docker::Type::Mount->new(target => '/x', read_only => 0);
  ok(exists $explicit->TO_JSON->{ReadOnly},
    'a Bool explicitly set to false is present and false');
  is($json->encode($explicit->TO_JSON), '{"ReadOnly":false,"Target":"/x"}',
    'and encodes as JSON false, never as 1 or the empty string');
  like(
    do { local $@; eval { API::Docker::Type::Mount->new(read_only => [1]) }; $@ },
    qr/Bool wants a scalar/,
    'something that cannot mean true or false croaks instead of being guessed at');
};

# ---------------------------------------------------------------------------
# Nesting, arrays of objects, and the allOf inheritance
# ---------------------------------------------------------------------------

subtest 'nested objects and arrays' => sub {
  my $hc = API::Docker::Type::HostConfig->from_data({
    RestartPolicy       => { Name => 'on-failure', MaximumRetryCount => 3 },
    LogConfig           => { Type => 'json-file', Config => { 'max-size' => '10m' } },

t/type.t  view on Meta::CPAN

    'a value that does not fit croaks out of new');
  like(
    do { local $@; eval {
      API::Docker::Type::HostConfig->new(
        restart_policy => { Name => 'always', MaximumRetryCount => 'many' }) }; $@ },
    qr/did not pass type constraint|MaximumRetryCount|maximum_retry_count/,
    'and so does one inside a nested hashref the caller wrote');
  like(
    do { local $@; eval { API::Docker::Type::Mount->new(read_only => [1]) }; $@ },
    qr/Bool wants a scalar/,
    'a Bool that can mean neither still croaks rather than being guessed at');

  my $ok = API::Docker::Type::ContainerInspectResponse->new(Id => 'x');
  is_deeply($ok->rejected_fields, {},
    'an object a caller built never has anything in rejected_fields');

  # The leniency hangs on the entry point, not on the flag that tells a
  # nested coercion which entry point it is under. Were it the flag, a `new`
  # reached from inside a response inflation would go soft with it.
  like(
    do { local $@;



( run in 3.453 seconds using v1.01-cache-2.11-cpan-54e63673c56 )