API-Docker

 view release on metacpan or  search on metacpan

.claude/agents/api-docker-doc-writer.md  view on Meta::CPAN

  skills:
    - api-docker-core
    - getty-perl-release-author-getty
    - getty-perl-core
---

You are the api-docker-doc-writer for **API::Docker**.

Document the surface as it exists. If the code and the documentation disagree, the code
wins and the disagreement is a finding you report — you do not change behavior to match
prose. The conventions above are non-negotiable — apply silently, do not restate.

## What this distribution's POD looks like

POD is interleaved with the code, each `=attr`/`=method` block directly after the
`has`/`sub` it documents, and every class ends with `=seealso`. Option lists are `=over`
blocks with one `=item * C<name> - meaning` per accepted key — mirror the method's own
`%params`/`%opts` handling, including the defaults it applies (`rm` defaults to true in
`build`, `tag` to `latest` in `pull`).

The two places where accuracy matters most, because a reader cannot discover the truth
from the signature:

- **What a method returns.** `list`/`inspect` hand back entity objects; everything else
  hands back the raw daemon response, and the streaming endpoints hand back an arrayref
  of newline-delimited JSON events — except when the stream held exactly one object.
- **What the client deliberately does not do.** The `CONTAINER ENGINES` section in
  `API::Docker` documents that socket discovery reads `DOCKER_HOST` and the default
  socket and consults no Docker contexts, and contrasts that with other clients. That
  section is a promise about behavior; keep it true or flag it.

Say what is true, do not describe intent as capability — and do not trust a claim of
incapability written down here either. This paragraph used to assert that `tls` and
`cert_path` were unimplemented. They are implemented: `API::Docker::Role::HTTP` carries
a full `IO::Socket::SSL` path, including `tls_insecure` and the `docker` CLI's cert

.claude/agents/api-docker-engine-worker.md  view on Meta::CPAN

---
name: api-docker-engine-worker
description: "Docker Engine API specialist for API::Docker — use whenever the question is what the daemon does or expects: adding or correcting an endpoint, query-parameter and filter semantics, response shapes (204/304, NDJSON event streams, error...
model: inherit
allowed-tools: Read, Edit, Write, Bash, Glob, Grep
briefing:
  skills:
    - docker-engine-api
    - api-docker-core
    - getty-perl-core
    - getty-perl-moo
    - getty-perl-release-author-getty
    - getty-git-commit-style
    - kanban-issues-karr-cli
---

You are the api-docker-engine-worker for **API::Docker**.

Your lane is the boundary between this distribution and the Docker Engine: what the
daemon accepts, what it answers, and whether this client models that faithfully.
Everything that is a Perl or packaging question — Moo structure, the socket and chunked
reader, refactoring, `cpanfile`, dist plumbing — belongs to `api-docker-worker`; hand it
over rather than drifting into it. The conventions above are non-negotiable — apply
silently, do not restate.

Coordinate via `karr`: pick tickets from the local board, record drift you find as new
tickets rather than expanding scope mid-change.

## Working method

Measure, don't assume — and first find out what there is to measure against. Which
engines this machine runs is not written down anywhere: check which sockets exist
(`/var/run/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`) and what each answers on
`GET /version` (`Platform.Name`, `ApiVersion`, `MinAPIVersion`) before the first probe.

.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

Division of labor: the dispatching agent owns test **intent** — which behaviors matter
and whether coverage is sufficient. You own the **mechanics** — translating that intent
into correct, intent-faithful setups and assertions. Don't invent coverage decisions; if
the intent is unclear or the briefed behavior seems wrong, stop and ask.

Hard rules:

- **The default run reaches no daemon and no network.** `prove -lr t/` with no
  environment set must pass on a machine with no Docker installed. Anything live sits
  behind `is_live()`, mutations behind `can_write()` / `skip_unless_write()`, with
  `register_cleanup` for every resource created.
- **Never point a live test at a daemon the suite does not own.** Write tests create and
  destroy real containers, images, networks and volumes.

## Mechanics that decide whether a test is real

- **Pick the right level.** `test_docker` replaces `_request` wholesale, so anything
  below it — request line assembly, header sanitising, chunked reading, status handling,
  the NDJSON fallback — is invisible to a route-table test. Transport behavior is tested
  either by calling the private function directly (`t/images_push_auth.t` calls
  `_build_registry_auth_header`) or by capturing `local *API::Docker::_request`. State
  which level you are on before writing the file.
- **`API::Docker::Role::HTTP` currently has no coverage beyond `use_ok`.**
  `_read_chunked`, `_read_response` and the >=400 croak path are untested; a fake socket
  (an in-memory filehandle over a canned HTTP/1.1 response) is the way in. Treat that as
  a standing gap worth a ticket, not as something to fix inside an unrelated task.
- **Route keys are matched as exact strings first, then as regexes** (`m{^$route_path$}`
  in the fallback). A key containing `.`, `?` or `+` matches more than it looks like it
  does — anchor intent by making the exact key match, or escape deliberately.
- **Assert the request, not only the response.** A route handler receives
  `($method, $clean_path, %opts)`: assert on `params`, `body` and `headers` there when
  the point of the test is what the client sends. A test that only checks the mocked
  return value proves the fixture, not the code.
- **Decode exactly what the engine would receive.** The push-auth helper used to append
  the missing base64 padding before decoding and so passed with and without the defect
  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/agents/api-docker-type-writer.md  view on Meta::CPAN

    - getty-perl-moo
    - getty-perl-release-author-getty
    - getty-git-commit-style
    - kanban-issues-karr-cli
---

You are the api-docker-type-writer for **API::Docker**.

Your lane is the type model: the swagger in `spec/`, the `API::Docker::Type` DSL and its
registry, the generated classes, and the drift checker that keeps them honest. The
transport, the resource classes and the tests belong to `api-docker-worker`,
`api-docker-engine-worker` and `api-docker-test-writer` — hand those over rather than
drifting into them. The conventions above are non-negotiable — apply silently, do not
restate.

## What makes this lane different

You write from a **specification**, not from a running daemon. That is a discipline, not
a shortcut:

- The spec is the source for a field's name, type and description. Take the description
  and make it read well; do not invent one the spec does not have.
- Where a measurement contradicts the spec — and this distribution has several, recorded
  in POD and `Changes` — the POD says both, and names the engine and version the
  measurement came from. The spec does not win by default and neither does the
  measurement.
- You are allowed to probe a daemon to settle a question, but a class is never justified
  by "the daemon answered this once". 132 definitions cannot be verified that way, which
  is exactly why the drift checker exists.

## The two failures that matter

**Translating a key that is the caller's data.** `Labels`, `ExposedPorts`,
`PortBindings`, `Volumes`, `Sysctls` and their kin are keyed by what the user wrote. A
label named `com.example.Some-Label` must arrive at the daemon spelled exactly that way.
Check the swagger for `additionalProperties` before deciding a hash's keys are structure.

**Dropping a field the model does not know.** A caller whose engine is newer than the
spec we generated from must still reach the daemon. Translate what you know, forward the
rest verbatim. This distribution's ability to work with an engine released after it is
worth more than a tidy model.

## Done means checkable

A class is finished when `maint/spec-drift-check.pl` reports it with no missing and no
extra fields, every attribute carries an `=attr` block, and `prove -lr t/` is green.
Report the drift checker's output, not your impression of the class.

When you are one of several agents writing classes in parallel, stay inside the files you
were given. The registry is shared state at runtime but one file per class on disk;
collisions come from editing the DSL, the drift checker or the prefix map, so say so
rather than doing it.

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

    - getty-perl-moo
    - getty-perl-release-author-getty
    - perl-release-dist-ini
    - getty-git-commit-style
    - kanban-issues-karr-cli
---

You are the api-docker-worker for **API::Docker**.

Implement, refactor, debug, and test the Perl side of this distribution. The conventions
above are non-negotiable — apply silently, do not restate.

**Where your lane ends.** You own how this distribution is built: `Role::HTTP`'s socket
handling and chunked reader, Moo composition, the entity roles composed onto the
generated types, `cpanfile`, dist
plumbing. You do not own what the Docker Engine accepts or answers. If the task turns on
daemon semantics — a wire format, a query-parameter meaning, a response shape, registry
auth, API version gating — stop and hand it to `api-docker-engine-worker`, which is
briefed with the Engine API reference and you are not. Guessing at daemon behavior from
the existing Perl is exactly how a wrong assumption gets cemented; the current
`containers->logs` is the proof.

Coordinate via `karr`: pick tickets from the local board, record drift you find as new
tickets rather than expanding scope mid-change.

## Repo-specific notes — beyond the briefed skills

**This distribution has a consumer in the same workspace.**
`../p5-dist-zilla-plugin-docker-api` calls into `$docker->images` and the client
constructor. Changing a public method signature, a return type, or what `_request` hands
back is a cross-repo change: check that repo builds and tests green, or file a ticket on
its board before landing the change here.

**`our $VERSION` is repeated in all 12 `.pm` files and must stay identical.** That is the
house shape here (`[@Git::VersionManager]` allows `^lib/.*\.pm$` to be dirty in the
version-bump commit) — a new module gets the same literal as the rest. The value is the
*next* release; `dzil release` bumps it, never you by hand.

**Behavior changes get a `Changes` entry under `{{$NEXT}}`, and the entry says what was
measured.** The existing entries name the exact engine error string and what a local
registry did before and after. Match that standard: a claim about the daemon's behavior
is worth writing down only if you observed it.

POD lives next to the code (`=attr`, `=method`, `=head1`), woven by `@Author::GETTY`.
Touch a public signature, touch its POD in the same change.

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


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

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

- **You can spawn subagents** (orchestrating main agent): Do NOT touch behavior-relevant
  code yourself — delegate. Your lane: coordinate, inspect, plan, review diffs, run
  tests, manage git, edit non-behavioral docs. When in doubt, delegate. Why: only the
  `api-docker-*` agents get their skills force-loaded via `briefing.skills`; you get no
  briefing and would touch internals with too little context.

  | Task | Agent |
  |---|---|
  | 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

Subagents share one working tree with the orchestrator and with each other. A global git
command in one reaches all of them, so:

- **A subagent never mutates git** — no `stash`, `reset`, `checkout -- <path>`, `clean`,
  `add` or `commit`. The orchestrator owns git and commits. Say so in every subagent
  prompt, but do not rely on the prompt alone: a subagent's `git stash`/`reset`/`checkout`
  has thrown away another agent's uncommitted work three times (k111) even when the prompt
  forbade it.
- **When two or more code-touching agents run at once, isolate them.** Launch each with
  `isolation: "worktree"` so a stray git command in one cannot reach another's tree, or run
  them sequentially in the shared tree. Never fan out parallel code-touching agents into the
  same working tree without isolation.
- **A worktree may branch from a stale base.** Integrate its result by the diff
  (`git diff <merge-base> <branch> -- <files>` piped to `git apply`, or a cherry-pick),
  never by `git checkout <branch> -- <file>` for a file the main tree has since changed —
  that reverts the main tree to the stale copy. Check the merge-base against what main
  touched first.
- **Commit a verified-green checkpoint before the next mutating fan-out.** A committed HEAD
  is immune to a later stray `stash`/`reset`; uncommitted work is not.

## Coordination — karr board (always in scope)

Ticket coordination is the orchestrating agent's job, so `karr` is always in scope —
don't invoke the `kanban-issues-karr-cli` skill first, just use it. Git-native kanban;
state lives in `refs/karr/*`; one board, this repo. Day-to-day: `karr list --compact` /
`karr board` for open work; `karr show ID` for detail; `karr create/edit/move/handoff`
for the usual flow; mutating commands auto-sync. Use it for drift to reconcile and
follow-up work that must not block the current change. Full surface: skill
`kanban-issues-karr-cli`.

**Serialize board mutations when fanning out.** Keep implementation work parallel if you
like, but collect results and loop `karr move`/`handoff`/`sync` sequentially — N landing
at once is a resource event, not a cheap command.

## Release — never without permission

`dzil build`, `dzil test` and `prove -lr t/` are fine anytime. `dzil release` and any
CPAN upload are STRICTLY forbidden without the maintainer's explicit go-ahead — even if
`Changes` or a plan names "release" as the next step. `[@Author::GETTY]` bumps `$VERSION`
across all of `lib/` and tags on release; for anything heading toward release: stop and
ask.

## Public issues — never act without instruction

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

  removes actual containers, images, networks and volumes; cleanup runs in an `END`
  block, so an interrupted run leaves them behind. Run only when the task is about live
  behavior.
- **`prune` destroys, and `dangling => 0` destroys MORE, not less.** `POST
  /images/prune` with `filters => { dangling => ['false'] }` removes every
  unused *tagged* image on the engine, locally built ones included, and they
  are not recoverable. It reads like a narrowing filter and is the opposite.
  This has already cost a locally built image, during what its caller
  believed was a read-only probe. **No `prune` of any
  kind -- images, containers, networks, volumes, build cache -- and no
  `rm -a` or `system reset`, on either engine, ever, unless the user names
  the command.** Probing what an endpoint answers is not a reason: measure
  it against something you created yourself.
- **`images->push` publishes.** With credentials it writes to a real registry under the
  maintainer's account. Never run it — nor any test that does — without explicit
  instruction.
- **Streaming endpoints block until the daemon closes, unless given a callback.**
  `_request` still buffers a whole response by default, so `system->events` or
  `containers->stats` without a bound and without `on_event`/`on_frame`/`on_chunk` never
  returns. Bound the window, pass a callback, or wrap a manual probe in `timeout` — a
  callback still needs `$stop->()` called from somewhere, or it runs until the daemon
  closes the connection on its own.
- **`../p5-dist-zilla-plugin-docker-api` consumes this API.** A public signature or
  return-shape change is a cross-repo change: verify that repo, or file a ticket on its
  board before landing.
- **`[@Author::GETTY]` gathers through `Git::GatherDir`, which sees only tracked
  files.** A new `.pm`, test file or fixture is invisible to `dzil build`/`dzil test`
  until it is `git add`-ed — while `prove -lr t/` stays green the whole time, because it
  reads `lib/` and `t/` directly rather than through the gathered file list. A `dzil
  build` failure that looks like a missing module, or a passing `prove` next to a
  failing `dzil test`, is this before anything else: check `git status` for an untracked
  file first.

## Perl specifics — reference, don't restate

Module loading, `$VERSION`, cpanfile pinning and house style: skills `getty-perl-core`,
`getty-perl-moo`. `[@Author::GETTY]`, POD weaving, `{{$NEXT}}`: skill
`getty-perl-release-author-getty`. dist.ini mechanics: `perl-release-dist-ini`. Commit
messages: `getty-git-commit-style`. Architecture and transport invariants:
`api-docker-core`. What the daemon itself does — wire formats, response shapes, filters,
registry auth: `docker-engine-api` (briefed only into the engine-worker and the
test-writer). Don't duplicate any of it here.

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

---
name: api-docker-core
description: "Use when working on the API::Docker distribution's client architecture — the HTTP transport role and its socket handling (unix://, tcp://, TLS), a resource API under API::Docker::API::*, how list/inspect wrap a daemon response into a ...
---

# API::Docker — architecture and invariants

A pure-Perl client for the Docker Engine HTTP API. It speaks HTTP/1.1 directly
over the daemon's 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 three layers

```
API::Docker                     client; host, api_version, negotiation
  └─ with API::Docker::Role::HTTP    _request + get/post/put/delete_request
  └─ ->images / ->containers / ->networks / ->volumes / ->system / ->exec
        API::Docker::API::*      one class per resource, holds `client`
          └─ _wrap / _wrap_list  → $class->from_data($data, client => ...)
                                    on an API::Docker::Type::* class, with
                                    API::Docker::Role::Entity::* composed
                                    onto that class at load time
```

**`_request($method, $path, %opts)` is the only way out of the process.** Every
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`,
  `ImageSummary`/`ImageInspect` (see `API::Docker::API::Containers/"The two
  container shapes"`). Which resources have one class vs. two is read off
  `spec/v1.51.yaml`, not assumed — a future swagger could add a second
  definition to a resource that only has one today.
- **The seven hand-written entity classes are gone (k84)** —
  `API::Docker::{Container,Image,Network,Volume,Plugin,Secret,Config}`. The
  files still ship, but only as stubs that croak on load and on every method
  call (k92), so installing a new release overwrites the working copy an
  older one left on disk instead of leaving it to shadow the release. Never
  write code against them or treat their POD as current; the objects the
  daemon answers with are the `API::Docker::Type::*` classes above.
- **The composed `client` on an entity is a `weak_ref`**, declared by
  `API::Docker::Role::Entity` and composed into every wrapped
  `API::Docker::Type::*` alongside the resource-specific
  `API::Docker::Role::Entity::*` role. `API::Docker->new->images->list`
  leaves every returned entity with `client => undef`, and the next
  `$image->remove` dies on an undefined invocant. The client must stay in a
  live variable — in library code, in examples, and in tests.
- **Query-string booleans are normalised to `1`/`0`** (`$opts{all} ? 1 : 0`);
  **JSON-body booleans are `\1`/`\0`** (see `Exec::start`), because the engine
  type-checks the body but not the query string.
- **A `params` value that is a hashref is JSON-encoded automatically**, but
  `filters` specifically goes through `API::Docker::Role::Filters`, which
  normalises it into the map-of-string-to-array-of-string shape the engine
  wants (wraps a bare scalar in an array, stringifies numbers, turns a JSON
  or `\1`/`\0` boolean into `'true'`/`'false'`) and croaks on anything else —
  another ref, `undef`, an empty string. Pass
  `filters => { dangling => ['true'] }` and let the role do the rest;
  encoding it by hand double-encodes it.
- **Extra headers go through `headers =>`**, which strips CR/LF. Never
  concatenate a header into the request string.
- `_uri_encode` deliberately leaves `/` and `:` raw so image names survive in
  the path (`/images/library/nginx:1.25/push`).
- `sub push` and `sub kill` shadow Perl builtins inside their packages — that
  is why `namespace::clean` is loaded; always call them as methods.

## Entities: generated types with composed methods, not hand-written classes

`_wrap`/`_wrap_list` build the object with `$class->from_data($data, client
=> $self->client)` — never `new`. A daemon response and a caller-built
object are different name spaces: `from_data` reads only the swagger's own
wire names, so a key it does not recognise keeps its own spelling in
`unknown_fields` instead of being misread as the Perl name of an unrelated
field it happens to collide with, and a value that disagrees with the
declared type costs that one field (recorded in `rejected_fields`) rather
than failing the whole response. `new` stays strict and croaks on both
cases — it is what a caller's own arguments go through. Detail:
`API::Docker::Role::Type`.

The convenience methods (`$container->start`, `$image->remove`, `logs`,
`is_running`, ...) are not on the generated classes. They live in
`API::Docker::Role::Entity::*` roles and are composed onto the generated
`API::Docker::Type::*` classes at load time
(`Moo::Role->apply_roles_to_package`), because the generated files must
match `maint/spec-to-type.pl`'s output byte for byte (`t/spec_to_type.t`
enforces it) — nothing hand-written can live in them. See

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

a wrapper class holding one.

Fields carry the swagger's own names in snake_case — `$container->state`,
not `$container->State` — and the model does normalise: a field declared
`Bool` comes back `1`/`0` regardless of what the daemon actually sent
(measured on `Plugin.enabled`), and `TO_JSON` writes it back out as a JSON
boolean rather than the Perl truth value.

## X-Registry-Auth — padded base64url, always sent

The engine requires the header on **every** push, anonymous included, and
decodes it with Go's `base64.URLEncoding`, which **requires the padding**.
Stripping the `=` made every push fail with
`failed to parse "X-Registry-Auth" header ... unexpected EOF` — including the
anonymous case, whose payload `{}` encodes to `e30=`, three characters and one
pad. `_registry_auth_header` produces padded base64url (`tr{+/}{-_}`, no `=`
removal); a bare base64-looking string passed as `auth` is forwarded
untouched.

## Transport behavior that's easy to get wrong

- **Buffers the whole response by default; streaming needs a callback.**
  Every request sends `Connection: close`. With none of `on_event`,
  `on_frame`, `on_chunk` given, `_request` reads the whole response before
  parsing, so `/build`, `/images/create`, `/push`, `/events`,
  `/containers/*/stats` and `logs(follow)` block until the daemon closes the
  connection — an unbounded `events` or `stats` call without one of those
  callbacks never returns. Pass one of the three to consume the response as
  it arrives instead. Detail: `API::Docker::Role::HTTP`'s "Streaming a
  response as it arrives".
- **The buffered streaming return type is not stable.** `_request` first
  tries `decode_json` on the whole body and only falls back to line-by-line
  NDJSON parsing (returning an arrayref of events). A stream that carries
  exactly one JSON object comes back as that hashref, not as a one-element
  array. Callers check `ref` before iterating.
- **A failed build/pull/push is still HTTP 200.** `_request` croaks on status
  >= 400 only; `errorDetail` inside the event stream is the caller's job.
- **TLS is implemented, not stubbed.** `tls => 1` on a `tcp://` connection
  (`unix://` never encrypts, and refuses the combination outright) swaps in
  `IO::Socket::SSL` in place of the plain socket — same reader, same writer,

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

  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()` /
`skip_unless_write()`, with `register_cleanup` for anything they create.

Fixtures in `t/fixtures/*.json` are captured from a real daemon, so drift stays
detectable — do not hand-roll them. That was not always true until karr k101
(and its follow-up): all eight are now real captures -- see the header of
`t/type_fixture_passthrough.t` for which engine and API version backs each
one, including `containers_list.json`, `container_inspect.json` and
`volumes_list.json`, captured from a disposable container and volume created
and removed for the purpose once an earlier pass found neither engine
reachable from the recapturing machine holding one to read.

**A test helper that repairs its input cannot see the defect.** The push-auth
test used to compute and append the missing base64 padding before decoding, so
it passed both with and against the bug it existed to catch. Decode exactly
what the engine would receive.

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

## The two rules that override everything else

**Never hand-edit a file under `lib/API/Docker/Type/`.** Not one character,
not a typo, not a comma. `t/spec_to_type.t` runs the generator and asserts
that every class is byte-identical to what it emits — a hand edit turns the
suite red, and the edit is lost the moment anyone regenerates. Change the
generator or its data files instead; the next section says which.

**The generator only ever creates.** It writes a file that does not exist and
refuses to overwrite one that does. There is no `--force` and no bulk
refresh, and the refusal is enforced against relative paths, absolute paths,
`maint/../lib`, symlinks and subdirectories. When a newer spec lands, the
drift checker reports the difference and a human decides field by field. A
generated class is one that someone has since read, corrected and
documented; a re-run would throw that away silently.

## Where a change belongs

| What you want to change | Where it goes |
|---|---|
| The prose of an `=attr`, a `# ABSTRACT`, a DESCRIPTION | `maint/spec-to-type-prose.yaml` |

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


=cut

docker port_bindings => { Str, ['PortBinding'] }, since => '1.41';
```

Every attribute carries a snake_case name, a type, its CamelCase wire name
(derived unless `wire => ...` says otherwise) and an `=attr` block taken from
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
single most damaging mistake the model can make.

Find these by grepping the spec for `additionalProperties`, never by looking
for `type: object` — at least one field declares the keyword with no type
above it, and reading the spec by type alone quietly degrades that field to
untyped passthrough.

**An unknown field passes through unchanged.** A caller whose engine is newer
than the spec we generated from must still reach the daemon; the model
translates what it knows and forwards the rest verbatim. This is not
theoretical: a real Podman `/info` answers with fields the swagger does not
have, and `ImageSummary` still serves `VirtualSize`, which Docker dropped from
the spec after v1.41.

**A null is where that stops, and only for a field we know.** A known field an
engine sends as `null` is read as unset: the attribute stays `undef` and
`TO_JSON` writes no key for it. That is not a leak, it is the daemon's own
resolution — measured 2026-08-28 against Podman 5.8.4 (API 1.44), where
`POST /containers/create` answers `{}`, `{"Image":null}` and `{"Image":""}`
with byte-identical errors, because Go's `encoding/json` unmarshals a null
into the type's zero value and an absent field leaves that same zero value.
It holds outbound too, which is why `/images/{id}/history` answers
`"Tags": null` instead of omitting the field. An *unknown* field keeps its
null, because with no declared type there is no zero value to read it as, and
so does a null under a key the caller chose. Three shapes, three outcomes, on
purpose; the reasoning lives in `API::Docker::Role::Type`'s POD and
`t/type_fixture_passthrough.t` holds all three against the fixtures.

**`since` is documentation, never a check.** It records which API version
introduced a field, derived by diffing the specs in `spec/` against each
other — the swagger itself carries no per-field version. Nothing is
validated, warned about or dropped at runtime. Podman serves fields its
announced version does not promise and refuses ones it does; we are not the
authority on what an engine can do.

## Where the values come from

`spec/` holds the swagger verbatim as Docker publishes it, so a `curl | diff`
still checks out. Generate against the newest version present and keep the
older ones for the diff that produces `since`.

    https://docs.docker.com/reference/api/engine/version/v1.51.yaml

Parse it with `YAML::XS`, not `YAML::PP`: Docker's `example:` blocks are
multi-line flow maps whose closing brace is under-indented, and YAML::PP
refuses the file. Both maint scripts read the spec through
`maint/spec-common.pl` so field order comes from one place.

A field's `description` is its `=attr` text — rewrapped, its grammar

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

  identical, the diff empty.
- `perl maint/spec-drift-check.pl --baseline …` — zero in all seven tiers.
- `prove -lr t/` green, and `dzil test` green so the POD is known to weave.

A class nobody can check against the spec is not done, however good it looks.

## Related

- `references/dsl.md` — the `docker` keyword, the registry, serialisation
- `references/types.md` — the type vocabulary and the data-key list in full
- `api-docker-core` — the transport and the resource classes these feed
- `getty-perl-moo` — Moo conventions this distribution follows

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

The design comes from `IO::K8s::Resource` in ../io-k8s-p5, which solves the
same problem for Kubernetes. The built implementation is
`lib/API/Docker/Type.pm` — read that first; it has diverged where Docker
needed it to, most visibly in storing a recursive type descriptor rather than
IO::K8s's flat `is_array_of_objects` flags.

## What `docker` does

    docker $perl_name => $type;
    docker $perl_name => $type, since => '1.44';
    docker $perl_name => $type, wire => 'CPUShares';
    docker $perl_name => $type, required => 1;

It declares a Moo attribute AND writes an entry into a package-level registry:

    $REGISTRY{$class}{$perl_name} = {
        type     => $type,       # the declared type
        wire     => 'PortBindings',
        since    => '1.41',      # or undef
        required => 0,
    };

Both halves matter. The attribute is what a caller uses; the registry is what
serialisation and `maint/spec-drift-check.pl` read. A field that is an
attribute but not in the registry is invisible to the drift checker, which is
the failure mode that makes the whole model untrustworthy.

## Deriving the wire name

The registry stores the spec's spelling. The Perl name is derived from it at
generation time, not at runtime:

    PortBindings   -> port_bindings
    CPUShares      -> cpu_shares
    OOMKillDisable -> oom_kill_disable
    ID             -> id
    NanoCpus       -> nano_cpus

Runs of capitals are one word. When the derivation produces something that
collides with another field in the same class, or reads wrong, pass `wire`
explicitly and choose the Perl name by hand — do not bend the derivation to
fit one case.

## Serialisation, both directions

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


Every path is prefixed `/v1.NN` (`/v1.47/containers/json`). Unversioned paths
work and mean "whatever the daemon defaults to" — fine for `/version` and
`/_ping`, wrong for anything a client should pin.

`GET /version` answers `ApiVersion` (newest supported) and `MinAPIVersion`
(oldest). Negotiate by requesting `/version` unprefixed, then using
`ApiVersion` for everything else. Asking for a version above `ApiVersion` fails
with 400 `client version 1.99 is too new`; below `MinAPIVersion` fails the same
way. A feature added in a later version is simply absent — the daemon returns
404 or silently ignores the query parameter, so a client that assumes a
parameter took effect can be wrong without any error.

## Response shapes

- **204 No Content** is the success case for `start`, `stop`, `kill`, `pause`,
  `remove` and friends. There is no body to decode.
- **304 Not Modified** means the container was already in the requested state —
  starting a running container, stopping a stopped one. It is *not* an error,
  and a client that only special-cases `>= 400` will hand back an empty result
  here. Decide explicitly whether that is success.
- **Errors** carry `{"message": "..."}` as JSON with a 4xx/5xx status. The
  message is human text; do not parse it for control flow.
- **`/build`, `/images/create` (pull) and `/images/{name}/push` stream
  newline-delimited JSON** — one object per line: `{"stream":…}`,
  `{"status":…,"progress":…}`, `{"aux":{"ID":…}}`, `{"errorDetail":{…}}`.

**A failed build, pull or push is still HTTP 200.** The failure arrives as an
`errorDetail` object inside the stream, after the daemon has already committed
to a successful status line. Any client that treats HTTP status as the verdict
reports a broken build as a success. Scan the events.

## The multiplexed stream — the one that looks like it works

`GET /containers/{id}/logs`, `/containers/{id}/attach` and
`POST /exec/{id}/start` return **frames, not text**, whenever the container was

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

## Registry auth

`X-Registry-Auth` carries **base64url of a JSON object**, and the padding is
required — the daemon decodes with Go's `base64.URLEncoding`, not
`RawURLEncoding`. Stripping `=` produces
`failed to parse "X-Registry-Auth" header ... unexpected EOF`.

The header is mandatory on **every** push, anonymous included; the anonymous
form is the encoding of `{}`, which is `e30=` — three characters and one pad,
the shortest case and the one that proves padding matters. Payload keys:
`username`, `password`, `serveraddress`, or `identitytoken`.

`/build` uses a different header for the same job: `X-Registry-Config`,
base64url of a map from registry hostname to auth object, because a build may
pull from several registries.

## Bodies and paths

`POST /build` is the odd one: the request body is the **tar build context**
(`Content-Type: application/x-tar`), and every option — `t`, `dockerfile`,
`buildargs`, `target`, `platform` — rides in the query string. `buildargs` and

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

`unix://$XDG_RUNTIME_DIR/podman/podman.sock`, and it announces API 1.41.
Multi-stage builds including `target` pass through unchanged, and the frame
format above is byte-identical. It is a reimplementation, not Docker: treat
anything beyond the documented surface — event payload details, healthcheck
fields, error message text — as unverified until measured against the engine
you actually target.

Clients differ in how they *find* the daemon: `DOCKER_HOST` is the one
mechanism all of them honour. Docker contexts
(`~/.docker/config.json` `currentContext` plus
`~/.docker/contexts/meta/*/meta.json`) are resolved by the `docker` CLI and
docker-java but not by most library clients, so "works in the terminal, fails
in my program" usually means a context the program never read.

## Probing by hand

```bash
curl --unix-socket /var/run/docker.sock http://localhost/v1.47/containers/json
curl --unix-socket /var/run/docker.sock -X POST \
  'http://localhost/v1.47/images/create?fromImage=alpine&tag=3'
```

.claude/skills/getty-git-commit-style/SKILL.md  view on Meta::CPAN

- **Summary line**: imperative mood ("Add", "Fix", "Rename", "Remove"), describe the primary intent
- **Body**: list every discrete change on its own line, no bullets needed, no prose explanations
- **Completeness**: every file-level change must be mentioned — don't silently lump things together
- **Brevity**: state what changed, not why it's important or how it works — the diff shows that
- **No filler**: no "This commit...", no "In this change...", no "Also..."
- **No @ symbols**: never use `@` in commit messages (e.g. write `[DBIO]` not `[@DBIO]`) — platforms like GitHub interpret `@word` as user/org mentions
- **Language**: English
- **Co-Author**: append the Co-Authored-By line whenever Claude wrote or co-wrote the changes.
  Name the model that actually did the work — read it from the session rather than
  copying a version out of an older commit or out of this file. If the harness
  prescribes its own trailers (a session link, a different spelling), use those verbatim.

## Examples

Good:
```
Rename _dbic_connect_attributes to _dbio_connect_attributes

Storage/DBI.pm: accessor declaration and two call sites
Schema/Versioned.pm: one call site
```

.claude/skills/getty-git-commit-style/SKILL.md  view on Meta::CPAN

walkthrough of the mechanism, no defence of the alternative that lost. Name what is
true now and what a user does differently because of it. The reasoning has homes that
keep it — the commit body, the ticket, an ADR; a changelog is read by someone who
never saw the old behaviour.

**Reference only the tracker the repo publishes.** In a changelog a `#123` earns its
place when the reader can open it — GitHub or Gitea issues on a repo that has them. An
internal board ticket is unreadable outside the workspace, so it does not appear there
at all: name the change instead of the number. A commit message may carry one, written
in the board's own notation (karr ids are `k254`) — never as `#254`, which every
hosting platform resolves against *its* issue 254: a dead link today, someone else's
bug once the repo has that many.

Aim for one to three lines per entry, and let the count of entries fall out of the
work rather than the detail per entry. For scale: a mature Getty distribution carries
320 releases in a 348-line `Changes` — a whole history shorter than a single unreleased
section that was allowed to accrete.

## Multi-repo commits

When committing across multiple repos in a workspace, each repo gets its own
commit with its own message. Don't reference other repos in the message.

## HEREDOC usage

Always pass commit messages via HEREDOC to preserve formatting:

```bash
git commit -m "$(cat <<'EOF'
Summary line

Body lines here.

Co-Authored-By: Claude <Model> <noreply@anthropic.com>
EOF
)"

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


## Singletons

- **`->instance`** for `MooseX::Singleton` / `MooX::Singleton` classes. Never `->new` on a singleton.
- **`->new`** for everything else.

## Subroutines

- **`my ( $self, $x ) = @_;`** as the first line — explicit destructure, spaces inside the parens. Never `my $self = shift;` as argument unpacking.
- **One-liners skip unpacking** and use `shift->` or `$_[0]->`: `sub trace { shift->_logger->trace(@_) }`. This is the one place `shift` is right.
- **A builder that ignores `$self` fits on one line:** `sub _build_readonly { 0 }`.
- **`_` prefix marks private** subs and attributes. Builders for private attributes double up: `sub _build__mp`.

### Methods, not bare subs

- **In a class, every helper is a method on `$self`** — not `sub _foo {...}` invoked as `_foo($self->config, $x)`.
- **Per-process caches go on the singleton as an attribute** (`has _cache => ( is => 'ro', default => sub { {} } )`), not a `my %CACHE` package variable.
- **No package-level state** unless it is a true constant (an `%ENGINE_CLASS` lookup table counts; a per-call cache does not).
- Bare subs are fine in **non-class utility modules** imported as functions. Once a file says `use Moose`/`use Moo`, every `sub` is a method.

Why: bare subs hide what the call needs, can't be overridden or mocked, and force every caller to thread state by hand.

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

## Strings

- **Concatenate, do not interpolate:** `'Adding '.$f.' with '.$length.' bytes'`. Interpolate only where concatenation would be unreadable.
- **Single quotes by default.** `'...'` and `"..."` are genuinely different in Perl — `"` interpolates and processes escapes, `'` does not. Reach for `"` when you need that, not by habit.
- **Import lists as `qw( croak confess )`** — spaces inside the parens. Never rely on default exports.

## Control flow

- **Postfix `if`/`unless`** for guards and short conditions: `croak(...) if $self->readonly;`
- **`unless $x`** instead of `if !$x`.
- **Guard clauses return bare:** `return unless $res->is_success;` — not `return undef;`.
- **Nested ternaries** for a return that picks between expressions, instead of an if/elsif chain.

## Data

- **`{ %hash }` and `\%hash` are different operations, not two styles.** `{ %h }` builds a new anonymous copy; `\%h` references the existing hash. Return a copy when the caller must not mutate your state; return the reference when sharing is the poin...
- **`Path::Tiny`** for every file operation — not `File::Spec`, not bare `open`. `path(...)->child(...)->slurp_utf8`.
- **`JSON::MaybeXS`** always — never `JSON::PP`, `JSON::XS`, `Cpanel::JSON::XS` directly. Encoders get `canonical => 1, convert_blessed => 1`.
- **Every serialiser is deterministic.** MessagePack `->canonical`, DBIC `serializer_options => { canonical => 1 }`. Same rule, every format.
- **Booleans: `JSON->true` / `JSON->false`.** `use JSON::MaybeXS;` covers codec and booleans.
- `$YAML::XS::Boolean = 'JSON::PP'` is one of YAML::XS's fixed mode names, not a module choice — leave it alone.
- **Align `=>` in multi-line hash literals** when keys are of similar length.
- **Optional pairs inline:** `$cond ? ( experimental => 1 ) : (),`

## Configuration

Config comes from environment variables prefixed with the project name
(`$ENV{MYPROJECT_TIME_ZONE}`), each with a default in code. Where many
attributes share that shape, write a generator that wraps `has` rather than
repeating the declaration.

## DBIC-ish result classes

- Column defs via **`DBIx::Class::Candy`** or **`DBIO::Candy`** — `primary_column` / `column` macros, not `__PACKAGE__->add_column(...)`.
- **`keep_storage_value => 1`** on enum and integer columns that shouldn't be inflated/deflated.
- **`\'NOW()'`** (literal scalar ref) for DB-side timestamp defaults.

## Style, comments, structure

- **2-space indentation.** Not 4. Not tabs. Every Getty Perl file.
- **No trailing commas** at the end of multi-line lists (unlike Python).
- **Section long files with a figlet banner** as a comment block. Pick from `standard`, `slant`, `small`, `banner`. Where figlet is unavailable or the file is short, a `#### <Name>` rule does the job.
- **Commented-out debug lines stay** (`#use DDP; p($res);`). They mark where debugging was needed before — deleting them as dead code removes a warning sign, and sometimes the precaution it guards.

## cpanfile

- **A `cpanfile` carries the requirements** — that is the file, not `dist.ini` prereq blocks.
- **`requires 'Module::Name';`** — the version argument is optional, omit it when unpinned. Never write `'0'`.
- **A version means "or higher".** `requires 'Foo', '5.0';` already accepts 5.1 — never write `'>= 5.0'`.
- **Alphabetical order**, phase blocks (`on test => sub {...}`) at the end.

### Getty-authored dependencies — CRITICAL

Getty's `dist.ini` uses `[@Author::GETTY]`, which sets `$VERSION` in the repo to the **next, unreleased** version (`0.402` while CPAN is at `0.401`). The repo is ALWAYS one ahead of CPAN.

1. **Pin what the code actually needs, not the number that happened to be in front of you.** Opening a sibling repo, reading its `$VERSION` and pinning that — while depending on nothing that version introduced — is the mistake this section exists...
2. **Check `cpanm --info Module::Name`** for the released version. If the released one carries what you use, that is the pin.
3. **Pinning the next, unreleased version is correct when the change spans both repos** — the sibling gained what this code calls, or a release is being prepared and the distributions are tested together from their working trees. It commits you to ...
4. **Pin every Getty-authored distribution.** Not stale, not omitted — current.
5. **Re-check on upgrade.**

```bash
cpanm --info Module::Name | tail -1
# → GETTY/Module-Name-1.234.tar.gz  ← pin to 1.234
```

Getty-authored (non-exhaustive): `Langertha`, `IO::K8s`, `Kubernetes::REST`, `WWW::Crawl4AI`, `Net::Async::Crawl4AI`, `Net::Async::WebSearch`, `Catalyst::Plugin::ChainedURI`, `Locale::Simple`, `DBIO::*`, `WWW::Zitadel`, `WWW::PayPal`, `WWW::Chain`.

## Changelog (the Changes file)

Every distribution ships a `Changes` file with a `{{$NEXT}}` token at the top (Dist::Zilla's `[NextRelease]` fills it at release time).

- **Add a bullet under `{{$NEXT}}` in the SAME commit as any user-facing change** — new bindings, behaviour changes, bug fixes, deprecations. If a CPAN consumer would notice, it belongs there.
- **Match the existing style:** two-space indent, `  - ` bullets, wrap near 78 columns, present-tense imperative ("New binding X", "Fix Y on macOS").
- **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

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


## Core Principle
Use **inheritance sparingly** (stable "is-a" contracts), **roles heavily** (horizontal reuse). When in doubt: role, not subclass.

---

## House conventions

- **`with '...'` goes directly under `use Moo;`**, before the remaining `use` lines. Role composition is a runtime action, not an import — placing it with the class declaration says so. Same in Moose.
- **`is => 'lazy'` + a separate `sub _build_x`** for anything non-trivial; never move construction into `default => sub {...}`.
- **A builder that ignores `$self` is one line:** `sub _build_readonly { 0 }`.
- **`is => 'ro'` is the default**; `rw` needs a reason.
- **Type attributes.** `Types::Standard` is the house choice and typed attributes are wanted — reach for a type before leaving one off. See Type Constraints below.
- **`init_arg => undef`** for attributes the constructor must not set; `init_arg => 'other'` to rename or free up a method name.

---

## Pattern 1 – `extends` + Attribute Override

```perl
package App::Base;

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

package App::Friendly;
use Moo;
extends 'App::Base';
has '+prefix' => (default => sub { 'Hi' });   # override via +attr
```

**Rules:** Multiple `extends` calls REPLACE (don't add). Reference defaults always as coderefs (`sub { [] }`, never `[]`).

---

## Pattern 2 – Role with `requires`

```perl
package App::Role::UppercaseName;
use Moo::Role;
requires 'name';                          # contract: consumer must have name()
sub uppercase_name { uc $_[0]->name }

package App::User;
use Moo;
extends 'App::Base';
with 'App::Role::UppercaseName';          # composed; missing 'name' → loud failure
```

**Rules:** `requires` fails at composition time, not runtime. Imports inside a role land as methods on the consumer unless cleaned up – everything loaded *before* `use Moo::Role` is auto-cleaned; everything after is composed.

---

## Pattern 3 – Thin Classes (roles only, no base)

```perl
package App::Role::HasId;  use Moo::Role; has id => (is => 'ro', required => 1);
package App::Role::CanDescribe;
use Moo::Role; requires 'id';
sub describe { "id=" . $_[0]->id }

package App::Thing;
use Moo;
with 'App::Role::HasId', 'App::Role::CanDescribe';
```

Use when there's no meaningful "is-a" relationship. Prefer over deep hierarchies.

---

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

  warnings->import::into($target);
  Moo->import::into($target);
  namespace::clean->import::into($target);   # after Moo, cleans stray imports
}

package App::Thing;
use My::Mooish;
has x => (is => 'ro', default => sub { 1 });
```

**Rules:** Order matters: imports → `use Moo` → `namespace::clean`. Use `namespace::autoclean` ≥ 0.16 only (older versions inflate Moo classes to Moose). Use `strictures` v2 with Moo 2.

---

## Pattern 5 – Delegation via `handles`

```perl
package App::UsesCounter;
use Moo;
has counter => (
  is      => 'ro',
  required => 1,
  handles => 'App::Role::CounterAPI',   # role defines the interface (inc/reset/value)
);
```

Three forms: `handles => 'RoleName'` (interface from role), `handles => [qw(inc reset)]` (list), `handles => { add => 'push' }` (rename). Does not trigger `isa`/`coerce`/`trigger` on the delegate itself.

---

## Pattern 6 – Native-Trait Delegation via `Sub::HandlesVia`

```perl
package Kitchen;
use Moo;
use Sub::HandlesVia;
use Types::Standard qw(ArrayRef Str);

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


has status => (
  is      => 'rw',
  trigger => 1,                                        # calls _trigger_status on set
);
sub _trigger_status { die "bad" unless $_[1] =~ /\A(new|ok)\z/ }

has _secret => (is => 'ro', init_arg => 'secret');       # constructor param alias
```

`trigger` fires on `new()` and `set`, NOT on `default`/`builder`. Old value is NOT passed (unlike Moose). `is => 'lazy'` = lazy reader, runs builder on first access.

---

## Pattern 9 – Lifecycle Hooks

```perl
around BUILDARGS => sub {
  my ($orig, $class, @args) = @_;
  return { source => $args[0] } if @args == 1 && !ref $args[0];  # normalize
  $class->$orig(@args);

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

};

package Thing;
use Moo;
use MooX::Role::Parameterized::With;
with Counter => { name => 'hits' };

Thing->new->inc_hits;   # generates: hits attribute + inc_hits method
```

Module is marked **experimental**. `role { }` block runs at composition time; `$mop` proxies `has/around/before/after/requires`.

---

## Pattern 13 – Moose Interop

When Moose is loaded before Moo classes are compiled, Moo auto-inflates its metaclasses. This means:

- Moose class can `extends` a Moo class
- Moo class can `with` a Moose role

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


Moo has no built-in type system — `isa` takes a coderef, and `Type::Tiny` objects
are coderefs, so `Types::Standard` plugs straight in:

```perl
use Types::Standard qw( Str ArrayRef );
has name => ( is => 'ro', isa => Str );
has tags => ( is => 'ro', isa => ArrayRef[Str], default => sub { [] } );
```

Where to type and where not, own type libraries, parameter signatures:
**`getty-perl-typing`**.

---

## Decision Guide

| Situation | Use |
|---|---|
| Shared attributes/methods, stable "is-a" | `extends` |
| Optional/horizontal feature | `Moo::Role` + `with` |
| Same pattern, different config | `MooX::Role::Parameterized` |
| Delegate method set to sub-object | `handles` |
| Array/Hash operations on attribute | `Sub::HandlesVia` |
| Logging/validation/caching wrapper | `before`/`around`/`after` |
| Catch constructor typos | `MooX::StrictConstructor` |
| Cross-project boilerplate | `Import::Into` house-style module |
| Named types | `Type::Tiny` / `Types::Standard` |
| Multiple roles define same method | Sequential `with` or refactor |
| Legacy non-Moo parent | `FOREIGNBUILDARGS` |
| Multiple inheritance | Last resort; use `mro 'c3'` |

---

## Common Pitfalls

- `default => []` → **shared state bug**. Always `default => sub { [] }`.
- `extends 'A'; extends 'B'` → replaces, does NOT add B to A. Use `extends 'A', 'B'`.
- Imports after `use Moo::Role` are **composed into consumers** as methods.
- `namespace::autoclean` < 0.16 inflates Moo classes to Moose unexpectedly.
- `trigger` does NOT receive old value (unlike Moose).

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

a licence only from a committed `LICENSE`, and a generated one never exists
outside the build — which is why a distribution built the default way shows up
as unlicensed on its own project page. `genlicense` writes the bare licence
text, not the `fulltext` that `[License]` generates: the copyright notice
`fulltext` prefixes is enough to make GitHub report `NOASSERTION` instead of
naming the licence.

`[LicenseFile]` is a recent addition to the bundle, so distributions that built
fine before it landed fail on their next build. When picking up an older dist,
`ls LICENSE` is cheaper than diagnosing it mid-release. For a dist that
deliberately ships no committed LICENSE, `generate_license = 1` restores the
generated file and adds no check.

## When the bundle applies

`[@Author::GETTY]` is for Getty's own CPAN work. A distribution that is not
released to CPAN — a proprietary application, a deploy artefact — lists its
Dist::Zilla plugins explicitly instead, because the bundle assumes a CPAN
release. Non-CPAN dists that still use the bundle set `no_cpan = 1`.

## `# ABSTRACT` lines

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

- `gitea` - Treat the remote host as Gitea/Forgejo (repository/bugtracker/homepage via GiteaMeta). Only needed for self-hosted instances — codeberg.org and the author's own are auto-detected. No effect when a GitHub remote exists
- `include_readme` - Ship README.md (excluded from the tarball by default)
- `no_install` - Resulting distribution can't be installed
- `generate_license` - Go back to a generated LICENSE: `@Basic` keeps its License plugin, no LicenseFile check is added. Default 0 — the bundle expects a committed LICENSE (see above)

### Identity & Metadata
- `author` - CPAN author name used for the authority
- `authority` - Override the authority, e.g. `authority = ETHER` when uploading modules owned by another author (default: the `author` value)

### XS with Alien
- `xs_alien = Alien::Foo` - Auto-configures MakeMaker::Awesome for XS+Alien
- `xs_object = Name` - Override XS object name (default: derived from Alien name)

### Versioning
- `task = 1` - TaskWeaver + AutoVersion
- `manual_version = x.x` - Manual version
- `major_version = 2` - Major version for AutoVersion
- `version_finder` - multi-value; forwarded as the `finder` option of RewriteVersion::Transitional + BumpVersionAfterRelease (default path) and PkgVersion (task/manual_version path). Defaults to `:MainModule` when `no_cpan` is set, otherwise unset.

### Build & Release
- `weaver_config` - PodWeaver `config_plugin` to use (default: the bundle's own)
- `installrelease_command` - Command used to install after release, instead of cpanm

### Docker
- `docker_image` - Image repository. Auto-adds one Docker::API plugin, which is a working Releaser on its own (no UploadToCPAN needed for non-CPAN dists)
- `docker_tags` - Whitespace-separated tag list (default: `latest %V %v`)
- `docker_local` - Build and tag the image, but don't push
- `docker_default` - Set to 0 to suppress the auto-added plugin when you configure builds exclusively through `[@Author::GETTY::Docker / name]` subsections

### Support
- `irc = #channel` - IRC channel
- `irc_server` - Server (default: irc.perl.org)
- `irc_user` - Username for SUPPORT section

### Git
- `release_branch` - Branch for releases (default: main)
- `tag_format` - Release tag format. Default `%v`, the bare `$VERSION` (`0.317`) — *not* a v-prefixed SemVer tag. Use `v%v.0` when the tag must satisfy strict vMAJOR.MINOR.PATCH (Perl's decimal `$VERSION` has only two parts, the `.0` supplies the p...
- `commit_files_after_release` - Multi-value; extra files folded into the release commit (via Git::Commit's `allow_dirty`). For artefacts a `run_before_release` hook rewrites, e.g. a sibling Python/JS version file

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

- `alien_autoconf_with_pic` - Pass --with-pic to autoconf
- `alien_isolate_dynamic` - Isolate dynamic libraries
- `alien_version_check` - Command to check installed version

**Custom build commands (for non-autoconf projects):**
- `alien_build_command` - Custom build commands (multi-value, use `%s` for prefix)
- `alien_install_command` - Custom install commands (multi-value)
- `alien_test_command` - Custom test commands (multi-value)

**Dependencies:**
- `alien_bin_requires` - Build dependencies (multi-value)

### Run Hooks (prefix `run_`)
- `run_before_build`, `run_after_build`
- `run_before_release`, `run_after_release`
- `run_release`, `run_test`

Use a run hook for the project-specific step that follows a release — a Docker
build and push, a deploy script — so it travels with `dzil release` instead of
living in someone's shell history. Never wire up a step the bundle already
performs; the hook is for what it does *not* know about.

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

### Every file carries its own `$VERSION`

**Each file under `lib/` and `bin/` needs its own `our $VERSION = '...';`**, set to
the version that will be released NEXT — one higher than what is on CPAN (or
higher). A file without a `$VERSION` ships versionless and breaks consumers that
pin against it.

**Only the FIRST `our $VERSION` in a file gets rewritten.** RewriteVersion::Transitional
and BumpVersionAfterRelease both stop after the first match, so a file holding two
packages leaves the second one frozen at whatever version it was written with —
while MetaProvides::Update happily reports the real release version. The result is
a distribution whose META and whose code disagree, silently, for as many releases
as it takes someone to notice.

So: **one package per file.** If you find several `package` statements in one file,
split them out before releasing.

**Executables belong in `bin/`, never `script/`.** The bundle sets no `ExecDir`, so
Dist::Zilla's default of `bin` applies: files under `script/` are not installed as
executables and their `$VERSION` is never rewritten. A distribution with a `script/`
directory should have it renamed to `bin/` — otherwise none of the above takes

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

`allow_dirty` list, so an uncommitted edit stops nothing — it is folded into the release
snapshot silently, under a message naming only the version.

## Conventions

1. `copyright_year` IS used in dist.ini — GETTY has it in ALL distributions, do NOT remove it
2. No `=head1 SUPPORT/AUTHOR/COPYRIGHT` in POD
3. Use inline `=attr`/`=method` directly after code
4. Dependencies in `cpanfile`, not dist.ini
5. Changes file with `{{$NEXT}}` for unreleased
6. For XS+Alien modules: use `xs_alien = Alien::Foo` (auto-configures MakeMaker::Awesome)
7. `LICENSE` is generated once with `dzil genlicense` and **committed** — the build aborts without it

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN


`--json` is available on every command with an alternate rendering. `--compact`
is not -- exactly nine render one: `board`, `config`, `context`, `dashboard`,
`list`, `log`, `metrics`, `pick`, `show`. Anywhere else it answers
`Unknown option: compact` with the usage and exit 2, rather than accepting the
flag and ignoring it.

## Referring to a karr id

In prose -- commit subjects, card bodies, anywhere the text travels -- a karr id is
written `k12`, never a bare `#12`: GitHub, Gitea and GitLab all resolve `#12` against
*their own* issue 12, which is a different thing or nothing at all. This is karr's
numbering only and says nothing about how the repository's public issue tracker is
referenced -- that is separate, with its own notation.

## Commands

### Initialize

```bash
karr init [--name NAME] [--statuses s1,s2,s3] [--claude-skill] [--new-board]
```

Creates the board refs inside the current Git repository. With
`--claude-skill`, installs this skill to
`.claude/skills/kanban-issues-karr-cli/SKILL.md`.

Before it writes anything, init asks the remote whether this repository already
has a board there: `git clone` does not fetch `refs/karr/*`, so a fresh clone
looks exactly like a repository that never had one. A remote that advertises
`refs/karr/*` means the board exists and is one `karr sync` away, so init
refuses and says so rather than starting a second board beside it. Every other
answer -- no remote, an unreachable one, no answer inside the probe budget --
lets init through, because it has to work offline. Use `--new-board` only when
a clone is really meant to keep its own, independent board: the two will not
sync with each other, and the board-identity guard is what stops them.

### Create task

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN

karr create --title "Title" --assignee NAME --due 2026-03-15
karr create "Ship it" --depends-on 2,3       # ids of tasks this one depends on; each must exist on this board
karr create "Wait for the fix" --needs other-repo#7   # waits on a card in ANOTHER repository of the fleet
karr create "Fix the thing" --escalated-from home#5   # the card raised in that other repository
```

### List tasks

```bash
karr list                                    # the open cards
karr list --status todo,in-progress          # filter by status
karr list --priority high,critical           # filter by priority
karr list --tag backend                      # filter by tag
karr list --class expedite                   # filter by class of service
karr list --blocked                          # only the blocked cards
karr list --not-blocked                      # only the unblocked ones
karr list --archived                         # the archive, and nothing else
karr list -s "search term"                   # search title/body/tags
karr list --sort priority --reverse          # sort and reverse
karr list --sort priority -n 5 --json        # the five most urgent open cards
karr list --claimed-by agent-1               # filter by claim owner

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN

karr show --agent NAME     # the task most recently claimed by NAME
karr show ID --compact     # one line per card, as list --compact
```

### Move task

```bash
karr move ID STATUS                          # move to specific status
karr move ID --next                          # advance one status
karr move ID --prev                          # go back one status
karr move ID in-progress --claim agent-1     # move and claim
```

### Edit task

```bash
karr edit ID --title "New title"
karr edit ID --priority high --add-tag urgent
karr edit ID --add-depends-on 2,3            # append dependency ids (no duplicates; ids must exist, no self-reference)
karr edit ID --remove-depends-on 4           # absent ids are a no-op (cleanup after a deleted dependency)
karr edit ID --add-needs other-repo#7        # append a cross-board dependency (see below)

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN

karr edit ID --body "New description"
karr edit ID -a "Appended note"              # append to body
karr edit ID --claim agent-1                 # claim
karr edit ID --release                       # release claim
karr edit ID --block "Waiting on API"        # mark blocked
karr edit ID --unblock                       # clear blocked
```

An unknown or non-numeric id given to `--depends-on`/`--add-depends-on` rejects
the whole invocation before anything is written (usage error, exit 2); a
self-reference (`karr edit 5 --add-depends-on 5`) fails only that id, the rest
of the batch proceeds, and the command exits 1. Taking up a card whose
dependencies are unfinished warns on move/pick but is never blocked.

### Delete task

```bash
karr delete ID                               # asks first
karr delete ID --yes                         # skip confirmation
karr delete ID,ID,ID --yes                   # a batch
```

Before an id goes, `delete` names on STDERR every card on this board that
points at it -- a `depends_on` entry or a `parent` -- and every cross-board
link the card itself carries (`escalated-from:`, `needs:`), offering
`karr archive` as the way to keep the card readable instead. The delete then
proceeds: karr warns about dependencies, it does not block on them. `--json`
carries the same sentences as `dependent_warnings` and `cross_board_warnings`
in the result object.

The question itself goes to STDERR on every path, not only under `--json`:
STDOUT belongs to the result, so `karr delete ID --json` decodes as a whole
even when the answer is typed rather than passed as `--yes`. A task with a live
claim is not deleted at all -- release it or wait for `claim_timeout`.

### Archive task

```bash
karr archive ID                              # soft-delete (move to archived)
```

Idempotent — archiving an already-archived task is a no-op.

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN


No line ever exceeds the terminal width. Where there are more board-less
repositories than fit one line, they collapse to a count
(`No board: 46 repos (--show-no-board to list them)`) rather than wrapping
over half the screen and burying the summary.

### Pick next task (multi-agent)

```bash
karr pick --claim agent-1                    # pick highest priority available
karr pick --claim agent-1 --status todo --move in-progress
karr pick --claim agent-1 --tags backend
karr pick --claim agent-1 --compact          # stop after the assignment line
```

Atomically finds and claims the next available task. Respects claim timeouts, blocked state, and class-of-service priority ordering (expedite > fixed-date > standard > intangible); where two `fixed-date` cards meet, the due date is asked before prior...

### Unlock a stuck task

```bash
karr unlock                                  # list the pick locks currently held
karr unlock ID                               # break one
karr unlock --all                            # break all of them
```

`karr pick` takes a lock ref and gives it back inside the same command, so normally there is nothing here to see. An agent that dies mid-pick leaves one behind. Locks expire on their own after `lock_timeout` (default `5m`, board config); this is how ...

### Handoff task for review

```bash
karr handoff ID --claim agent-1              # move to review, refresh claim
karr handoff ID --claim agent-1 --note "Done, needs QA" --timestamp
karr handoff ID --claim agent-1 --block "waiting for feedback" --release
```

Moves the task to the board's review column, refreshes the claim, and optionally appends a timestamped note, blocks, or releases the claim. On a board that configures a `review` status that is the target; a board without one hands off to its last non...

### Cross-board dependencies

`--depends-on` is board-local. When work here cannot proceed until something is
fixed in *another repository*, that link is a cross-board dependency:

```bash
# in the other repository -- raise the card and record where it came from
karr create "Fix the API" --escalated-from home#5

# here -- record what you are waiting for, block, release the claim, leave
karr edit 5 --add-needs other-repo#7 --block "needs other-repo#7: API change first" --release

# any time -- what is this board waiting on, and is it done yet?
karr needs
karr needs --board other-repo=/srv/other-repo     # where that board is on THIS machine
karr needs --resolve                              # drop settled links, unblock what is free
```

A reference is `BOARD#ID`: the other board's **name** and a task id. Never a
path -- the card is shared state and two clones of the same fleet have
different directories. karr turns the name into a directory from
`--board NAME=PATH` or from the fleet config
(`~/.config/karr-foundation/config.yml`, `--fleet-config` to point elsewhere),
matching the repository's directory basename.

`--resolve` settles a link whose far card has reached one of the **far** board's
own terminal statuses, and lifts the `blocked` flag when a card's last link
settles, printing the reason it lifted. A far card that does not exist settles
nothing. A board this machine cannot place is reported, not fatal.

Like `depends_on`, a cross-board link blocks nothing by itself: `pick` hands the
card over and says what it waits on. The `blocked` flag is what keeps the card
out of `pick` and out of karr-foundation's selection -- the link is the fact,
`blocked` is the decision.

### Config

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN

karr config get KEY                          # get a single value
karr config set KEY VALUE                    # set a writable value
karr config show --defaults                  # karr's defaults, no board read
karr config --json                           # JSON output
karr config show --compact                   # key=value per line, no padding
```

Writable keys: `board.name`, `board.description`, `defaults.status`, `defaults.priority`, `defaults.class`, `claim_timeout`, `lock_timeout`, `foundation.enabled`, `foundation.reason`.

`show` and `get` read this board and refuse with exit 1 when there is none —
they never fall back to the built-in defaults, which is how a fresh clone used
to answer `board.name: Kanban Board` for a board that has a name. Ask for those
defaults explicitly with `--defaults`: it reads no board (and needs no
repository), so `diff <(karr config show) <(karr config show --defaults)` is
exactly what this board overrides.

### Disable / enable automated agent runs

```bash
karr disable                                 # no automated agent runs here
karr disable --reason "abandoned driver, backlog parked"

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN

```bash
karr context                                 # print markdown summary
karr context --write-to AGENTS.md            # create/update file with sentinels
karr context --sections blocked,overdue      # filter sections
karr context --days 14                       # lookback for recently-completed
karr context --activity-limit 10             # other agents' log entries in Recent Activity
karr context --json                          # JSON output
karr context --compact                       # board_name and the four counts, key=value
```

Generates a markdown summary with sections: In Progress, Blocked, Overdue, Recently Completed, Recent Activity (other agents' log entries, newest first, bounded by `--activity-limit`, default 5). `--sections` takes the slugs `in-progress,blocked,over...

### Skill management

```bash
karr skill install                           # install skill for detected agents
karr skill install --agent claude-code       # install for specific agent
karr skill install --global                  # install globally (~/)
karr skill install --force                   # force reinstall
karr skill check                             # check if installed skills are current
karr skill update                            # update outdated skills

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN

`karr sync` also carries `refs/karr-foundation/*` — karr-foundation's shared
chain, run logs, question mailbox and design documents — in the same run,
after the board and never on its own. One command on purpose: a separate one
would be a second thing to remember, and a coordination namespace nobody
synced fails quietly. Mutating commands still sync the board only, so this
costs nothing outside an explicitly typed `karr sync`, and a repository
holding nothing under `refs/karr-foundation/` pushes nothing there. Deletions
in that namespace (log retention, a cleared chain) travel like board deletions
do, so a pruned run log does not come back on the next pull.

**A fresh clone fetches the board by itself.** `git clone` does not carry
`refs/karr/*`, so a new checkout holds no board while the whole board sits on
its remote. The read commands (`board`, `list`, `show`, `log`, `context`,
`metrics`, `needs`, and `config show`/`config get`) do not pull as a rule —
only mutating commands do — but where there is nothing under `refs/karr/` at
all and the remote has a board, they fetch it once and answer, with one line
on STDERR (never STDOUT) saying where it came from. Where there is no remote,
or the remote has no board, they still refuse with exit 1 rather than
rendering an empty board: that is the only place `karr init` is the answer. In
a clone whose board is on the remote, `karr init` refuses as well and points
at `karr sync`, so it can no longer start a second, empty board beside the

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN

needed. A board created by a later version needs nothing here and says so.

The same command also raises a `started` stamp that precedes its own card's
`created` up to that `created` — karr wrote `started` as a bare date until
ticket #68, which reads as midnight and so lands before a card created later
the same day. A clamped card then asserts zero queue time and no longer
records that its stamp was ever day-granular, so the dry run tells you how
many cards that is before you apply it. It reports, but does not touch,
`completed` stamps with the same day-granular problem.

### Backup and restore

```bash
karr backup > karr-backup.yml
karr restore --yes < karr-backup.yml
```

`restore` is destructive and replaces the entire `refs/karr/*` namespace.

### Destroy

```bash
karr destroy --yes
```

Deletes the entire `refs/karr/*` namespace from the repository and prunes the
remote board state too when a remote is configured. Prefer taking a
`karr backup` first.

### Helper refs

```bash
karr set-refs superpowers/spec/1234.md draft ready
karr set-refs superpowers/spec/1234.md < design.md    # multi-line payload
karr get-refs superpowers/spec/1234.md
```

Stores and retrieves helper payloads in Git refs outside protected namespaces
such as `refs/karr/*`, branches, and tags. Use this for shared planning blobs,
agent scratch data, or similar workflow artifacts that should sync through Git
without becoming task cards.

The arguments after the ref are joined with a single space, so they are a
one-line payload. A document goes in on stdin instead — with no content
argument at all, `karr set-refs REF < file` stores the file verbatim and
`karr get-refs REF > file` gives it back unchanged.

### Activity log

```bash
karr log                                     # last 20 entries
karr log --agent swift-fox                   # filter by agent
karr log --task 5                            # filter by task
karr log --last 50 --json                    # more entries, JSON
karr log --compact                           # one line per entry, no padding

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN

qualified instead of cleaned — they are *in* the figure, not missing from it,
which is why they are not in `unusable_timestamps`. They come from boards
written before karr 0.403, which stamped `started`/`completed` as a bare
`YYYY-MM-DD` that reads as midnight; on such a board an average printed to the
hour is finer than the data underneath it.

### Agent name

```bash
NAME=$(karr agent-name)                       # mint once, reuse everywhere
karr pick --claim "$NAME" --move in-progress
karr handoff ID --claim "$NAME" --note "Implementation complete"
```

Every `karr agent-name` call mints a **new** name and remembers it nowhere, so
`--claim "$(karr agent-name)"` written a second time claims under one name and
hands off under another — while the first claim is live the handoff is refused,
and once it has expired it silently re-stamps the card with a name nobody holds.
Capture the name once into a shell variable and pass that same variable to every
later `--claim`, `--claimed-by` and `log --agent`. If it was never captured, read
it back off the board (`karr show ID` → `Claimed:`, or `karr pick`'s own
`(claimed by NAME)`) rather than minting a fresh one.

## Stored task format

```markdown
id: 1
title: Set up CI pipeline
status: backlog
priority: high
class: standard
created: 2026-03-12T10:00:00Z

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN


## Config refs

```yaml
version: 1
board:
  name: My Project
statuses:
  - backlog
  - todo
  - name: in-progress
    require_claim: true
  - name: review
    require_claim: true
  - done
  - archived
priorities: [low, medium, high, critical]
classes: [expedite, fixed-date, standard, intangible]
claim_timeout: 1h
defaults:
  status: backlog

.claude/skills/kanban-issues-karr-cli/SKILL.md  view on Meta::CPAN

```

That YAML lives in `refs/karr/config` as sparse overrides. The next numeric id
is kept separately in `refs/karr/meta/next-id`.

## Decision tree: which command?

1. **Need a board?** → `karr init`
2. **New work item?** → `karr create "Title" --priority high`
3. **What's on the board?** → `karr board` or `karr list`
4. **Starting work?** → `karr pick --claim NAME --move in-progress`
5. **Done with task, hand to review?** → `karr handoff ID --claim NAME --note "reason"`
6. **Done with task, close it?** → `karr edit ID --release && karr move ID done`
7. **Blocked?** → `karr edit ID --block "reason"`
8. **Need details?** → `karr show ID`
9. **Soft-delete?** → `karr archive ID`
10. **Board snapshot for agent context?** → `karr context --write-to AGENTS.md`
11. **Check/change config?** → `karr config` / `karr config set KEY VALUE`
12. **Install agent skills?** → `karr skill install`
13. **Need a full board snapshot?** → `karr backup` / `karr restore --yes`
14. **Need shared non-task workflow data?** → `karr set-refs` / `karr get-refs`
15. **Board should never be drained by an automation host?** → `karr disable --reason "why"`
16. **Need to remove the board completely?** → `karr destroy --yes`
17. **Overview of every board under a directory?** → `karr dashboard`

## Multi-agent workflow

```bash
# 1. Generate agent name and pick task
NAME=$(karr agent-name)
karr pick --claim $NAME --status todo --move in-progress

# 2. Work on task...

# 3. Hand off for review
karr handoff ID --claim $NAME --note "Implementation complete" --timestamp

# 4. Or: release and mark done directly
karr edit ID --release
karr move ID done
```

CLAUDE.md  view on Meta::CPAN

    release` replaces it with the version + timestamp.

## What this distribution is

A pure-Perl client for the Docker Engine API. No LWP, no shell-outs —
HTTP/1.1 (incl. chunked) is spoken directly over the daemon's Unix
socket (default) or a TCP endpoint. Any engine serving that API works;
Podman needs nothing but `DOCKER_HOST`.

The synchronous `_request` core lives in
`API::Docker::Role::HTTP`; resource-specific API methods live in
`API::Docker::API::*`. Entities hang off the resource APIs: every resource
returns generated `API::Docker::Type::*` classes with an
`API::Docker::Role::Entity::*` composed onto them (karr k79 step 6/7,
finished in k84). There are no hand-written entity wrapper classes left.

Architecture, transport invariants, the streaming and `X-Registry-Auth`
details, and the mock harness are in skill `api-docker-core` — that is
the source of truth, not this file.

## Layout

```
lib/API/Docker.pm                       # main client, version negotiation
lib/API/Docker/Role/HTTP.pm             # HTTP/1.1 transport (unix:// + tcp://, TLS)
lib/API/Docker/Role/RegistryAuth.pm     # X-Registry-Auth / AuthConfig encoding
lib/API/Docker/Role/Filters.pm          # the `filters` query parameter, shape-normalised
lib/API/Docker/Role/Using.pm            # `using`, the resource class clone that bounds a run of calls
lib/API/Docker/API/System.pm            # /version, /info, /_ping, /auth, /events
lib/API/Docker/API/Containers.pm        # container endpoints (incl. archive, attach)
lib/API/Docker/API/Images.pm            # image endpoints (build, pull, push, tar, commit, ...)
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/API/Distribution.pm      # /distribution registry manifest lookups
lib/API/Docker/API/Secrets.pm           # /secrets
lib/API/Docker/API/Configs.pm           # /configs
lib/API/Docker/API/Plugins.pm           # /plugins

CLAUDE.md  view on Meta::CPAN

lib/API/Docker/Role/Entity/Container.pm # container operations, composed onto ContainerSummary + ContainerInspectResponse
lib/API/Docker/Role/Entity/Image.pm     # image operations, composed onto ImageSummary + ImageInspect
lib/API/Docker/Role/Entity/Network.pm   # network operations, composed onto Type::Network (one class for list and inspect)
lib/API/Docker/Role/Entity/Volume.pm    # volume operations, composed onto Type::Volume (list, inspect and create)
lib/API/Docker/Role/Entity/Plugin.pm    # plugin operations, composed onto Type::Plugin
lib/API/Docker/Role/Entity/Secret.pm    # secret operations, composed onto Type::Secret
lib/API/Docker/Role/Entity/Config.pm    # config operations, composed onto Type::Config
lib/API/Docker/Error/HTTP.pm            # croaked on a status of 400 or above
lib/API/Docker/Error/Stream.pm          # croaked on a failed build/pull/push stream
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
prove -lv t/images.t    # single test

CLAUDE.md  view on Meta::CPAN

# what each one announces: Platform.Name, ApiVersion, MinAPIVersion
curl -s --unix-socket <socket> http://localhost/version
# then
API_DOCKER_TEST_HOST=unix://<socket> prove -lr t/
```

A missing socket makes the suite `skip_all`, so a live run pointed at a
socket that is not there reports success while testing nothing — read the
skip line, not just the exit code. Ask the engine what it announces rather
than reading a version off a path or off the `/v1.XX/` in a hand-written
URL. Run it and read the result. No file or test count belongs here: one was
written down twice and was wrong both times, because the suite grows with
every fixture and every generated type.

`t/system.t`'s `events` subtest used to assert a shape a real daemon does not
return for an empty window; the live
branch was made tolerant of it in `1ad2c28`, and the underlying cause is now
fixed too — `_request` returns `[]` rather than `undef` for a zero-byte
`ndjson` body.

## Delegation

Don't touch behavior-relevant code yourself — hand it to the right agent.
The principle, the lanes and the repo's hazards are in
`.claude/rules/api-docker-rules.md`.

| Task | Agent |
|---|---|
| What the daemon does or expects — endpoints, wire formats, filters, registry auth | `api-docker-engine-worker` |
| The Perl side — Moo, transport internals, entities, refactoring, cpanfile | `api-docker-worker` (default) |
| Write/extend tests, add fixtures | `api-docker-test-writer` |
| The generated type model — `API::Docker::Type::*`, the 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. Only `api-docker-engine-worker`
is briefed with `docker-engine-api`, the shared Engine API reference.

`api-docker-type-writer` is briefed with `api-docker-type-model`, which carries the
pattern for the generated classes; see karr k79.

The agents carry their skills via `briefing.skills` (see `.claude/agents/`);
the main agent delegates rather than loading them. Skill sources live under
`.claude/skills/` — `api-docker-core` is owned here, the rest are hardlinks
managed with `manage-skills` (`docker-engine-api` lives in the shared library and
is reused by `../p5-dist-zilla-plugin-docker-api`).

Ticket coordination runs on the repo's `karr` board (`karr board`).

## When changing behavior

- Add a `Changes` entry under `{{$NEXT}}`, and say what was measured.
- Update the POD on the affected class. POD lives next to the code
  (`=method`, `=attr`, `=head1 SYNOPSIS` ...) and is woven by the

Changes  view on Meta::CPAN

Revision history for API-Docker

0.004     2026-08-30 03:30:59Z

  - The typed object model: `list` and `inspect` on every resource now
    return generated `API::Docker::Type::*` objects with snake_case
    accessors (`$c->id`, `$c->size_root_fs`) instead of hashrefs. An
    unrecognised field is kept and re-sent unchanged rather than dropped,
    and `since` is documentation only.
  - The hand-written entity classes are gone. Their convenience methods
    (`start`, `stop`, `logs`, ...) keep their signatures and move to
    `API::Docker::Role::Entity::*`, composed onto the generated classes; the
    old names (`API::Docker::Container`, `::Image`, `::Network`, `::Volume`,
    `::Plugin`, `::Secret`, `::Config`) ship as stubs that croak, naming what
    replaces them.
  - New streaming options `on_event`, `on_frame` and `on_chunk` on the HTTP
    verbs, wired into `system->events`, `containers->logs`/`stats`/`attach`,
    `exec->start`, the `images` build/pull/push/load/get family and the
    `plugins` install/upgrade/push. A callback receives each event, frame or
    chunk as it arrives; `$stop->()` ends the stream early. Without one the
    unbounded endpoints still block.
  - New `read_timeout` and `connect_timeout`, as client attributes and
    per-request options, off by default. `read_timeout` is idle time since
    the last byte; both croak `API::Docker::Error::Timeout`, which carries
    whatever already arrived. A new `TIMEOUTS` section in `API::Docker`
    documents what each bounds. `containers->stats` is deliberately not
    bounded by `read_timeout` -- its stream keeps producing rather than
    going idle.
  - New `API::Docker::Role::Using`: `$docker->containers->using(read_timeout
    => 5)->list` clones a resource class to bound a run of calls. Every
    request the run makes, version negotiation included, carries the bound;
    an explicit `0` turns a client-wide default off.
  - A truncated response is now an exception. A body shorter than its
    Content-Length, a short or malformed chunk, a missing zero chunk, a
    malformed status line, a bad `Content-Length` or a stray 1xx all croak
    `API::Docker::Error::Truncated` instead of being handed back as a whole
    response.
  - A status of 400 or above croaks `API::Docker::Error::HTTP` instead of a
    plain string, carrying `status`, `reason`, `body` and decoded `data`. It
    stringifies exactly as the old string croak did, so text-matching
    callers are unaffected.
  - `tls => 1` now speaks real TLS over `tcp://`. `cert_path` names the
    `docker` CLI's `ca.pem`/`cert.pem`/`key.pem` layout, `tls_insecure`
    turns verification off, and `tls` defaults from
    `$ENV{DOCKER_TLS_VERIFY}`. `IO::Socket::SSL` is a recommended
    dependency, loaded on the first TLS connection.
  - New `response => \%h` option fills `status`, `reason` and `headers`,
    including for a request that croaked. New `head` verb beside
    `get`/`post`/`put`/`delete_request`, which never waits for a body.
  - `negotiate_version` croaks, naming `GET /version` and the expected
    shape, when the body is not a JSON object carrying an `ApiVersion` of the
    form `N.N`.
  - New `API::Docker::Role::Filters`, consumed by all eight resource classes
    and applied at every `filters` call site: a bare value, a boolean or a
    mis-shaped filter is normalised to the engine's JSON map-of-arrays
    instead of silently matching nothing.
  - JSON request bodies send booleans as real `true`/`false`; a caller may
    pass `1`/`0` or a JSON boolean interchangeably.
  - `_uri_encode` UTF-8-encodes a decoded character string before
    percent-escaping, so a name or tag typed as characters (`ü`, `中`) goes
    out as valid UTF-8.
  - A request path outside the RFC 3986 origin-form character set is refused
    before it reaches the daemon, closing a request-line injection through a
    container name or image reference.
  - An ArrayRef query parameter expands into one repeated `k=v` pair per
    element (`names=a&names=b`), which some endpoints require.
  - A bare JSON scalar body (`null`, `true`, a number, a quoted string) is
    decoded rather than handed back as raw bytes; `raw` and `ndjson` return
    `''` and `[]` for a zero-byte body instead of `undef`.
  - Registry credentials reach `images->pull` (`auth`, sent as
    `X-Registry-Auth`) and `images->build` (`registry_config`, sent as
    `X-Registry-Config`), sent only when given. An already-base64 auth value
    in the standard alphabet is respelled URL-safe, which the engine
    requires.
  - `images->pull` no longer appends a default `tag` onto a reference that
    already carries a `:tag` or `@digest`.
  - New `images->get`, `->get_all` and `->load`: the image tar roundtrip in
    and out of a daemon without a registry. New `images->commit`
    (POST /commit) and `images->build_prune` (POST /build/prune, the
    BuildKit cache, a different store from the dangling images
    `images->prune` deletes).
  - New container endpoints: `get_archive`, `put_archive`, `stat_archive`
    (the `docker cp` primitives), `changes`, `export`, `resize` and the
    one-way half of `attach`. `attach` defaults to `stream => 0, logs => 1`
    (replay and return) and refuses a container that is not running unless
    `require_running => 0`.
  - `containers->start`/`stop`/`restart`/`pause`/`unpause` return 1 when the
    call changed the container's state and 0 when it was already in it (the
    engine answers a no-op with 304), instead of always undef.
  - `containers->stats` croaks `API::Docker::Error::HTTP` when Podman reports
    a failure inside a 200 response, instead of handing the error object back
    as a reading.
  - New `API::Docker::API::Plugins` (`$docker->plugins`): `list`,
    `privileges`, `install`, `inspect`, `remove`, `enable`, `disable`,
    `upgrade`, `push` and `configure`. Needs a real Docker daemon; Podman
    serves no `/plugins`.
  - New `API::Docker::API::Secrets` and `API::Docker::API::Configs`: `list`,
    `create`, `inspect`, `update` and `remove`. `Data` is base64-encoded for
    the caller; `update` takes the current `Version.Index` as a mandatory
    concurrency token.
  - New `API::Docker::API::System::auth` (POST /auth): check registry

Changes  view on Meta::CPAN

    Docker answers `{"message":...}`; Podman answers a failed push with
    the stream shape instead -- `{"errorDetail":{"message":...},"error":
    ...}` and no `message` at all -- so the whole JSON object used to be
    the croak text and the reason had to be dug out of it by eye. A body
    that is not an object still surfaces verbatim.
  - `images->build`, `->pull` and `->push` now croak when the engine
    reports a failure inside the event stream, instead of returning the
    stream and leaving the check to the caller. A failed build, pull or
    push is answered with HTTP 200 -- the status line is committed
    before the operation is attempted -- and the failure arrives as an
    `errorDetail` object among the progress events, so nothing about the
    response says the operation broke. Measured against the rootless
    Podman socket (5.4.2, API 1.41): a Dockerfile of `FROM alpine:3` /
    `RUN exit 7` answers `200 OK` and ends the stream with
    `{"errorDetail":{"message":"building at STEP \"RUN exit 7\": while
    running runtime: exit status 7\n"},"error":"..."}`, the flat `error`
    key carrying the same text. Callers that never scanned for it -- the
    documented shape until now -- reported a broken build as a success.
    The exception is an `API::Docker::Error::Stream`, a new class whose
    whole purpose is that the progress output is not lost with the
    return value: `$err->events` is the complete event list, error event
    included. It overloads stringification and produces exactly what the
    plain croak it replaces produced, reason plus Carp's ` at FILE line
    N.` suffix, so existing eval-and-inspect-$@ code needs no change;
    `$@ =~ s/...//` on it yields a plain string as it would for any
    overloaded object. The trigger is the `errorDetail` key alone, never
    the word "error" in payload text.
    Which of the three actually takes that route depends on the engine,
    and Podman is not Docker here -- measured on the same socket, all
    three cases: only `build` answers 200 with the failure in the
    stream. A pull of a missing repository answers `403 Forbidden` with
    `{"message":"denied: requested access to the resource is denied"}`,
    a missing tag answers `404 Not Found` with `{"message":"manifest
    unknown: manifest unknown"}`, and a push to an unreachable registry
    answers `500 Internal Server Error` with an `errorDetail` body and
    no `message` key at all. The first two never reach the stream; the
    third has its whole JSON body used as the croak text, because the
    >= 400 path looks for `message`. So on Podman the new check fires
    for `build` and the pre-existing status check catches the other two.
    All three are loud either way, but catching
    `API::Docker::Error::Stream` specifically is not a reliable way to
    catch a failed pull or push -- inspect $@ as a string, which both
    routes satisfy. The POD on each method says which engine does what.
    `system->events` is explicitly exempt and never croaks on stream
    content: it is a feed, so an object in it records something that
    happened on the engine rather than the outcome of this call. The
    check is on by default for the transport's `ndjson` option and
    exempting an endpoint is deliberate (`croak_on_error => 0`), because
    the operation-shaped streaming endpoints are open-ended while the
    feed-shaped ones are `/events` and nothing else.
  - `tls => 1` now croaks with "not implemented" instead of being
    accepted and ignored. `tls` and `cert_path` were attributes no code
    read: `API::Docker::Role::HTTP` builds a plain IO::Socket::INET and
    speaks HTTP over it, so a `tcp://` daemon was always addressed in
    cleartext and a caller who asked for TLS got an unencrypted
    connection with no indication of it -- anyone passing the option was
    by definition sending credentials in the clear while believing
    otherwise. TLS is still not implemented; the croak names the reason
    and the way round it, which is to terminate TLS in front of the
    daemon (stunnel, socat, `ssh -N -L`) and point `host` at the local
    end. Both attributes are kept. `cert_path` on its own does not
    croak: it defaults from `DOCKER_CERT_PATH`, which is exported on
    plenty of machines that also run the docker CLI, so croaking on it
    would break constructions over a value the caller never passed, and

Changes  view on Meta::CPAN

    the rootless Podman socket (5.4.2, API 1.41) with a container
    running `echo OUT; echo ERR 1>&2`: without a TTY the body is
    `01 00 00 00 00 00 00 04 "OUT\n" 02 00 00 00 00 00 00 04 "ERR\n"`,
    and the same exec produces byte-identical output. With a TTY there
    is no framing at all -- the body is `"OUT\r\n" "ERR\r\n"` -- which
    is why hand-testing interactively never showed the defect. TTY
    output comes back as one frame with `stream => 'raw'`, so the shape
    never varies and `$_->{stream} eq 'stderr'` is safe on any frame.
    Callers wanting plain text use
    `join '', map { $_->{data} } @$frames`.
    Framing is decided from the response bytes, not from `Content-Type`.
    Measured on Podman: `GET /containers/{id}/logs` sends no
    `Content-Type` whatsoever, for either kind of container, and
    `POST /exec/{id}/start` sends
    `application/vnd.docker.raw-stream` for both -- including the
    non-TTY exec whose body is in fact multiplexed. Trusting that header
    would put frame headers back into the caller's output on that
    engine. Instead the body is walked as frames and is only treated as
    framed when the walk consumes it exactly; the one way to fool it,
    and the `tty => 1` option that overrides it, are documented on
    `API::Docker::Role::HTTP::stream_frames`.

Changes  view on Meta::CPAN

    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
    unbounded event stream therefore never returns.
    Note for anyone scanning these events: a failed build is still HTTP
    200 with the failure carried as an `errorDetail` object inside the
    stream, confirmed on Podman for a Dockerfile whose `RUN` exits 7.
    A failed *pull* differs there -- Podman answers 404 with a plain
    `{"message":...}` body where Docker streams `errorDetail` on a 200 --
    so `pull` can croak as well as report an error event.
  - Bring the cpanfile in line with what the code loads. `URI` was
    required and is used nowhere in `lib/` or `t/`, so every consumer
    installed it for nothing; it is gone. `Carp` (loaded by eight of the
    twelve modules) and `IO::Socket::INET` (loaded by
    API::Docker::Role::HTTP beside its already-declared `IO::Socket::UNIX`
    sibling) were undeclared and are now required, as is `Exporter`
    under `on test` for the mock helper. Nothing else in the tree loads
    an undeclared module: `SOCK_STREAM` in the HTTP role comes from
    IO::Socket, which IO::Socket::UNIX and IO::Socket::INET both
    re-export, and `Path::Tiny` appears in `lib/` only inside the
    API::Docker::API::Images SYNOPSIS, so it stays a test dependency.
  - Fix every image push failing with a 400. The X-Registry-Auth header
    was encoded as base64url with the padding stripped; the engine
    decodes it with Go's `base64.URLEncoding`, which requires padding
    and answers `failed to parse "X-Registry-Auth" header ... unexpected
    EOF` without it. That hit authenticated and anonymous pushes alike
    -- the anonymous payload is `{}`, which encodes to three characters
    and one `=`. Measured against a local registry: before, all three
    tags of a test image came back 400 and nothing reached the registry;
    after, all three are there.
    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.

Changes  view on Meta::CPAN


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

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

LICENSE  view on Meta::CPAN

software and to any other program whose authors commit to using it.
You can use it for your programs, too.

  When we speak of free software, we are referring to freedom, not
price.  Specifically, the General Public License is designed to make
sure that you have the freedom to give away or sell copies of free
software, that you receive source code or can get it if you want it,
that you can change the software or use pieces of it in new free
programs; and that you know you can do these things.

  To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.

  For example, if you distribute copies of a such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have.  You must make sure that they, too, receive or can get the
source code.  And you must tell them their rights.

  We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.

LICENSE  view on Meta::CPAN


                    GNU GENERAL PUBLIC LICENSE
   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION

  0. This License Agreement applies to any program or other work which
contains a notice placed by the copyright holder saying it may be
distributed under the terms of this General Public License.  The
"Program", below, refers to any such program or work, and a "work based
on the Program" means either the Program or any work containing the
Program or a portion of it, either verbatim or with modifications.  Each
licensee is addressed as "you".

  1. You may copy and distribute verbatim copies of the Program's source
code as you receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice and
disclaimer of warranty; keep intact all the notices that refer to this
General Public License and to the absence of any warranty; and give any
other recipients of the Program a copy of this General Public License
along with the Program.  You may charge a fee for the physical act of
transferring a copy.

LICENSE  view on Meta::CPAN

    exchange for a fee.

Mere aggregation of another independent work with the Program (or its
derivative) on a volume of a storage or distribution medium does not bring
the other work under the scope of these terms.

  3. You may copy and distribute the Program (or a portion or derivative of
it, under Paragraph 2) in object code or executable form under the terms of
Paragraphs 1 and 2 above provided that you also do one of the following:

    a) accompany it with the complete corresponding machine-readable
    source code, which must be distributed under the terms of
    Paragraphs 1 and 2 above; or,

    b) accompany it with a written offer, valid for at least three
    years, to give any third party free (except for a nominal charge
    for the cost of distribution) a complete machine-readable copy of the
    corresponding source code, to be distributed under the terms of
    Paragraphs 1 and 2 above; or,

    c) accompany it with the information you received as to where the
    corresponding source code may be obtained.  (This alternative is
    allowed only for noncommercial distribution and only if you
    received the program in object code or executable form alone.)

Source code for a work means the preferred form of the work for making
modifications to it.  For an executable file, complete source code means
all the source code for all modules it contains; but, as a special
exception, it need not include source code for modules which are standard
libraries that accompany the operating system on which the executable
file runs, or for standard header files or definitions files that
accompany that operating system.

  4. You may not copy, modify, sublicense, distribute or transfer the
Program except as expressly provided under this General Public License.
Any attempt otherwise to copy, modify, sublicense, distribute or transfer
the Program is void, and will automatically terminate your rights to use
the Program under this License.  However, parties who have received
copies, or rights to use copies, from you under this General Public
License will not have their licenses terminated so long as such parties
remain in full compliance.

  5. By copying, distributing or modifying the Program (or any work based
on the Program) you indicate your acceptance of this license to do so,
and all its terms and conditions.

  6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the original
licensor to copy, distribute or modify the Program subject to these
terms and conditions.  You may not impose any further restrictions on the
recipients' exercise of the rights granted herein.

  7. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time.  Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.

Each version is given a distinguishing version number.  If the Program
specifies a version number of the license which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation.  If the Program does not specify a version number of
the license, you may choose any version ever published by the Free Software
Foundation.

  8. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission.  For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this.  Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.

                            NO WARRANTY

  9. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS

LICENSE  view on Meta::CPAN

The hypothetical commands `show w' and `show c' should show the
appropriate parts of the General Public License.  Of course, the
commands you use may be called something other than `show w' and `show
c'; they could even be mouse-clicks or menu items--whatever suits your
program.

You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary.  Here a sample; alter the names:

  Yoyodyne, Inc., hereby disclaims all copyright interest in the
  program `Gnomovision' (a program to direct compilers to make passes
  at assemblers) written by James Hacker.

  <signature of Moe Ghoul>, 1 April 1989
  Moe Ghoul, President of Vice

That's all there is to it!


--- The Perl Artistic License 1.0 ---

This software is Copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> L<https://raudssus.de/>.

This is free software, licensed under:

LICENSE  view on Meta::CPAN

        and so on.  (You will not be required to justify it to the
        Copyright Holder, but only to the computing community at large
        as a market that must bear the fee.)

        "Freely Available" means that no fee is charged for the item
        itself, though there may be fees involved in handling the item.
        It also means that recipients of the item may redistribute it
        under the same conditions they received it.

1. You may make and give away verbatim copies of the source form of the
Standard Version of this Package without restriction, provided that you
duplicate all of the original copyright notices and associated disclaimers.

2. You may apply bug fixes, portability fixes and other modifications
derived from the Public Domain or from the Copyright Holder.  A Package
modified in such a way shall still be considered the Standard Version.

3. You may otherwise modify your copy of this Package in any way, provided
that you insert a prominent notice in each changed file stating how and
when you changed that file, and provided that you do at least ONE of the
following:

LICENSE  view on Meta::CPAN

interpreter is so embedded.

6. The scripts and library files supplied as input to or produced as
output from the programs of this Package do not automatically fall
under the copyright of this Package, but belong to whoever generated
them, and may be sold commercially, and may be aggregated with this
Package.  If such scripts or library files are aggregated with this
Package via the so-called "undump" or "unexec" methods of producing a
binary executable image, then distribution of such an image shall
neither be construed as a distribution of this Package nor shall it
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.

MANIFEST  view on Meta::CPAN

lib/API/Docker/Role/Entity/Secret.pm
lib/API/Docker/Role/Entity/Volume.pm
lib/API/Docker/Role/Filters.pm
lib/API/Docker/Role/HTTP.pm
lib/API/Docker/Role/JSONBody.pm
lib/API/Docker/Role/RegistryAuth.pm
lib/API/Docker/Role/Type.pm
lib/API/Docker/Role/Using.pm
lib/API/Docker/Secret.pm
lib/API/Docker/Type.pm
lib/API/Docker/Type/Address.pm
lib/API/Docker/Type/AuthConfig.pm
lib/API/Docker/Type/BuildCache.pm
lib/API/Docker/Type/BuildInfo.pm
lib/API/Docker/Type/ClusterInfo.pm
lib/API/Docker/Type/ClusterVolume.pm
lib/API/Docker/Type/ClusterVolume/Info.pm
lib/API/Docker/Type/ClusterVolume/PublishStatus.pm
lib/API/Docker/Type/ClusterVolumeSpec.pm
lib/API/Docker/Type/ClusterVolumeSpec/AccessMode.pm
lib/API/Docker/Type/ClusterVolumeSpec/AccessMode/AccessibilityRequirements.pm

MANIFEST  view on Meta::CPAN

lib/API/Docker/Type/PluginDevice.pm
lib/API/Docker/Type/PluginEnv.pm
lib/API/Docker/Type/PluginInterfaceType.pm
lib/API/Docker/Type/PluginMount.pm
lib/API/Docker/Type/PluginPrivilege.pm
lib/API/Docker/Type/PluginsInfo.pm
lib/API/Docker/Type/Port.pm
lib/API/Docker/Type/PortBinding.pm
lib/API/Docker/Type/PortStatus.pm
lib/API/Docker/Type/ProcessConfig.pm
lib/API/Docker/Type/ProgressDetail.pm
lib/API/Docker/Type/PushImageInfo.pm
lib/API/Docker/Type/RegistryServiceConfig.pm
lib/API/Docker/Type/ResourceObject.pm
lib/API/Docker/Type/Resources.pm
lib/API/Docker/Type/Resources/BlkioWeightDevice.pm
lib/API/Docker/Type/Resources/Ulimit.pm
lib/API/Docker/Type/RestartPolicy.pm
lib/API/Docker/Type/Runtime.pm
lib/API/Docker/Type/Secret.pm
lib/API/Docker/Type/SecretSpec.pm

MANIFEST  view on Meta::CPAN

lib/API/Docker/Type/SwarmSpec.pm
lib/API/Docker/Type/SwarmSpec/CAConfig.pm
lib/API/Docker/Type/SwarmSpec/CAConfig/ExternalCA.pm
lib/API/Docker/Type/SwarmSpec/Dispatcher.pm
lib/API/Docker/Type/SwarmSpec/EncryptionConfig.pm
lib/API/Docker/Type/SwarmSpec/Orchestration.pm
lib/API/Docker/Type/SwarmSpec/Raft.pm
lib/API/Docker/Type/SwarmSpec/TaskDefaults.pm
lib/API/Docker/Type/SwarmSpec/TaskDefaults/LogDriver.pm
lib/API/Docker/Type/SystemInfo.pm
lib/API/Docker/Type/SystemInfo/DefaultAddressPool.pm
lib/API/Docker/Type/SystemVersion.pm
lib/API/Docker/Type/SystemVersion/Component.pm
lib/API/Docker/Type/SystemVersion/Platform.pm
lib/API/Docker/Type/TLSInfo.pm
lib/API/Docker/Type/Task.pm
lib/API/Docker/Type/TaskSpec.pm
lib/API/Docker/Type/TaskSpec/ContainerSpec.pm
lib/API/Docker/Type/TaskSpec/ContainerSpec/Config.pm
lib/API/Docker/Type/TaskSpec/ContainerSpec/Config/File.pm
lib/API/Docker/Type/TaskSpec/ContainerSpec/DNSConfig.pm

MANIFEST  view on Meta::CPAN

t/connect_timeout.t
t/containers.t
t/containers_endpoints.t
t/containers_stats_error.t
t/dist_source.t
t/distribution.t
t/entity_container.t
t/entity_roles.t
t/exec.t
t/filters.t
t/fixtures/container_inspect.json
t/fixtures/containers_archive.tar
t/fixtures/containers_list.json
t/fixtures/containers_logs_multiplexed.bin
t/fixtures/containers_logs_tty.bin
t/fixtures/containers_logs_tty_json.bin
t/fixtures/exec_start_multiplexed.bin
t/fixtures/images_build_error_stream.ndjson
t/fixtures/images_build_quiet_stream.ndjson
t/fixtures/images_build_stream.ndjson
t/fixtures/images_get.tar
t/fixtures/images_list.json
t/fixtures/images_load_stream.ndjson
t/fixtures/images_pull_stream.ndjson
t/fixtures/networks_list.json
t/fixtures/secrets_list.json
t/fixtures/system_events_stream.ndjson
t/fixtures/system_info.json
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

MANIFEST  view on Meta::CPAN

t/stream_incremental.t
t/streaming_callback.t
t/streaming_methods.t
t/streaming_shape.t
t/system.t
t/system_auth.t
t/timeout_forwarding.t
t/tls.t
t/tls_read.t
t/transport_shape.t
t/truncated_response.t
t/type.t
t/type_fixture_passthrough.t
t/type_model.t
t/using.t
t/version.t
t/volumes.t

META.json  view on Meta::CPAN

   "license" : [
      "perl_5"
   ],
   "meta-spec" : {
      "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec",
      "version" : 2
   },
   "name" : "API-Docker",
   "prereqs" : {
      "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",
            "IO::Handle" : "0",
            "IO::Socket::INET" : "0",
            "IO::Socket::UNIX" : "0",
            "Import::Into" : "0",
            "JSON::MaybeXS" : "0",
            "Log::Any" : "0",
            "MIME::Base64" : "0",
            "Module::Runtime" : "0",

META.json  view on Meta::CPAN

            "Path::Tiny" : "0",
            "Scalar::Util" : "0",
            "Socket" : "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"

META.json  view on Meta::CPAN

         "version" : "0.004"
      },
      "API::Docker::Secret" : {
         "file" : "lib/API/Docker/Secret.pm",
         "version" : "0.004"
      },
      "API::Docker::Type" : {
         "file" : "lib/API/Docker/Type.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::Address" : {
         "file" : "lib/API/Docker/Type/Address.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::AuthConfig" : {
         "file" : "lib/API/Docker/Type/AuthConfig.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::BuildCache" : {
         "file" : "lib/API/Docker/Type/BuildCache.pm",
         "version" : "0.004"
      },

META.json  view on Meta::CPAN

         "version" : "0.004"
      },
      "API::Docker::Type::PortStatus" : {
         "file" : "lib/API/Docker/Type/PortStatus.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::ProcessConfig" : {
         "file" : "lib/API/Docker/Type/ProcessConfig.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::ProgressDetail" : {
         "file" : "lib/API/Docker/Type/ProgressDetail.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::PushImageInfo" : {
         "file" : "lib/API/Docker/Type/PushImageInfo.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::RegistryServiceConfig" : {
         "file" : "lib/API/Docker/Type/RegistryServiceConfig.pm",
         "version" : "0.004"
      },

META.json  view on Meta::CPAN

         "version" : "0.004"
      },
      "API::Docker::Type::SwarmSpec::TaskDefaults::LogDriver" : {
         "file" : "lib/API/Docker/Type/SwarmSpec/TaskDefaults/LogDriver.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::SystemInfo" : {
         "file" : "lib/API/Docker/Type/SystemInfo.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::SystemInfo::DefaultAddressPool" : {
         "file" : "lib/API/Docker/Type/SystemInfo/DefaultAddressPool.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::SystemVersion" : {
         "file" : "lib/API/Docker/Type/SystemVersion.pm",
         "version" : "0.004"
      },
      "API::Docker::Type::SystemVersion::Component" : {
         "file" : "lib/API/Docker/Type/SystemVersion/Component.pm",
         "version" : "0.004"
      },

META.json  view on Meta::CPAN

      "API::Docker::Type::VolumeListResponse" : {
         "file" : "lib/API/Docker/Type/VolumeListResponse.pm",
         "version" : "0.004"
      },
      "API::Docker::Volume" : {
         "file" : "lib/API/Docker/Volume.pm",
         "version" : "0.004"
      }
   },
   "release_status" : "stable",
   "resources" : {
      "bugtracker" : {
         "web" : "https://github.com/Getty/p5-api-docker/issues"
      },
      "homepage" : "https://github.com/Getty/p5-api-docker",
      "repository" : {
         "type" : "git",
         "url" : "https://github.com/Getty/p5-api-docker.git",
         "web" : "https://github.com/Getty/p5-api-docker"
      }
   },

META.json  view on Meta::CPAN

            "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"
                     }
                  ],
                  "include_underscores" : 0
               },
               "Dist::Zilla::Role::MetaProvider::Provider" : {
                  "$Dist::Zilla::Role::MetaProvider::Provider::VERSION" : "2.002004",
                  "inherit_missing" : 1,
                  "inherit_version" : 1,
                  "meta_noindex" : 1
               },
               "Dist::Zilla::Role::ModuleMetadata" : {
                  "Module::Metadata" : "1.000038",
                  "version" : "0.006"

META.json  view on Meta::CPAN

         "class" : "Dist::Zilla::Dist::Builder",
         "config" : {
            "is_trial" : 0
         },
         "version" : "6.037"
      }
   },
   "x_authority" : "cpan:GETTY",
   "x_generated_by_perl" : "v5.40.1",
   "x_serialization_backend" : "Cpanel::JSON::XS version 4.43",
   "x_spdx_expression" : "Artistic-1.0-Perl OR GPL-1.0-or-later"
}

META.yml  view on Meta::CPAN

---
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:
  API::Docker:

META.yml  view on Meta::CPAN

    version: '0.004'
  API::Docker::Role::Using:
    file: lib/API/Docker/Role/Using.pm
    version: '0.004'
  API::Docker::Secret:
    file: lib/API/Docker/Secret.pm
    version: '0.004'
  API::Docker::Type:
    file: lib/API/Docker/Type.pm
    version: '0.004'
  API::Docker::Type::Address:
    file: lib/API/Docker/Type/Address.pm
    version: '0.004'
  API::Docker::Type::AuthConfig:
    file: lib/API/Docker/Type/AuthConfig.pm
    version: '0.004'
  API::Docker::Type::BuildCache:
    file: lib/API/Docker/Type/BuildCache.pm
    version: '0.004'
  API::Docker::Type::BuildInfo:
    file: lib/API/Docker/Type/BuildInfo.pm
    version: '0.004'

META.yml  view on Meta::CPAN

    version: '0.004'
  API::Docker::Type::PortBinding:
    file: lib/API/Docker/Type/PortBinding.pm
    version: '0.004'
  API::Docker::Type::PortStatus:
    file: lib/API/Docker/Type/PortStatus.pm
    version: '0.004'
  API::Docker::Type::ProcessConfig:
    file: lib/API/Docker/Type/ProcessConfig.pm
    version: '0.004'
  API::Docker::Type::ProgressDetail:
    file: lib/API/Docker/Type/ProgressDetail.pm
    version: '0.004'
  API::Docker::Type::PushImageInfo:
    file: lib/API/Docker/Type/PushImageInfo.pm
    version: '0.004'
  API::Docker::Type::RegistryServiceConfig:
    file: lib/API/Docker/Type/RegistryServiceConfig.pm
    version: '0.004'
  API::Docker::Type::ResourceObject:
    file: lib/API/Docker/Type/ResourceObject.pm
    version: '0.004'

META.yml  view on Meta::CPAN

    version: '0.004'
  API::Docker::Type::SwarmSpec::TaskDefaults:
    file: lib/API/Docker/Type/SwarmSpec/TaskDefaults.pm
    version: '0.004'
  API::Docker::Type::SwarmSpec::TaskDefaults::LogDriver:
    file: lib/API/Docker/Type/SwarmSpec/TaskDefaults/LogDriver.pm
    version: '0.004'
  API::Docker::Type::SystemInfo:
    file: lib/API/Docker/Type/SystemInfo.pm
    version: '0.004'
  API::Docker::Type::SystemInfo::DefaultAddressPool:
    file: lib/API/Docker/Type/SystemInfo/DefaultAddressPool.pm
    version: '0.004'
  API::Docker::Type::SystemVersion:
    file: lib/API/Docker/Type/SystemVersion.pm
    version: '0.004'
  API::Docker::Type::SystemVersion::Component:
    file: lib/API/Docker/Type/SystemVersion/Component.pm
    version: '0.004'
  API::Docker::Type::SystemVersion::Platform:
    file: lib/API/Docker/Type/SystemVersion/Platform.pm
    version: '0.004'

META.yml  view on Meta::CPAN

    file: lib/API/Docker/Type/VolumeCreateOptions.pm
    version: '0.004'
  API::Docker::Type::VolumeListResponse:
    file: lib/API/Docker/Type/VolumeListResponse.pm
    version: '0.004'
  API::Docker::Volume:
    file: lib/API/Docker/Volume.pm
    version: '0.004'
recommends:
  IO::Socket::SSL: '0'
requires:
  Carp: '0'
  Errno: '0'
  IO::Handle: '0'
  IO::Socket::INET: '0'
  IO::Socket::UNIX: '0'
  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'
  Types::Standard: '0'
  namespace::clean: '0'
  overload: '0'
  perl: '5.014'
resources:
  bugtracker: https://github.com/Getty/p5-api-docker/issues
  homepage: https://github.com/Getty/p5-api-docker
  repository: https://github.com/Getty/p5-api-docker.git
version: '0.004'
x_Dist_Zilla:
  perl:
    version: '5.040001'
  plugins:
    -
      class: Dist::Zilla::Plugin::Git::GatherDir

META.yml  view on Meta::CPAN

      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'
          include_underscores: 0
        Dist::Zilla::Role::MetaProvider::Provider:
          $Dist::Zilla::Role::MetaProvider::Provider::VERSION: '2.002004'
          inherit_missing: 1
          inherit_version: 1
          meta_noindex: 1
        Dist::Zilla::Role::ModuleMetadata:
          Module::Metadata: '1.000038'
          version: '0.006'
      name: '@Author::GETTY/MetaProvides::Package'
      version: '2.004003'

META.yml  view on Meta::CPAN

      name: '@Author::GETTY/MetaProvides::Package/AUTOVIV/:InstallModulesPM'
      version: '6.037'
  zilla:
    class: Dist::Zilla::Dist::Builder
    config:
      is_trial: 0
    version: '6.037'
x_authority: cpan:GETTY
x_generated_by_perl: v5.40.1
x_serialization_backend: 'YAML::Tiny version 1.76'
x_spdx_expression: 'Artistic-1.0-Perl OR GPL-1.0-or-later'

cpanfile  view on Meta::CPAN

requires 'perl', '5.014';

requires 'Carp';
requires 'Errno';
requires 'Import::Into';
requires 'IO::Handle';
requires 'IO::Socket::INET';
requires 'IO::Socket::UNIX';
requires 'JSON::MaybeXS';
requires 'Log::Any';
requires 'MIME::Base64';
requires 'Module::Runtime';
requires 'Moo';
requires 'namespace::clean';
requires 'overload';
requires 'Package::Stash';
requires 'Path::Tiny';
requires 'Scalar::Util';
requires 'Socket';
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 {
  requires 'YAML::XS';
};

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


  # The docker CLI's own rule, read off cli/flags/options.go:
  #   dockerTLSVerify = os.Getenv(client.EnvTLSVerify) != ""
  # Every non-empty value turns TLS on, DOCKER_TLS_VERIFY=0 included. Perl
  # truthiness would read that '0' as off and disagree with the CLI on exactly
  # the value a user is most likely to type for "off", so the test is
  # defined-and-not-empty rather than a boolean one.
  return 0 unless defined $ENV{DOCKER_TLS_VERIFY}
    && $ENV{DOCKER_TLS_VERIFY} ne '';

  # And the CLI ignores TLS on a socket host without saying so
  # (cli/context/docker/load.go, "there's no need to configure TLS for a
  # socket connection"). Ignoring it here is not politeness: BUILD croaks on
  # tls => 1 with a non-tcp:// host, so a host-blind default would make a bare
  # API::Docker->new die on every unix:// machine that exports the variable.
  return $self->host =~ m{^tcp://} ? 1 : 0;
}


has cert_path => (
  is      => 'ro',

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

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

    # Container management -- list/inspect return generated
    # API::Docker::Type::* objects with snake_case accessors, not hashrefs
    my $containers = $docker->containers->list(all => 1);
    for my $container (@$containers) {
        say $container->id;
        say $container->status;
    }

    my $result = $docker->containers->create(
        Image => 'nginx:latest',
        name  => 'my-nginx',
    );
    $docker->containers->start($result->{Id});

    my $inspected = $docker->containers->inspect($result->{Id});
    say $inspected->state->running ? 'running' : 'not running';

    # 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
volumes.

Key features:

=over

=item * Pure Perl implementation with minimal dependencies

=item * Unix socket and TCP transport, the latter in the clear or over TLS
with client certificates (L</tls>, L</cert_path>)

=item * Automatic API version negotiation

=item * A typed object model generated from Docker's own swagger
(L<API::Docker::Type>) -- complete across all seven resources: C<list> and
C<inspect> return these generated classes, not hashrefs; see
L</Architecture> below

=item * Comprehensive logging via L<Log::Any>

=back

=head2 Architecture

The distribution is organized into several layers:

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

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

=item * L<API::Docker::API::Secrets> - Swarm secrets

=item * L<API::Docker::API::Configs> - Swarm configs

=item * L<API::Docker::API::Plugins> - Managed plugins

=back

=item * B<Entity Roles> - the convenience methods of a resource, composed at
load time onto the generated L<API::Docker::Type> classes its endpoints
answer with. There is no separate wrapper object: C<< $docker->images->list >>
hands back real L<API::Docker::Type::ImageSummary> objects that also have
C<< ->remove >>. See L<API::Docker::Role::Entity>.

=over

=item * L<API::Docker::Role::Entity::Container> - composed into
L<API::Docker::Type::ContainerSummary> and
L<API::Docker::Type::ContainerInspectResponse>

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

=over

=item * L<API::Docker::Role::HTTP> - HTTP transport layer

=item * L<API::Docker::Role::RegistryAuth> - X-Registry-Auth / AuthConfig
encoding, shared by Images, Plugins, Distribution and System

=item * L<API::Docker::Role::Filters> - the C<filters> query parameter,
normalised into the one shape the engine reads

=item * L<API::Docker::Role::Using> - C<using>, the resource class clone that
bounds a run of calls

=item * L<API::Docker::Role::Type> - the instance behaviour of every
generated L<API::Docker::Type> class: serialisation both ways and
C<unknown_fields>

=item * L<API::Docker::Role::Entity> - the client reference an entity
delegates through, composed by a resource-specific entity role such as
L<API::Docker::Role::Entity::Container>

=back

=back

=head2 Swarm orchestration is out of scope

C</swarm>, C</nodes>, C</services> and C</tasks> are deliberately absent, and
staying absent is the plan rather than a gap waiting to be closed. That is a

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

    );

The default follows the C<docker> CLI rather than the Go SDK's C<FromEnv>: the
CLI reads the variable as C<< != "" >>, so B<every non-empty value turns TLS
on> -- C<DOCKER_TLS_VERIFY=0> included, and so are C<false>, C<no> and C<off>.
Only unset, or the empty string, is off. That is deliberately not Perl
truthiness: C<'0'> is the value most likely to be typed for "off" and is
precisely where the two rules would part company. An explicit C<< tls => ... >>
passed to the constructor outranks the variable in both directions.

The variable is B<ignored on a socket host>, as the CLI ignores it -- a
C<unix://>, C<npipe://> or C<fd://> connection carries nothing to encrypt.
Without that exception a shell exporting C<DOCKER_TLS_VERIFY> would make a bare
C<< API::Docker->new >> croak on every machine talking to a local socket, since
C<< tls => 1 >> on a non-C<tcp://> host is a construction error (below).

C<DOCKER_TLS_VERIFY> with no L</cert_path> and no C<DOCKER_CERT_PATH> beside it
is TLS against the system trust store, not an error; the CLI asks for no
certificates either, and non-empty there means encrypt B<and> verify.

With C<< tls => 1 >> the transport opens an L<IO::Socket::SSL> connection

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

is not set.

After negotiation, L</api_version> will contain the negotiated version
(e.g., C<1.41>).

C<GET /version> must answer with a JSON object carrying an C<ApiVersion> of
the form C<N.N> -- the value is placed directly into the path of every later
request (C</v1.44/...>). A body that is not such an object croaks, naming the
endpoint and the shape expected: a non-object body, an object with no
C<ApiVersion>, or an C<ApiVersion> that is not two dot-separated numbers. This
replaces three earlier failures on the same path -- a non-object body dying in
C<strict refs>, an object with no C<ApiVersion> silently leaving the client
sending every request unversioned, and a malformed C<ApiVersion> being copied
verbatim into the request path.

Options:

=over

=item * C<read_timeout> - Seconds of silence after which the request gives up
and croaks with an L<API::Docker::Error::Timeout>. Off by default; see

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

attributes of the client, and they are set in two places -- which are two
levels, not two spellings of one thing:

    # the rule, for this client
    my $docker = API::Docker->new(connect_timeout => 2, read_timeout => 30);

    # the exception, for this run of calls
    $docker->containers->using(read_timeout => 5)->list;
    $docker->system->using(read_timeout => 0)->events;

L<API::Docker::Role::Using/using> returns a clone of the resource class
carrying the bounds, and every request made through that clone is given them.
There is deliberately no third way: the individual methods take no timeout
options, so their arguments are the request and nothing else.

Both are off by default, which is the behaviour this distribution has always
had. C<0> means the same as unset -- no bound -- and is how a client-wide
default is turned off for a run of calls: what C<using> carries is read with
C<exists> rather than for truth, so a C<0> reaches the transport instead of
vanishing into "no opinion".

Three things they do not do:

=over

=item * B<C<read_timeout> is an idle timeout, not a deadline.> The clock
measures the time since the last byte arrived, not the time since the request
started. A stream that keeps producing runs as long as it likes; one that
stops producing is cut off. So it bounds a daemon that goes quiet -- it does
not bound a long transfer, and it does not bound a stream that keeps sending
without saying anything, which is what C<< containers->stats >> degrades into
on Docker after the container exits.

=item * B<Neither of them bounds writing the request.> Sending the bytes out
is unbounded on every transport. In practice that matters for one thing: a
large C</build> context or C<< images->load >> archive being written to a
daemon that has stopped reading.

=item * B<Under TLS, C<read_timeout> is not quite an idle timer on the
plaintext.> It is C<SO_RCVTIMEO> on the socket, which bounds each blocking
receive on the underlying connection, and one plaintext read can consume
several of those while a TLS record arrives in pieces -- so a record dribbling
in slowly enough resets the clock without a byte reaching the caller. It still
bounds the hang, which is what it is for. C<connect_timeout> over TLS bounds
the TCP connect and not the handshake that follows it.

=back

An expiry croaks with an L<API::Docker::Error::Timeout> carrying what did
arrive; it never returns a truncated response.
L<API::Docker::Role::HTTP/"Bounding a request that never ends"> and
L<API::Docker::Role::HTTP/"Bounding the connection itself"> have the
per-transport measurements behind all of this.

=head2 Where a bound applies

Every public method of every resource class that reaches the daemon -- all of
them, with no exception for the ones whose arguments are the request body --
makes its request with the bounds in force. That is what the clone buys: the
method builds the request and the resource class it was called on says how
long to wait for it, so there is no list of methods that forward a bound and
no list of methods that cannot.

The requests a method makes on the caller's behalf without being asked are
bounded too, and for the same reason -- they run on the same resource class:

=over

=item * L<API::Docker::API::Containers/attach> asks whether the container is
running before attaching. That check carries the bounds the attach carries.

=item * L<API::Docker::API::Plugins/install> and
L<API::Docker::API::Plugins/upgrade> with C<< accept_privileges => 1 >> fetch
the plugin's privileges first. That fetch carries them too.

=item * L</negotiate_version> runs before the first request of a client with
no L</api_version>, and inherits the bounds of the request that triggered it:
C<< $docker->containers->using(read_timeout => 5)->list >> on a fresh client
bounds the C<GET /version> as well as the list. It is the one place the two
options are still written out per call, because it can be called directly and
is not reached through a resource class:

    $docker->negotiate_version(read_timeout => 5);

=back

The entity classes have no C<using> of their own; a bound for
C<< $container->logs >> goes on the resource class instead, see
L<API::Docker::Role::Using/"What has no clone of its own">.

=head1 CONTAINER ENGINES

This client speaks the Docker Engine HTTP API over a socket. It never shells
out to the C<docker> binary, so any engine serving that API works, whether or
not Docker itself is installed.

=head2 Installing Docker

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

Podman statements have been checked against both 5.4.2 and 5.8.4. Where an
individual statement names no version, it holds for both. A version named at
one particular measurement -- C<"Measured against Podman 5.4.2 (API 1.41):
...">, for instance -- names the engine that measurement was taken I<on>, not
the only engine it is claimed to hold for; read it as provenance, not as a
scope limit. Where a measurement genuinely is version-specific -- superseded
by a later one, or not re-checked on the other version -- the text says so.

=head2 Socket discovery

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.

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


=over

=item * L<API::Docker::Role::HTTP> - HTTP transport implementation

=item * L<API::Docker::Role::RegistryAuth> - X-Registry-Auth / AuthConfig
encoding

=item * L<API::Docker::Role::Filters> - the C<filters> query parameter

=item * L<API::Docker::Role::Using> - C<using>, the resource class clone that
bounds a run of calls

=item * L<API::Docker::Type> - the DSL and attribute registry behind the
generated C<API::Docker::Type::*> classes

=item * L<API::Docker::Role::Type> - the generated classes' own behaviour

=item * L<API::Docker::Role::Entity> - the client reference an entity
delegates through

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

=item * L<API::Docker::Error::HTTP> - Raised for a status of 400 or above,
carries the status code

=item * L<API::Docker::Error::Stream> - Raised for a failure reported inside a
200 event stream

=item * L<API::Docker::Error::Timeout> - Raised when a request given a
C<read_timeout> or C<connect_timeout> runs out of it

=item * L<API::Docker::Error::Truncated> - Raised when the daemon closed
before the response it announced was complete

=back

=head1 SUPPORT

=head2 Issues

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

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

use namespace::clean;


has client => (
  is       => 'ro',
  required => 1,
  weak_ref => 1,
);


# The class is the caller's argument, as it is on the resource classes whose
# list and inspect really are two definitions -- here both are the swagger's
# one `Config`, and passing it keeps the seam in the same place.
#
# from_data, not new: this is a daemon response, and the two entry points of
# API::Docker::Role::Type read it differently. from_data takes the swagger's
# wire names and nothing else, so a key it has not heard of keeps its own
# spelling instead of being read as the Perl name of one it has, and a value
# that disagrees with the swagger costs its own field rather than the whole
# response. `client` is ours rather than the engine's, so it goes beside the
# data instead of into it.
sub _wrap {
  my ($self, $class, $data) = @_;
  return $class->from_data($data, client => $self->client);
}

sub _wrap_list {
  my ($self, $class, $list) = @_;
  return [ map { $self->_wrap($class, $_) } @$list ];
}

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

    $self->client->get("/configs/$id",
      %{ $self->_request_options },
    ));
}


sub update {
  my ($self, $id, $version, %spec) = @_;
  croak __PACKAGE__ . '->update config ID or name required'
    unless defined $id && length $id;
  croak __PACKAGE__ . '->update requires the current version as its second '
    . 'argument: the Version.Index from inspect($id), which the daemon uses '
    . 'as an optimistic-concurrency token and will not accept the update '
    . 'without'
    unless defined $version;
  croak __PACKAGE__ . '->update version must be the numeric Version.Index '
    . "from inspect(\$id), got '$version'"
    unless $version =~ /\A[0-9]+\z/;
  $spec{Data} = $self->_encode_data('update', $spec{Data})
    if defined $spec{Data};
  return $self->client->post("/configs/$id/update", \%spec,

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

one behavioural difference is that a config's value can be read back --
L</inspect> returns it in C<< $config->spec->data >>, and
L<API::Docker::Role::Entity::Config/decoded_data> decodes it, where a secret
returns no payload at all. Which is the whole point of the split: put configuration in
a config, and anything you would mind seeing in a C<docker config inspect> in
a secret.

=head2 Data is raw bytes on the way out, base64 on the way back

The wire field C<Data> carries base64. B<L</create> and L</update> encode it
for you> -- pass them raw bytes, and do not pre-encode, or the daemon stores
your base64 text as the config's content.

Doing it here is not a convenience, it is a guard. The daemon does not
validate what it decodes: measured against Podman 5.4.2's C</secrets>, which
takes the identical field, a C<Data> of the plain text C<"hello there!"> was
accepted with B<HTTP 200> and stored three bytes of garbage -- Go's decoder
took the leading C<"hell">, stopped at the space, and said nothing. A caller
left to encode their own payload can corrupt the value and be told it worked.

The alphabet is B<standard> base64 with padding (C<+> and C</>), unwrapped,

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

explicit call on the entity:

    my $text = $config->decoded_data;

The asymmetry follows one rule: this class encodes where getting it wrong is
silent, and rewrites nothing where getting it wrong is visible. An unencoded
C<Data> going out is stored as garbage with a 200; a base64 string coming back
is obvious the moment you look at it. So the decode is offered where it costs
nothing -- L<API::Docker::Role::Entity::Config/decoded_data> derives the bytes
on demand and leaves the spec verbatim -- rather than by replacing a field of a daemon
response, which nothing in this distribution does.

To send an already-encoded value verbatim, bypass this class:

    $docker->post('/configs/create', { Name => 'my-config', Data => $b64 });

=head2 update takes the current version, and it is mandatory

C<POST /configs/{id}/update> carries a C<version> query parameter and the
daemon rejects the request without it. The value is the C<Version.Index> of
the config as it stands right now, which is what L</inspect> returns:

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

and the daemon refuses the write rather than silently overwriting that change.
Read it immediately before the update, and again before a retry.

This class makes it the second positional argument and croaks when it is
missing or not numeric, so the mistake surfaces here instead of one round trip
later. L<API::Docker::Role::Entity::Config/update> supplies it from the
entity's own C<< ->version->index >> instead, which is the same value read at
the same moment.

The Engine API reference states that only C<Labels> may actually change: the
rest of the spec must go back unchanged from what C<inspect> returned. Hence
C<< %spec = %{ $config->spec->TO_JSON } >> in the SYNOPSIS -- C<TO_JSON>
renders the spec object back into the daemon's own spelling, and the whole
spec goes back with the one key edited. Note that a spec from C<inspect>
carries C<Data> already base64-encoded, so passing it straight to L</update>
would encode it a second time; delete the key, or pass
L<API::Docker::Role::Entity::Config/decoded_data> in its place, before
sending it back.

=head2 Swarm, and Podman

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

has never run it is the ordinary case, not an edge one.

B<Podman does not serve C</configs>,> though what it answers for "not served"
differs by path. Measured against the rootless socket on Podman 5.8.4 (API
1.44): C<GET /configs> -- the collection listing -- still answers B<404> with
the plain-text body C<Not Found>, not a JSON error, so the croak from this
client reads C<Docker API error (404): Not Found>. Every other path under it
-- C<GET /configs/{id}>, C<POST /configs/create>, C<DELETE /configs/{id}> and
C<POST /configs/{id}/update> -- answers B<503> instead, with a JSON body
naming the route it refuses, e.g. C<< {"cause":"Podman does not support
service: /v1.44/configs/xyz","message":"...","response":503} >>.

An earlier pass measured every path here as a flat 404 against Podman 5.4.2
(API 1.41). That measurement is not reproducible on this machine any more --
5.4.2 is gone from it -- so whether 5.8.4 actually changed this or the
original pass only ever exercised the collection endpoint is not something
this distribution can decide from here; it is recorded as what 5.8.4 answers,
not as a change from 5.4.2. Either way, the split this section used to draw
between the two engines -- Docker's 503 "not a swarm manager" against a flat
Podman 404 -- no longer holds cleanly: most C</configs> paths on Podman answer
503 too now, just with a different body and for a different reason.

There is no Podman-side equivalent to fall back on and no socket setting that
enables it. This remains the one class in the distribution that cannot be
exercised for real against the engine the rest of it is tested on --
L<API::Docker::API::Secrets>, the same five endpoints, is served by Podman
from its own local secret store.

=head2 client

Reference to L<API::Docker> client. Weak reference to avoid circular dependencies.

=head2 list

    my $configs = $configs->list;

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

=back

=head2 create

    my $created = $configs->create(
        Name   => 'my-config',
        Data   => "listen 8080;\n",
        Labels => { app => 'web' },
    );

Create a config. Returns the daemon's response, a HashRef carrying C<ID> --
not an L<API::Docker::Type::Config>, because C<ID> is all the daemon answers
with and an entity built from it would carry no C<Spec> and no C<Version>. Call
L</inspect> on that C<ID> for the object.

Options:

=over

=item * C<Name> - Required. The config's name.

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

  required => 1,
  weak_ref => 1,
);


# The class is the caller's argument rather than a constant of this module:
# `list` and `inspect` are two definitions in the swagger and therefore two
# generated classes. Both carry the same convenience methods, composed by
# API::Docker::Role::Entity::Container -- see "The two container shapes".
#
# from_data, not new: this is a daemon response, and the two entry points of
# API::Docker::Role::Type read it differently. from_data takes the swagger's
# wire names and nothing else, so a key it has not heard of keeps its own
# spelling instead of being read as the Perl name of one it has, and a value
# that disagrees with the swagger costs its own field rather than the whole
# response. `client` is ours rather than the engine's, so it goes beside the
# data instead of into it.
sub _wrap {
  my ($self, $class, $data) = @_;
  return $class->from_data($data, client => $self->client);
}

sub _wrap_list {
  my ($self, $class, $list) = @_;
  return [ map { $self->_wrap($class, $_) } @$list ];
}

# A state-change endpoint answers 204 when it changed something and 304 when
# the container was already in the state asked for. Neither carries a body, so
# the return value of the request is undef either way and the two are
# indistinguishable from it. The status comes out through the `response`
# out-parameter (see API::Docker::Role::HTTP/"Reading the status line and the
# response headers") and becomes the documented 1/0.
sub _state_change {
  my ($self, $path, %opts) = @_;
  my %response;
  $self->client->post($path, undef, %opts, response => \%response);
  return 0 if defined $response{status} && $response{status} == 304;
  return 1;
}

sub list {
  my ($self, %opts) = @_;
  my %params;
  $params{all}     = $opts{all} ? 1 : 0  if defined $opts{all};
  $params{limit}   = $opts{limit}        if defined $opts{limit};
  $params{size}    = $opts{size} ? 1 : 0 if defined $opts{size};
  $params{filters} = $self->_normalise_filters($opts{filters})
    if defined $opts{filters};
  my $result = $self->client->get('/containers/json',
    params => \%params,
    %{ $self->_request_options },
  );
  return $self->_wrap_list('API::Docker::Type::ContainerSummary', $result // []);
}


# The booleans of the container create body, from spec/v1.51.yaml: the
# ContainerConfig flags at the top level, and the HostConfig flags in the
# nested `HostConfig` object (its own plus the ones it inherits from
# Resources). The engine rejects a number for any of them, so 1/0 is
# normalised to a JSON boolean on the way out; a caller may still pass 1/0 or a
# JSON boolean and it goes out correctly either way.
my @CONTAINER_CONFIG_BOOLS = qw(

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

  my %params;
  $params{name} = delete $config{name} if defined $config{name};
  $self->_json_bools(\%config, @CONTAINER_CONFIG_BOOLS);
  # Copy the nested HostConfig before touching it -- _json_bools mutates, and
  # the sub-object is still the caller's until this copy replaces it.
  if (ref $config{HostConfig} eq 'HASH') {
    my %host_config = %{ $config{HostConfig} };
    $self->_json_bools(\%host_config, @HOST_CONFIG_BOOLS);
    $config{HostConfig} = \%host_config;
  }
  my $result = $self->client->post('/containers/create', \%config, params => \%params);
  return $result;
}


sub inspect {
  my ($self, $id) = @_;
  croak "Container ID required" unless $id;
  my $result = $self->client->get("/containers/$id/json",
    %{ $self->_request_options },
  );
  return $self->_wrap('API::Docker::Type::ContainerInspectResponse', $result);
}


sub start {
  my ($self, $id) = @_;
  croak "Container ID required" unless $id;
  return $self->_state_change("/containers/$id/start",
    %{ $self->_request_options },
  );
}

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

  my %params;
  $params{t}      = $opts{timeout} if defined $opts{timeout};
  $params{signal} = $opts{signal}  if defined $opts{signal};
  return $self->_state_change("/containers/$id/stop",
    params => \%params,
    %{ $self->_request_options },
  );
}


sub restart {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  my %params;
  $params{t} = $opts{timeout} if defined $opts{timeout};
  return $self->_state_change("/containers/$id/restart",
    params => \%params,
    %{ $self->_request_options },
  );
}


sub kill {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  my %params;

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

#
# It fails open on anything it does not recognise: a State it cannot read is
# not evidence that the container is stopped, and a guard that is unsure must
# not be the thing that breaks a working call.
sub _assert_container_running {
  my ($self, $id) = @_;

  # A State the model could not use is one more shape the check does not
  # recognise, and it arrives as one: the generated classes type their fields
  # from the swagger, and a State that is not the object
  # ContainerInspectResponse declares -- the bare status string of the list
  # shape, say -- leaves ->state unset and keeps the raw value in
  # unknown_fields rather than taking the response down with it. So there is
  # nothing to catch here; an error that does reach this line, the daemon's
  # own 404 included, is the caller's and goes up.
  my $inspected = $self->inspect($id);

  # An API::Docker::Type::ContainerState, or undef where the daemon sent no
  # State at all -- which is the "does not recognise" case above, not a stopped
  # container.
  my $state = $inspected->state;
  return unless blessed($state) && defined $state->running;
  return if $state->running;

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

  $params{ps_args} = $opts{ps_args} if defined $opts{ps_args};
  return $self->client->get("/containers/$id/top",
    params => \%params,
    %{ $self->_request_options },
  );
}


# The Podman compatibility path, and deliberately only that. Podman answers
# GET /containers/{id}/stats for a container that is not running with an error
# object inside a response it has already committed to 200 --
# {"cause":"container is stopped","message":"container is stopped",
# "response":500}, chunked, for the one-shot call and for stream => 1 alike
# (measured on Podman 5.4.2, API 1.41). Neither guard in the transport sees
# it: the >= 400 croak reads the status line, which says 200, and the stream
# check triggers on errorDetail, which this object does not carry. Docker
# 29.7.2 (API 1.55) answers the same call with a real, zero-filled reading and
# never produces this shape at all -- so this belongs here, next to the one
# endpoint and the one engine it was measured on, and not in Role::HTTP, where
# it would be a heuristic on daemon prose sitting under all twelve modules.
#
# All four clauses have to hold. The narrowness is the point, not an accident:
#
#   1. the status was 2xx. True by construction on both paths this guards:
#      _request croaks before returning for >= 400, and
#      _read_streaming_response reads such a body whole rather than handing it
#      to a callback, so nothing that failed the status line reaches here
#   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;
  return unless defined $response && $response =~ /\A[0-9]+\z/;
  return unless $response >= 400;

  return $value;
}

# API::Docker::Error::HTTP rather than ::Stream: the one-shot call is not a
# stream at all, so "Docker API stream error" would be the wrong sentence for
# it and ->events would be a fabricated list. What the caller wants instead is
# exactly what this class carries -- ->status for the code Podman named, and
# ->data for the object, whose `cause` key that attribute's own POD already
# points at. Two of its attributes are left at their defaults on this path, on

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

  $reason = $error->{cause}    unless defined $reason && length $reason;
  $reason = 'no message given' unless defined $reason && length $reason;
  # Carp appends no location to a message that already ends in a newline.
  $reason =~ s/\s+\z//;

  # The object goes into a variable first: `croak CLASS->new(...)` is indirect
  # object syntax and parses as CLASS->croak(new(...)). Carp hands a reference
  # straight back rather than decorating it, so the location is captured by
  # hand, naming the frame a croak of a plain string would have named.
  my $err = API::Docker::Error::HTTP->new(
    message  => 'Docker API error (' . $error->{response} . '): ' . $reason
      . ' -- reported inside a 200 response to ' . $endpoint,
    location => shortmess(''),
    status   => $error->{response},
    data     => $error,
  );
  croak $err;
}

sub stats {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  my $stream = $opts{stream} ? 1 : 0;
  my %params = ( stream => $stream );

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

  my $on_event = $opts{on_event};
  if (exists $opts{on_event} && ref $on_event eq 'CODE') {
    my $cb = $on_event;
    $on_event = sub {
      my ($reading, $stop) = @_;
      $self->_assert_no_podman_error($endpoint, $reading);
      return $cb->($reading, $stop);
    };
  }

  my $result = $self->client->get("/containers/$id/stats",
    params => \%params,
    %{ $self->_request_options },
    exists $opts{on_event} ? ( on_event => $on_event )
      : $stream            ? ( ndjson   => 1 )
      : (),
  );

  # A HashRef for the one-shot call and an ArrayRef of readings for
  # stream => 1 without a callback -- both are the buffered body and both can
  # be that error object. With a callback the return value is the summary
  # HashRef, which carries none of the three keys and passes untouched.
  $self->_assert_no_podman_error($endpoint, $_)
    for ref $result eq 'ARRAY' ? @$result : ($result);

  return $result;
}


sub changes {
  my ($self, $id) = @_;
  croak "Container ID required" unless $id;
  my $result = $self->client->get("/containers/$id/changes",
    %{ $self->_request_options },
  );
  # A container with nothing changed answers with a JSON null, which the
  # transport decodes to undef. Normalised here so the return is always
  # something a caller can iterate.
  return ref $result eq 'ARRAY' ? $result : [];
}


sub export {
  my ($self, $id) = @_;
  croak "Container ID required" unless $id;
  return $self->client->get("/containers/$id/export",
    raw => 1,
    %{ $self->_request_options },
  );
}


sub resize {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  my %params;
  $params{h} = $opts{h} if defined $opts{h};
  $params{w} = $opts{w} if defined $opts{w};
  return $self->client->post("/containers/$id/resize", undef,
    params => \%params,
    %{ $self->_request_options },
  );
}


sub wait {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  my %params;

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

my @UPDATE_BOOLS = qw( Init OomKillDisable );

sub update {
  my ($self, $id, %config) = @_;
  croak "Container ID required" unless $id;
  $self->_json_bools(\%config, @UPDATE_BOOLS);
  return $self->client->post("/containers/$id/update", \%config);
}


# The engine reports what a path is in a response header rather than a body,
# so both GET and HEAD carry it and only HEAD has nothing else to say. The
# header is base64-encoded JSON; handing the caller the base64 would make
# every one of them write this.
sub _decode_path_stat {
  my ($self, $response) = @_;

  my $header = $response->{headers}{'x-docker-container-path-stat'};
  return undef unless defined $header && length $header;

  # Docker encodes this one with Go's base64.StdEncoding -- unlike
  # X-Registry-Auth, which is URLEncoding. Decoded tolerantly rather than
  # strictly: translating the two URL-safe characters first costs nothing and
  # means an engine that reached for the other alphabet is still read.
  $header =~ tr{-_}{+/};
  my $stat = eval { decode_json(decode_base64($header)) };
  croak "Cannot decode X-Docker-Container-Path-Stat header: $@"
    unless ref $stat eq 'HASH';

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

  return $stat;
}

sub get_archive {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  croak "Path required" unless defined $opts{path} && length $opts{path};
  croak "The stat option must be a HashRef"
    if exists $opts{stat} && ref $opts{stat} ne 'HASH';

  my %response;
  my $tar = $self->client->get("/containers/$id/archive",
    params   => { path => $opts{path} },
    raw      => 1,
    response => \%response,
    %{ $self->_request_options },
  );

  if (my $out = $opts{stat}) {
    %$out = %{ $self->_decode_path_stat(\%response) // {} };
  }

  return $tar;
}


sub put_archive {
  my ($self, $id, $tar, %opts) = @_;
  croak "Container ID required" unless $id;
  croak "Path required" unless defined $opts{path} && length $opts{path};

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

    %{ $self->_request_options },
  );
}


sub stat_archive {
  my ($self, $id, %opts) = @_;
  croak "Container ID required" unless $id;
  croak "Path required" unless defined $opts{path} && length $opts{path};

  my %response;
  $self->client->head("/containers/$id/archive",
    params   => { path => $opts{path} },
    response => \%response,
    %{ $self->_request_options },
  );

  return $self->_decode_path_stat(\%response);
}


sub prune {
  my ($self, %opts) = @_;
  my %params;
  $params{filters} = $self->_normalise_filters($opts{filters})
    if defined $opts{filters};
  return $self->client->post('/containers/prune', undef,
    params => \%params,

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

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

    # 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
    $docker->containers->stop($result->{Id}, timeout => 10);
    $docker->containers->remove($result->{Id});

    # View logs (ArrayRef of { stream => 'stdout'|'stderr'|'raw', data => ... })
    my $frames = $docker->containers->logs($result->{Id}, tail => 100);
    my $text = join '', map { $_->{data} } @$frames;

    # Attach one-way: replays the same frames and returns (stream => 0 by
    # default -- stream => 1 on a stopped container never returns). On Podman,
    # attaching to a container that has ALREADY EXITED destroys its exit
    # status; use logs() for that case, see attach()
    my $attached = $docker->containers->attach($result->{Id});

    # Copy a file out, and a tar archive in (what docker cp is built on)
    my $tar = $docker->containers->get_archive($result->{Id},
        path => '/etc/hostname');
    $docker->containers->put_archive($result->{Id}, $tar, path => '/tmp');

=head1 DESCRIPTION

This module provides methods for managing Docker containers including creation,
lifecycle operations (start, stop, restart), inspection, logs, and more.

C<list> and C<inspect> return generated L<API::Docker::Type> objects carrying
the convenience methods of L<API::Docker::Role::Entity::Container>, so
C<< $container->start >> and C<< $container->logs >> work on either. Which
class each returns, and where the two disagree, is below.

Accessed via C<< $docker->containers >>, or through
L<API::Docker::Role::Using/using> for a run of calls that needs its own
transport bound: C<< $docker->containers->using(read_timeout => 5) >>.

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

=back

They overlap but do not line up, and the field names are the swagger's own
spelling in snake_case (C<Id> is C<< ->id >>, C<SizeRootFs> is
C<< ->size_root_fs >>). The differences worth knowing before reading a value
off the wrong one:

=over

=item * C<< ->image >> is the name the container was created from on a
summary (C<nginx:latest>) and the resolved C<sha256:> digest on an inspect.
A summary reports that digest separately as C<< ->image_id >>; an inspect
has no such field.

=item * C<< ->created >> is an integer Unix epoch on a summary and an
RFC 3339 string on an inspect. Same field name, two types -- C<Int> and
C<Str> in the model, which is the swagger's own answer, not a normalisation
this client applies.

=item * C<< ->state >> is the status string (C<running>, C<exited>) on a
summary and an L<API::Docker::Type::ContainerState> object on an inspect,

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


=item * C<< ->labels >> and C<< ->ports >> are top-level on a summary only.
An inspect carries the labels under C<< ->config->labels >> and the port
bindings under C<< ->network_settings->ports >>, which is a map of container
port to host bindings rather than the summary's ArrayRef of
L<API::Docker::Type::Port>.

=item * C<< ->names >> (an ArrayRef, each with a leading C</>) is the
summary's; C<< ->name >> (one string, also with the C</>) is the inspect's.

=item * C<< ->config >>, C<< ->restart_count >>, C<< ->driver >>,
C<< ->platform >>, C<< ->graph_driver >>, C<< ->exec_ids >> and the
C<*_path> fields come from an inspect only.

=item * C<< ->host_config >> and C<< ->network_settings >> exist on both and
are B<different classes>: the summary's are
L<API::Docker::Type::ContainerSummary::HostConfig> (C<NetworkMode> and
C<Annotations>, nothing else) and
L<API::Docker::Type::ContainerSummary::NetworkSettings> (C<Networks> alone),
against the full L<API::Docker::Type::HostConfig> and
L<API::Docker::Type::NetworkSettings> on an inspect.

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


=back

A field neither class knows -- a newer engine than the C<spec/v1.51.yaml>
this model was generated from -- is not dropped: it stays under the name it
arrived with in L<API::Docker::Role::Type/unknown_fields> and goes back out
unchanged.

A field whose B<value> disagrees with the swagger is kept the same way and
costs only itself. An engine answering C<State> with the bare status string
rather than the object the spec declares leaves C<< ->state >> C<undef> while
every other field of the inspect reads normally; the raw value is in
C<unknown_fields> under C<State>, and
L<API::Docker::Role::Type/rejected_fields> names it, so "not sent" and "sent
and not usable" are two different answers.

=head2 client

Reference to L<API::Docker> client. Weak reference to avoid circular dependencies.

=head2 list

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

List containers. Returns an ArrayRef of
L<API::Docker::Type::ContainerSummary> objects -- see L</"The two container
shapes"> for what a summary carries and L</inspect> does not.

Options:

=over

=item * C<all> - Show all containers (default shows just running)

=item * C<limit> - Limit results to N most recently created containers

=item * C<size> - Include size information

=item * C<filters> - HashRef of filter name to ArrayRef of string values, e.g.
C<< { status => ['running'], label => ['stage=build'] } >>. Shape-checked and
normalised by L<API::Docker::Role::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).

Common config keys: C<Image>, C<Cmd>, C<Env>, C<ExposedPorts>, C<HostConfig>.

Boolean flags may be given as a Perl C<1>/C<0> or as a JSON boolean; either
goes out as a real JSON C<true>/C<false>, which the engine's body type-check
requires. This applies to the top-level flags (C<Tty>, C<OpenStdin>,
C<AttachStdin>, C<AttachStdout>, C<AttachStderr>, C<StdinOnce>,
C<NetworkDisabled>, C<ArgsEscaped>) and to the C<HostConfig> flags
(C<Privileged>, C<PublishAllPorts>, C<ReadonlyRootfs>, C<AutoRemove>, C<Init>,
C<OomKillDisable>).

=head2 inspect

    my $container = $containers->inspect($id);

Get detailed information about a container. Returns an

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

    $containers->start($id);

    say 'was already running' unless $containers->start($id);

Start a container. Returns 1 when the container was started and 0 when it was
already running: the engine answers a state change with 204 and a no-op with
B<304 Not Modified>, and both carry an empty body, so until now both came back
as C<undef>.

The no-op keeps the falsy value this method always returned -- 0 where it used
to be C<undef> -- so a caller that ignores the return or tests it for falseness
is unaffected; only a caller testing C<defined> sees a difference. A failure is
still a croak, never a 0.

=head2 stop

    $containers->stop($id, timeout => 10);

    say 'was already stopped' unless $containers->stop($id);

Stop a container. Returns 1 when the container was stopped and 0 when it was

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

Options:

=over

=item * C<timeout> - Seconds to wait before killing (default 10)

=item * C<signal> - Signal to send (default SIGTERM)

=back

=head2 restart

    $containers->restart($id, timeout => 10);

Restart a container. Optionally specify C<timeout> in seconds.

Reports 1/0 like L</start>, but a restart has no no-op state to report: the
engine restarts a stopped container as readily as a running one. Measured
against Podman 5.4.2 (API 1.41) it answers 204 in both cases, and the Docker
Engine API documents no 304 for this endpoint either, so 0 is not expected
here. The value is reported the same way rather than specially, so an engine
that does answer 304 is not silently read as a change.

=head2 kill

    $containers->kill($id, signal => 'SIGKILL');

    $containers->kill($id, signal => 'SIGUSR1');   # not necessarily a stop

Send a signal to a container. Default signal is C<SIGKILL>.

Returns nothing -- unlike L</start>, L</stop>, L</restart>, L</pause> and
L</unpause>, which report 1/0 through their shared C<_state_change> path.
Those methods have two outcomes worth telling apart: a change (204) and a
no-op (304, where the engine sends one). C<kill> has only one, because
C<_request> croaks on any C<< status >= 400 >>, so the B<409> a non-running
container gets back never reaches this method's C<return>. A boolean with a
single possible value is not worth adding.

More importantly, B<204 does not mean the container stopped.> Measured on
B<both> engines -- Docker 29.7.2 (API 1.55) and rootless Podman 5.4.2 (API
1.41), same machine, identical behavior: sending a signal the container traps
or ignores -- C<< signal => 'SIGUSR1' >> against a process with a handler
installed for it -- is delivered, the container keeps running, the handler's
output turns up in L</logs>, and the engine still answers 204 exactly as it
does for a signal that does end the process. A caller that needs to know
whether the container is still running after a C<kill> has to ask
L</inspect>; that is also why this returns nothing rather than a plain C<1>
-- a 1 here would claim a state change that a trapped signal never made.

B<A paused container is where the two engines part.> Both answer 204 to
C<< signal => 'SIGUSR1' >> against a paused container, and then:

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

      { stream => 'stderr', data => "ERR\n" } ]

A container created without a TTY multiplexes stdout and stderr into a single
framed stream, and this method demultiplexes it -- without that, the 8-byte
frame headers end up in the caller's log text. A container created B<with> a
TTY writes to one pty and the engine sends no frame headers, so its whole
output arrives as a single frame with C<< stream => 'raw' >>: with a TTY there
is no stdout/stderr distinction left to report. C<stream> is always a plain
string, so C<< $_->{stream} eq 'stderr' >> is safe on any frame.

Framing is detected from the response bytes, because the engine's
C<Content-Type> cannot be trusted for it -- see
L<API::Docker::Role::HTTP/"Detecting a framed stream"> for the rule and its one
failure mode.

Options:

=over

=item * C<follow> - Keep the connection open and send new output as the
container writes it. Only usable with C<on_frame>; see below

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

        },
    );

    $summary;   # { delivered => 4, stopped => 1 }

With a callback the return value is that summary HashRef, not the frames:
C<delivered> is how many went to the callback, C<stopped> is 1 when the
callback ended the stream and 0 when the daemon did. Nothing is accumulated --
a followed log is unbounded by construction, and the callback has been handed
every frame already. See
L<API::Docker::Role::HTTP/"Streaming a response as it arrives">.

B<Without a callback, C<< follow => 1 >> blocks> until the container exits or
the daemon closes the connection, because the whole response is read before
anything is parsed. Use it with C<on_frame> or not at all.

C<tty> means something stronger on this path. The buffered path decides
framing by walking the whole body (see
L<API::Docker::Role::HTTP/"Detecting a framed stream">), which is exactly what
a streamed one does not have; so with C<on_frame> the flag is a promise about
the container rather than a hint, and an undeclared stream that turns out not
to be framed croaks instead of being handed back raw. Read C<Config.Tty> from
C<< $containers->inspect($id) >> and pass it. The frame shape is the same
either way -- a TTY stream arrives as a series of C<< stream => 'raw' >>

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

B<Attaching to a container that has already exited loses its exit code on
Podman, and nothing reports it.> Measured before and after a single attach
against one container that exited with 4, on Podman 5.4.2 (API 1.41) and
Docker 29.7.2 (API 1.55), same machine:

    PODMAN  before   inspect: exited 4    wait: { StatusCode => 4 }
    PODMAN  after    inspect: created 0   wait: { StatusCode => -1 }
    DOCKER  before   inspect: exited 4    wait: { StatusCode => 4 }
    DOCKER  after    inspect: exited 4    wait: { StatusCode => 4 }

Podman reverts the container to C<created>, resets C<ExitCode> to 0, and
answers a later L</wait> with the sentinel C<-1> inside a 200. The real value
is gone from the engine; there is nothing to read it back from. B<All three
variants do it> -- C<< stream => 1 >> with and without C<logs>, and the
C<< stream => 0, logs => 1 >> this method now sends by default, which is the
one that returns cleanly in milliseconds. It is the call that does it, not
the hang.

L</logs> does B<not> do it, and Docker does not do it at all. So on Podman
the sequence "attach to collect the output, then L</wait> for the exit code"
cannot work: read the output with L</logs> instead, or take the exit code

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

running and B<croaks instead of attaching> when it is not:

    API::Docker::API::Containers->attach refused: container x is exited. ...

C<< require_running => 0 >> turns that off and attaches anyway; the check is
then not performed at all, so opting out costs no round trip either.

With the guard off, the hang this section exists to explain becomes reachable:
attaching to a container that has already exited never returns on rootless
Podman (measured 5.4.2, still true on 5.8.4, API 1.44). A bound on the
resource class is how to survive that call instead of blocking on it forever:

    $docker->containers->using(read_timeout => 2)
      ->attach($id, require_running => 0);

See L<API::Docker::Role::Using> and
L<API::Docker::Role::HTTP/"Bounding a request that never ends">.

B<What the check does not do is close the race.> It is a pre-flight question,
and the container can stop between the answer and the attach arriving -- in
which case the exit status is destroyed exactly as it would have been without

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


The engine has two attach protocols behind one path. Sent with
C<Upgrade: tcp> and C<Connection: Upgrade>, C<< POST /containers/{id}/attach >>
answers B<101 Switching Protocols> and hands over a bidirectional connection:
that is what C<docker attach> uses, and it is what lets a caller type into the
container's stdin. Sent B<without> those headers -- which is what this method
does -- the engine answers B<200> and streams the container's output one way,
in exactly the frames L</logs> returns.

This method implements the second one only, because the transport here buffers
a whole response before returning it (see
L<API::Docker::Role::HTTP/"What the transport does not do">). Two consequences
a caller has to plan around:

=over

=item * B<You cannot write to the container.> C<< stdin => 1 >> is passed to
the engine, but this client sends no bytes after the request headers and then
reads until the daemon closes, so there is no moment at which input could be
supplied. Use L<API::Docker::API::Exec> to run something interactive-shaped,
or wait for the upgraded variant

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

returns.

B<This is a change.> Up to and including the previous release C<stream>
defaulted to 1, so the same call opened an open-ended subscription; a caller
who wants the live stream now has to ask for it with C<< stream => 1 >>.

The reason is that the subscription has exactly one terminator: the container
ending. C<stream> means I<stream attached streams from the time the request
was made onwards>, so on a container that has B<already exited> that
terminator is in the past and will not happen again. attach also hijacks the
connection -- the response carries no C<Content-Length> and no chunked
terminator -- so HTTP framing cannot signal the end either. The transport
reads until EOF, there is no EOF, and the call hangs. C<on_frame> does not
help: nothing will ever call C<< $stop->() >>.

Measured on Podman 5.4.2 (API 1.41), all four against one and the same
container:

=over

=item * C<?logs=1&stdout=1&stderr=1&stream=0>, exited container -- 200, the

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


=item * C<ps_args> - Arguments passed to C<ps> inside the container, e.g.
C<'aux'>. Omitted, the engine uses its own default

=back

=head2 stats

    my $stats = $containers->stats($id);

Get container resource usage statistics (CPU, memory, network, I/O). With no
options this is the one-shot call it always was: a single reading, returned as
a HashRef.

For a container that is B<not running> the two engines answer differently and
neither says so in the status line -- on Podman this method croaks, on Docker
it returns zeros that look like a reading. See
L</"A container that is not running: a croak on Podman, zeros on Docker">
before calling it on a container that may have stopped.

=head2 Following the stats

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

            printf "%.1f MB\n", $stats->{memory_stats}{usage} / 1024 ** 2;
            $stop->() if ++$seen >= 5;
        },
    );

    $summary;   # { delivered => 5, stopped => 1 }

With a callback the return value is that summary HashRef, not the readings:
C<delivered> is how many went to the callback, C<stopped> is 1 when the
callback ended the stream and 0 when the daemon did. Nothing is accumulated.
See L<API::Docker::Role::HTTP/"Streaming a response as it arrives">.

B<Without a callback, C<< stream => 1 >> blocks> until the container stops or
the daemon closes the connection: the whole response is read before anything
is parsed. It then returns an ArrayRef of readings rather than the single
HashRef the one-shot call returns, which is the other reason to pass a
callback instead.

Unlike L<API::Docker::API::System/events>, this does not turn the stream's
error check off. C</events> is a feed of engine records, where an
C<errorDetail> object would still be data; a stats stream is one container's
readings, and the transport's default is to croak on a failure reported inside
a 200 body (L<API::Docker::Role::HTTP/"Failure inside a 200 response">).

The default is kept on a measurement rather than on that analogy. Against
Podman 5.4.2 (API 1.41) every object a running container's stream carries is
a complete reading -- C<read>, C<cpu_stats>, C<memory_stats>, C<networks> and
the rest -- and killing the container and then removing it while the stream
was open ended the stream on a whole reading, with nothing appended after it.
No C<errorDetail> was sent in either case, and the Engine API reference names
that key for C</build>, C</images/create> and C</images/{name}/push> alone.
So the check has no legitimate reading here it could turn into a croak, and
that -- not an unexamined default -- is why it stays on.

Options:

=over

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


Neither engine answers this with an HTTP error, and they agree on nothing
else. Measured on Podman 5.4.2 (API 1.41) and Docker 29.7.2 (API 1.55), same
machine, one container that had exited.

B<Podman sends an error object inside the 200> -- chunked, for the one-shot
call and for C<< stream => 1 >> alike:

    { cause    => 'container is stopped',
      message  => 'container is stopped',
      response => 500 }

B<This method now croaks on it>, with an L<API::Docker::Error::HTTP> carrying
C<< ->status == 500 >> -- the code Podman named in C<response> -- and the
object itself as C<< ->data >>, so C<< $err->data->{cause} >> stays readable.
That is a B<change>: up to and including the previous release the one-shot
call returned this HashRef where a reading was expected, and an C<on_event>
callback was handed it as though it were one. The check runs on the buffered
return value and in front of the callback alike, and only on that exact
shape -- a HashRef inside a 2xx carrying all three of C<cause>, C<message>
and C<response> lower-cased, with C<response> reading as an integer >= 400.
Nothing else in this distribution, in its fixtures, or in a sweep of fifteen
read endpoints per engine carries even one of those keys lower-cased at the
top level. The rule is deliberately B<not> case-insensitive: a I<successful>
L</wait> answers with a top-level C<Error> key, and a looser rule would
report every one of those as a failure.

B<Docker sends a structurally valid reading with everything zeroed> -- 200,
about 825 bytes, no error anywhere in it:

    { id => '...', name => '/...', os_type => 'linux',
      read      => '0001-01-01T00:00:00Z',
      cpu_stats => { cpu_usage => { total_usage => 0, ... }, ... },
      memory_stats => {}, pids_stats => {}, num_procs => 0, ... }

So the advice this section used to give -- test for C<read> or C<cpu_stats>
before using what comes back -- is B<wrong on Docker>: both keys are present,
both look plausible, the test passes, and the caller uses zeros as though
they were a measurement. The markers in that body are Go's zero time in
C<read> (C<0001-01-01T00:00:00Z>) and an empty C<memory_stats>, and those are
Docker's shape rather than anybody's contract.

C<num_procs> is B<not> one of them, contrary to what this section said before
it was measured: a one-shot reading taken from a container that was genuinely
B<running> on Docker 29.7.2 carries C<< num_procs => 0 >> as well. It is zero
on that engine either way and separates nothing.

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

So on Docker C<< stream => 1 >> has B<no> terminator tied to the container at
all, and the readings turn to zeros without anything in the stream saying so.
A caller that follows a container's stats until it stops is asking for
something this endpoint does not offer on that engine: give C<on_event> its
own stopping condition -- a reading count, a deadline, or the Go zero time in
C<read> -- and do not wait for the stream to end on its own.

B<C<read_timeout> does not bound this.> It is an idle timeout -- silence since
the last byte -- and this stream is never silent: it keeps producing a
zero-filled reading once a second, indefinitely, so the clock that C<read_timeout>
measures never runs out. C<read_timeout> bounds a daemon that goes quiet; a
Docker stats stream after container exit does the opposite -- it keeps talking,
just not truthfully. C<on_event> still has to notice the Go zero time in
C<read> and call C<< $stop->() >> itself; do not rely on C<read_timeout> to end
this case.

=head2 Why this is documented and not guarded

L</attach> refuses a container that is not running (see
L</"This method refuses a container that is not running">). This method does
B<not>, and the difference is deliberate rather than an inconsistency.

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

    use Path::Tiny;
    path('container.tar')->spew_raw($containers->export($id));

Export the container's whole filesystem as a tar archive -- the endpoint
behind C<docker export>. Returns the raw archive bytes, never decoded and
never modified.

The archive is buffered whole in memory, so this costs the size of the
container's filesystem in RAM. There is no streaming variant here.

Unlike L<API::Docker::API::Images/get>, the result is a plain filesystem tar:
no C<manifest.json>, no layers, no image metadata. L<API::Docker::API::Images/load>
will not take it back -- importing a flat filesystem is
C<< POST /images/create?fromSrc=- >>, which this distribution does not expose.

=head2 resize

    $containers->resize($id, h => 40, w => 120);

Resize the TTY of a container, so a program inside it sees the new terminal
size. Form-identical to L<API::Docker::API::Exec/resize>, which resizes the
TTY of an exec instance instead.

Only meaningful for a container created with C<< Tty => 1 >>; the engine
rejects the call otherwise.

Options:

=over

=item * C<h> - New height in character rows

=item * C<w> - New width in character columns

=back

=head2 wait

    my $result = $containers->wait($id);
    my $code   = $result->{StatusCode};

    $containers->wait($id, condition => 'not-running');

Block until the container reaches a condition, then return B<a HashRef> --
not the exit code. C<StatusCode> is the exit status of the container's main
process, and it is the one key both engines always send:

    { StatusCode => 4 }                    # Docker 29.7.2 (API 1.55)
    { StatusCode => 4, Error => undef }    # Podman 5.4.2 (API 1.41)

B<The C<Error> key diverges, and C<exists> is the wrong test for it.>
Measured on successful waits on both engines: Docker B<omits> the key
entirely, Podman sends C<"Error": null> on every wait, which decodes to
C<undef>. So C<< exists $result->{Error} >> is false on Docker and true on
Podman for one and the same outcome, while C<< defined $result->{Error} >> is
false on both. Ask C<defined>, never C<exists>, and take C<StatusCode> as the
answer.

A B<non-null> C<Error> was not produced on either engine by any probe behind
this documentation. The Engine API reference documents it as an object
carrying C<Message>; that shape is B<documented but not measured here>, which
is not the same as unreachable -- do not write code that assumes it cannot
appear, and do not trust its shape without checking.

The call blocks in the client for as long as the engine takes to answer: this
endpoint answers only once the condition is met, and the whole response is
read before anything is parsed. There is no timeout, on this method or in the
transport.

Measured on both engines:

=over

=item * A container that has B<already exited> answers immediately with its
real exit status -- with no condition and with C<not-running> alike

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

and an unknown container ID croaks B<404>

=back

Podman also answers C<< StatusCode => -1 >> for a container whose exit status
L</attach> has destroyed -- see
L</"On Podman this destroys a stopped container's exit status">. The value is
that engine's sentinel for "no status", not an exit code.

This endpoint is also the reason the error check on L</stats> matches
C<cause>, C<message> and C<response> case-sensitively: a I<successful> wait
is a 2xx body with a top-level C<Error> key in it, and a rule matching
C<error> case-insensitively would turn every one of them into a failure.

Options:

=over

=item * C<condition> - What to wait for: C<not-running> (the engine's own
default), C<next-exit> or C<removed>. Sent only when given

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

=head2 rename

    $containers->rename($id, 'new-name');

Rename a container.

=head2 update

    $containers->update($id, Memory => 314572800);

Update container resource limits and configuration.

The boolean flags (C<Init>, C<OomKillDisable>) may be given as a Perl C<1>/C<0>
or as a JSON boolean; either goes out as a real JSON C<true>/C<false>, which
the engine's body type-check requires.

=head2 get_archive

    use Path::Tiny;
    my $tar = $containers->get_archive($id, path => '/etc/hostname');
    path('hostname.tar')->spew_raw($tar);

    # and what the path was, without a second request
    my %stat;
    my $tar = $containers->get_archive($id, path => '/var/log', stat => \%stat);

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

directory comes back as the directory and everything under it, with paths
relative to its parent. The whole archive is buffered in memory.

Options:

=over

=item * C<path> - Path inside the container to read. Required

=item * C<stat> - HashRef the C<X-Docker-Container-Path-Stat> header is
decoded into. The engine sends it on this response as well as on the HEAD
one, so asking for it here saves the extra round trip L</stat_archive> would
cost. Emptied when the engine sent no such header. See L</stat_archive> for
the keys

=back

=head2 put_archive

    use Path::Tiny;
    $containers->put_archive($id, path('payload.tar')->slurp_raw,

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


    say $stat->{name};                        # hostname
    say $stat->{size};                        # 13
    printf "%04o\n", $stat->{mode} & 0777;    # 0644

Stat a path inside a container without transferring it -- C<HEAD> on the same
endpoint L</get_archive> uses. Returns a HashRef, or C<undef> when the engine
answered without the header. A path that does not exist is a croak from the
transport's status handling, not an C<undef>.

The response has no body at all: the answer is the
C<X-Docker-Container-Path-Stat> header, base64-encoded JSON, which this method
decodes. Its keys are the engine's, passed through as they arrive:

=over

=item * C<name> - The path's basename. For a symlink the two engines
disagree: Docker reports the requested path's own basename, Podman the
resolved target's

=item * C<size> - Size in bytes

=item * C<mode> - Go's C<os.FileMode> bits, B<not> a POSIX mode word, on both
engines. The permission bits are the low nine (C<< $stat->{mode} & 0777 >>);
the type bits above them are Go's own numbering, so a directory's C<mode> is
C<os.ModeDir> (C<< 1<<31 >>) plus the permission bits -- C<2147484141> for a
C<0755> directory -- rather than POSIX's C<S_IFDIR>, which for the same
directory would give C<16877>

=item * C<mtime> - Modification time, RFC 3339

=item * C<linkTarget> - The symlink target. Docker sends the literal,
unresolved link content, and leaves this empty for anything that is not a
symlink exactly as the Engine API reference documents; Podman sends the
fully I<resolved> path instead, and was measured populating it even for a
plain regular file, where Docker leaves it empty

=item * C<isDir> - Boolean, true when the path is a directory. B<Podman
only> -- Docker was measured never sending this key, not even for a
directory, so it is not part of the Docker Engine API's own answer

=back

This shape is confirmed against Podman, not assumed: measured against the
rootless socket, C<stat_archive> on Podman returns exactly these six keys.
For a symlink such as F<hnlink> pointing at F</etc/hostname>, Docker reports
C<name> as C<hnlink> (the link's own basename) and C<linkTarget> as
C</etc/hostname> (the raw, unresolved content); Podman reports C<name> as
C<hostname> (the resolved target's basename) and the fully resolved path in
C<linkTarget>. The route itself was measured the same way on Podman: an
unknown container answers 404, and that 404 announces a C<Content-Length>
while sending no body -- which is why L<API::Docker::Role::HTTP/head> never
reads one.

Options:

=over

=item * C<path> - Path inside the container to stat. Required

=back

=head2 prune

    my $result = $containers->prune(filters => { until => ['24h'] });

Delete stopped containers. Returns hashref with C<ContainersDeleted> and C<SpaceReclaimed>.

Options:

=over

=item * C<filters> - HashRef of filter name to ArrayRef of string values; the
engine accepts C<until> and C<label> here. Shape-checked and normalised by
L<API::Docker::Role::Filters>

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

# plugin routes use -- so a public image is looked up anonymously, with no
# header at all. Not verifiable on this machine: Podman serves no route here.
sub _auth_headers {
  my ($self, $opts) = @_;
  return () unless defined $opts->{auth};
  return (headers => { 'X-Registry-Auth' => $self->_registry_auth_header($opts->{auth}) });
}

sub inspect {
  my ($self, $name, %opts) = @_;
  croak __PACKAGE__ . '->inspect requires an image reference' unless $name;

  return $self->client->get("/distribution/$name/json",
    $self->_auth_headers(\%opts),
    %{ $self->_request_options },
    (exists $opts{response} ? (response => $opts{response}) : ()));
}


# The engine's own "I have no such route" 404 versus the registry's "I do not
# have that reference" 404. Measured on Podman 5.4.2 (API 1.41), which has no
# route: 'Path /v1.41/distribution/nginx:latest/json is not supported'. That
# '1.41' is the negotiated API version echoed back from the request path, not
# a fixed string -- it moves with negotiation, which is why the regex below
# matches on wording rather than on a version number.
# Docker's own unknown-route answer is 'page not found'. Anything not
# recognised as the engine talking about itself is taken as the registry's
# answer, so an unfamiliar wording degrades to plain "404 means no" rather
# than to a wrong croak.
my $NO_SUCH_ROUTE = qr/\bis not supported\b|\bpage not found\b/i;

sub exists {
  my ($self, $name, %opts) = @_;
  croak __PACKAGE__ . '->exists requires an image reference' unless $name;

  # A caller's own response HashRef is reused rather than shadowed, so
  # passing one through this method still fills it.
  my $res = ref $opts{response} eq 'HASH' ? $opts{response} : {};
  my $ok = eval {
    $self->inspect($name, %opts, response => $res);
    1;
  };
  return 1 if $ok;

  my $err = $@;
  die $err unless ($res->{status} // 0) == 404;
  croak __PACKAGE__ . '->exists cannot ask this engine: ' . $err
    if $err =~ $NO_SUCH_ROUTE;
  return 0;
}



1;

__END__

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


L</exists> exists because answering "no" to everything is the failure this
class was added to remove -- see the Podman note below -- and a predicate
that cannot fail loudly would have reintroduced it one layer up.

=head2 Not available on Podman

Measured against the rootless Podman socket (5.4.2, API 1.41):
C<< GET /v1.41/distribution/nginx:latest/json >> answers C<404 Not Found>
with
C<< {"cause":"","message":"Path /v1.41/distribution/nginx:latest/json is not supported","response":0} >>
(the C<1.41> there is this client's negotiated API version, echoed back from
the request path -- it moves with negotiation, not a fixed string),
and so does every other reference, escaped or not -- the compat layer has no
route for this endpoint. This class therefore needs a real Docker daemon.

That 404 is exactly the one a naive predicate would read as "the registry
does not have it", which is why L</exists> tells the engine's own
no-such-route answer apart and croaks on it instead.

=head2 What this class returns

L</inspect> returns the decoded engine response -- a HashRef with
C<Descriptor> and C<Platforms> -- not an entity object, deviating from the
C<inspect> convention the other resource classes follow, because there is no
C<API::Docker::Distribution> entity class to wrap it in.

=head2 client

Reference to L<API::Docker> client. Weak reference to avoid circular dependencies.

=head2 inspect

    my $descriptor = $distribution->inspect('nginx:latest');
    my $descriptor = $distribution->inspect('private/app:1.0', auth => $auth);

Ask the registry for the manifest descriptor of an image reference. The
daemon performs the lookup; nothing is pulled and no local image is touched.

Returns a HashRef with C<Descriptor> -- C<MediaType>, C<digest>, C<size>,
C<URLs> -- and C<Platforms>, the list of C<{ Architecture, OS, ... }> the
reference resolves to.

B<A missing reference croaks.> This method is the endpoint, so it inherits
the transport's rule that any status at or above 400 is an error, and the
registry's "no such reference" is a 404 like any other. Use L</exists> for
the predicate, or eval and read the status:

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

Options:

=over

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

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

=back

=head2 exists

    if ($distribution->exists('myrepo/app:1.0', auth => $auth)) { ... }

Whether the registry has that image reference. Returns a true value when the
lookup succeeded, a false one when the registry answered 404, and B<croaks>
otherwise -- including when the engine has no C</distribution> route, so that
an engine which cannot answer the question never answers it with "no".

Takes the same options as L</inspect>. Callable without an C<eval>: every
outcome it returns is an answer from the registry, and everything else is
loud.

The distinction rests on the engine's error message, which is the only thing
that separates the two 404s -- C<is not supported> from Podman,
C<page not found> from Docker's own router. A wording neither recognises is
read as the registry's answer, i.e. as false, which is the behaviour a plain
"404 means no" would have had anyway.

=head1 SEE ALSO

=over

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

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

  # for it in silence. Handed over as it is, the transport says so instead.
  return $self->client->stream_frames('POST', "/exec/$exec_id/start",
    body => $body,
    $opts{Tty} ? ( tty => 1 ) : (),
    %{ $self->_request_options },
    exists $opts{on_frame} ? ( on_frame => $opts{on_frame} ) : (),
  );
}


sub resize {
  my ($self, $exec_id, %opts) = @_;
  croak "Exec ID required" unless $exec_id;
  my %params;
  $params{h} = $opts{h} if defined $opts{h};
  $params{w} = $opts{w} if defined $opts{w};
  return $self->client->post("/exec/$exec_id/resize", undef,
    params => \%params,
    %{ $self->_request_options },
  );
}


sub inspect {
  my ($self, $exec_id) = @_;
  croak "Exec ID required" unless $exec_id;
  return $self->client->get("/exec/$exec_id/json",

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

Create an exec instance. Returns hashref with C<Id>.

Required config: C<Cmd> (ArrayRef of command and arguments).

Common config keys: C<AttachStdin>, C<AttachStdout>, C<AttachStderr>, C<Tty>,
C<Env>, C<User>, C<WorkingDir>.

The boolean flags (C<AttachStdin>, C<AttachStdout>, C<AttachStderr>, C<Tty>,
C<Privileged>) may be given as a Perl C<1>/C<0> or as a JSON boolean; either
goes out as a real JSON C<true>/C<false>, which the engine's body type-check
requires. Passing C<1> where the daemon wants a boolean would otherwise be
rejected.

=head2 start

    my $frames = $exec->start($exec_id, Detach => 0);

    my $output = join '', map { $_->{data} } @$frames;

Start an exec instance. Returns an ArrayRef of frames in the same shape as
L<API::Docker::API::Containers/logs>:

    [ { stream => 'stdout', data => "OUT\n" },
      { stream => 'stderr', data => "ERR\n" } ]

An exec instance created without a TTY multiplexes stdout and stderr into one
framed stream, which this method demultiplexes. One created with a TTY has no
frame headers and its output arrives as a single C<< stream => 'raw' >> frame.
A detached start produces no output, so it returns an empty ArrayRef.

The exit status is B<not> part of this response. It comes from a separate call
once the exec has finished:

    my $exit = $exec->inspect($exec_id)->{ExitCode};

Options:

=over

=item * C<Detach> - Run detached; the engine returns immediately and no output
is streamed

=item * C<Tty> - Declares that this exec instance was created with a TTY. It is
sent in the request body, where the engine expects it to match the C<Tty> given
to L</create>, and it also suppresses demultiplexing of the response. Framing is
otherwise detected from the response bytes -- see
L<API::Docker::Role::HTTP/"Detecting a framed stream">

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

=back

=head2 Watching the output as it is produced

Without a callback this returns when the command has finished and the daemon

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

            $stop->() if $frame->{data} =~ /ready/;
        },
    );

    $summary;   # { delivered => 9, stopped => 1 }

With a callback the return value is that summary HashRef, not the frames:
C<delivered> is how many went to the callback, C<stopped> is 1 when the
callback ended the stream and 0 when the daemon did. Nothing is accumulated,
so joining the output is the callback's job. See
L<API::Docker::Role::HTTP/"Streaming a response as it arrives">.

A detached start produces no output, so its summary is
C<< { delivered => 0, stopped => 0 } >> where the buffered call returns an
empty ArrayRef.

C<Tty> means something stronger on this path. The buffered path decides
framing by walking the whole body, which is exactly what a streamed one does
not have; so with C<on_frame> it is a promise about the exec instance rather
than a hint, and an undeclared stream that turns out not to be framed croaks
instead of being handed back raw. Pass the same C<Tty> that went to L</create>
-- the engine expects them to agree in any case.

=head2 resize

    $exec->resize($exec_id, h => 40, w => 120);

Resize the TTY for an exec instance.

Options:

=over

=item * C<h> - New height in character rows

=item * C<w> - New width in character columns

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

  required => 1,
  weak_ref => 1,
);


# The class is the caller's argument rather than a constant of this module:
# `list` and `inspect` are two definitions in the swagger and therefore two
# generated classes. Both carry the same convenience methods, composed by
# API::Docker::Role::Entity::Image -- see "The two image shapes".
#
# from_data, not new: this is a daemon response, and the two entry points of
# API::Docker::Role::Type read it differently. from_data takes the swagger's
# wire names and nothing else, so a key it has not heard of keeps its own
# spelling instead of being read as the Perl name of one it has, and a value
# that disagrees with the swagger costs its own field rather than the whole
# response. `client` is ours rather than the engine's, so it goes beside the
# data instead of into it.
sub _wrap {
  my ($self, $class, $data) = @_;
  return $class->from_data($data, client => $self->client);
}

sub _wrap_list {
  my ($self, $class, $list) = @_;
  return [ map { $self->_wrap($class, $_) } @$list ];
}

sub list {
  my ($self, %opts) = @_;
  my %params;
  $params{all}     = $opts{all} ? 1 : 0     if defined $opts{all};
  $params{digests} = $opts{digests} ? 1 : 0 if defined $opts{digests};
  $params{filters} = $self->_normalise_filters($opts{filters})
    if defined $opts{filters};
  my $result = $self->client->get('/images/json',
    params => \%params,
    %{ $self->_request_options },
  );
  return $self->_wrap_list('API::Docker::Type::ImageSummary', $result // []);
}


sub build {
  my ($self, %opts) = @_;
  my $context = delete $opts{context};
  croak "Build context required (tar archive as scalar ref or raw bytes)" unless defined $context;

  my %params;
  $params{dockerfile} = $opts{dockerfile} if defined $opts{dockerfile};
  $params{t}          = $opts{t}          if defined $opts{t};
  $params{q}          = $opts{q} ? 1 : 0  if defined $opts{q};
  $params{nocache}    = $opts{nocache} ? 1 : 0 if defined $opts{nocache};
  $params{pull}       = $opts{pull}       if defined $opts{pull};
  $params{rm}         = defined $opts{rm} ? ($opts{rm} ? 1 : 0) : 1;
  $params{forcerm}    = $opts{forcerm} ? 1 : 0 if defined $opts{forcerm};
  $params{memory}     = $opts{memory}     if defined $opts{memory};
  $params{memswap}    = $opts{memswap}    if defined $opts{memswap};
  $params{cpushares}  = $opts{cpushares}  if defined $opts{cpushares};
  $params{cpusetcpus} = $opts{cpusetcpus} if defined $opts{cpusetcpus};
  $params{cpuperiod}  = $opts{cpuperiod}  if defined $opts{cpuperiod};
  $params{cpuquota}   = $opts{cpuquota}   if defined $opts{cpuquota};
  $params{shmsize}    = $opts{shmsize}    if defined $opts{shmsize};
  $params{networkmode} = $opts{networkmode} if defined $opts{networkmode};
  $params{platform}   = $opts{platform}   if defined $opts{platform};
  $params{target}     = $opts{target}     if defined $opts{target};

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

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

    $self->_auth_headers(\%opts),
    %{ $self->_request_options },
    exists $opts{on_event} ? ( on_event => $opts{on_event} ) : ( ndjson => 1 ),
  );
}


sub inspect {
  my ($self, $name) = @_;
  croak "Image name required" unless $name;
  my $result = $self->client->get("/images/$name/json",
    %{ $self->_request_options },
  );
  return $self->_wrap('API::Docker::Type::ImageInspect', $result);
}


sub history {
  my ($self, $name) = @_;
  croak "Image name required" unless $name;
  return $self->client->get("/images/$name/history",
    %{ $self->_request_options },
  );
}

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

  return $self->client->post('/images/prune', undef,
    params => \%params,
    %{ $self->_request_options },
  );
}


sub get {
  my ($self, $name, %opts) = @_;
  croak "Image name required" unless $name;
  # `raw` and `on_chunk` are the same promise made twice -- hand the response
  # bytes over undecoded -- so only one of them is sent: with a callback there
  # is no return value for `raw` to describe.
  return $self->client->get("/images/$name/get",
    %{ $self->_request_options },
    exists $opts{on_chunk} ? ( on_chunk => $opts{on_chunk} ) : ( raw => 1 ));
}


sub get_all {
  my ($self, @names) = @_;

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


=item * The parent layer is C<< ->parent_id >> on a summary and
C<< ->parent >> on an inspect. Both are empty for an image pulled from a
registry rather than built locally, and the swagger marks the inspect one
deprecated.

=item * C<< ->labels >> is top-level on a summary only. An inspect carries
the labels under C<< ->config->labels >>, where C<< ->config >> is the
L<API::Docker::Type::ImageConfig> the image runs containers with --
C<< ->cmd >>, C<< ->env >>, C<< ->entrypoint >>, C<< ->exposed_ports >> and
the rest.

=item * C<< ->containers >> (how many containers use the image) and
C<< ->shared_size >> come from a summary only. The swagger says of both that
C<-1> means the value was not calculated, and of C<SharedSize> that it is not
calculated by default -- so treat C<-1> as "unknown", not as a count.

=item * C<< ->architecture >>, C<< ->os >>, C<< ->os_version >>,
C<< ->variant >>, C<< ->author >>, C<< ->comment >>, C<< ->docker_version >>,
C<< ->config >>, C<< ->root_fs >>, C<< ->graph_driver >> and
C<< ->metadata >> come from an inspect only.

=item * C<< ->id >>, C<< ->repo_tags >>, C<< ->repo_digests >>, C<< ->size >>,
C<< ->descriptor >> and C<< ->manifests >> are on both and mean the same
thing. The swagger declares every field of a summary required and no field of
an inspect, which the model records but does not enforce -- see
L<API::Docker::Type/"C<since> is documentation">.

=back

There is no C<< ->virtual_size >>: the swagger dropped C<VirtualSize> from
both definitions after v1.44, and engines that still send it -- the Podman on
this machine does -- have it kept verbatim in
C<< ->unknown_fields->{VirtualSize} >>, where C<TO_JSON> writes it back
unchanged. F<t/type_fixture_passthrough.t> pins that.

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

Returns an ArrayRef of build events, one per object in the engine's
newline-delimited JSON stream, even when the stream carried a single object
(C<< q => 1 >> produces exactly one). A successful build returns; a failed one
croaks.

    my $events = $images->build(context => $tar, t => 'myapp:latest');
    my ($aux) = grep { $_->{aux} } @$events;
    my $image_id = $aux->{aux}{ID};

The engine answers a failed build with HTTP 200 and reports the failure as an
C<errorDetail> object inside the stream, so nothing about the response status
says the build broke. This method used to return that stream like any other
and leave the scan to the caller, which meant a caller who did not know to
scan reported a broken build as a success. It now croaks with an
L<API::Docker::Error::Stream> instead:

    my $events = eval { $images->build(context => $tar, t => 'myapp:latest') };
    if (my $err = $@) {
        warn "$err";               # the reason, with Carp's location suffix
        for my $event (@{ $err->events }) {   # the build output up to the failure
            print $event->{stream} if defined $event->{stream};

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

Options:

=over

=item * C<context> - Tar archive bytes (required)

=item * C<dockerfile> - Path to Dockerfile within the archive (default: C<Dockerfile>)

=item * C<t> - Tag for the image (e.g. C<name:tag>)

=item * C<q> - Suppress verbose build output

=item * C<nocache> - Do not use cache when building

=item * C<pull> - Always pull base image

=item * C<rm> - Remove intermediate containers (default: true)

=item * C<forcerm> - Always remove intermediate containers

=item * C<buildargs> - HashRef of build-time variables

=item * C<labels> - HashRef of labels to set on the image

=item * C<memory> - Memory limit in bytes

=item * C<memswap> - Total memory (memory + swap), -1 to disable swap

=item * C<cpushares> - CPU shares (relative weight)

=item * C<cpusetcpus> - CPUs to use (e.g. C<0-3>, C<0,1>)

=item * C<cpuperiod> - CPU CFS period (microseconds)

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

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

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

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

several registries can carry all of them at once. A pre-encoded base64 string
is also accepted. Sent only when given. This is B<not> C<auth>/C<X-Registry-Auth>,
which carries a single AuthConfig; C</build> uses the map form. See
L<API::Docker::Role::RegistryAuth>

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

=back

=head2 Progress as it arrives

Without a callback the whole stream is read before anything is parsed, so a
build that takes two minutes is two minutes of silence followed by all of its
output at once. Pass C<on_event> and the events are handed over as the daemon
sends them:

    my $summary = $images->build(
        context  => $tar,
        t        => 'myapp:latest',
        on_event => sub {

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

        },
    );

    $summary;   # { delivered => 41, stopped => 0 }

With a callback the return value is that summary HashRef, not the events:
C<delivered> is how many went to the callback, C<stopped> is 1 when the
callback ended the stream and 0 when the daemon did. Nothing is accumulated,
so a caller that wants the C<aux> event with the image id in it must keep that
event itself as it goes by. See
L<API::Docker::Role::HTTP/"Streaming a response as it arrives">.

The same section applies to L</pull>, L</push> and L</load>, which take
C<on_event> on the same terms.

=head3 A failed build still croaks, one event earlier

The C<errorDetail> check runs either way, so a failed build croaks with an
L<API::Docker::Error::Stream> on both paths. What differs is when, and what
the exception carries:

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

led up to the failure.

=item * Streamed, the check runs per event, so the croak happens at the event
that reports the failure rather than when the daemon eventually closes. The
exception then carries B<that one event> alone: a callback stream keeps no
history, having handed every earlier event to the callback already. The
failing event itself is not delivered.

=back

So a caller that reads the progress out of C<< $err->events >> must, on this
path, collect it in the callback instead:

    my @output;
    my $summary = eval {
        $images->build(context => $tar, t => 'myapp:latest',
            on_event => sub { push @output, $_[0] });
    };
    if (my $err = $@) {
        warn "$err";               # the reason, as before
        # $err->events is the failing event; @output is what preceded it

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

of its own>. The engine appends C<tag> to the reference rather than treating it
as a fallback, so defaulting it onto an already-qualified name breaks the pull:
measured against Docker 29.7.2 (API 1.55) C<< pull(fromImage => 'nginx:1.25')
>> would silently fetch C<nginx:latest> and report success, and against Podman
5.8.4 (compat API 1.44) the same request answers C<500 invalid reference
format> for C<nginx:1.25:latest>. A digest reference breaks the same way on
both. So C<tag> is sent only if given explicitly, or defaulted to C<latest>
when the name carries neither a C<:tag> (in the segment after the last C</>)
nor an C<@digest>. A registry C<host:port/> prefix is not mistaken for a tag.

Returns an ArrayRef of progress events, one per object in the engine's
newline-delimited JSON stream, even when the stream carried a single object.

A failed pull croaks either way, but which way depends on the engine, so do
not write code that expects one of them:

=over

=item * Docker reports it in the stream. The response is HTTP 200 and the
failure is an C<errorDetail> object among the progress events; this method
croaks with an L<API::Docker::Error::Stream>, whose C<< ->events >> holds the
progress that preceded the failure.

=item * Podman reports it in the status line. Measured against the rootless
socket (5.4.2, API 1.41): pulling a repository that does not exist answers
C<403 Forbidden> with C<< {"message":"denied: requested access to the resource
is denied"} >>, and an existing repository with a missing tag answers
C<404 Not Found> with C<< {"message":"manifest unknown: manifest unknown"} >>.
Neither reaches the stream at all -- the transport's own status handling
croaks with an L<API::Docker::Error::HTTP> -- which is that same string to
anything inspecting C<$@> as text -- first.

=back

Catching L<API::Docker::Error::Stream> specifically is therefore not a
reliable way to catch a failed pull. C<eval> and inspect C<$@> as a string,

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


=over

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

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

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

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

=back

=head2 inspect

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

Get detailed information about an image. Returns an
L<API::Docker::Type::ImageInspect>, which is B<not> the class L</list>
returns -- see L</"The two image shapes">.

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

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

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

=head2 push

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

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

Returns an ArrayRef of progress events, one per object in the engine's
newline-delimited JSON stream, even when the stream carried a single object.

A failed push croaks, by one of two routes depending on the engine -- an
unauthorised push to a private registry is the common case, and it is exactly
the one that must not be reported as a success.

Docker reports it inside a 200 stream as an C<errorDetail> object, which
croaks with an L<API::Docker::Error::Stream> carrying the progress events.
Podman puts an C<errorDetail> body behind a real error status instead:
measured against the rootless socket (5.4.2, API 1.41), a push to an
unreachable registry answers C<500 Internal Server Error> with
C<< {"errorDetail":{"message":"... connection refused"},"error":"..."} >>, so
the transport's status handling croaks with an L<API::Docker::Error::HTTP> --
which is that same string to anything inspecting C<$@> as text -- before the
stream is ever decoded. That body carries no C<message> key, so the whole
JSON object ends up as the croak text.

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

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

Options:

=over

=item * C<tag> - Tag to push

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

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

=back

=head2 tag

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

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

=head2 remove

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

=over

=item * C<force> - Force removal

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

=back

=head2 search

    my $results = $images->search('nginx', limit => 25);

Search Docker Hub for images. Returns ArrayRef of search results.

Options:

=over

=item * C<limit> - Maximum number of results

=item * C<filters> - HashRef of filter name to ArrayRef of string values; the
engine accepts C<is-official>, C<is-automated> and C<stars> here. The boolean
ones want the string, C<< { 'is-official' => ['true'] } >>, and C<stars> a
number written as one -- L<API::Docker::Role::Filters> takes care of both and
croaks on a shape the daemon would refuse

=back

=head2 prune

    my $result = $images->prune(filters => { dangling => ['true'] });

Delete unused images. Returns hashref with C<ImagesDeleted> and C<SpaceReclaimed>.

Options:

=over

=item * C<filters> - HashRef of filter name to ArrayRef of string values; the
engine accepts C<dangling>, C<until> and C<label> here. Shape-checked and
normalised by L<API::Docker::Role::Filters>

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


B<The return value is raw bytes, not a decoded structure.> The engine answers
with the tar stream itself, and the transport is told to hand it back
untouched (C<< raw => 1 >>), so what arrives is byte for byte what the daemon
wrote. Write it with a binary-safe file handle -- C<< path(...)->spew_raw >>,
or C<binmode> on a handle of your own. Treating it as text corrupts it, and
nothing about the value announces that it is binary.

The archive holds one tarball per layer, a config JSON per image,
C<manifest.json> and C<repositories>. Measured against Podman 5.4.2 (API
1.41): the response is chunked with
C<< Content-Type: application/octet; charset=us-ascii >>, where Docker sends
C<application/x-tar> -- the transport looks at neither, so the difference does
not reach the caller. Exporting C<alpine:3> through this method produced bytes
md5-identical to what C<curl --unix-socket> wrote for the same request, all
8705536 of them, so the chunked reader is binary-clean.

An unknown image croaks. On the same engine that is C<404 Not Found> with
C<< {"message":"failed to find image ...: image not known"} >>.

=head2 Exporting without buffering the archive

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

Measured against Podman 5.4.2 (API 1.41): exporting C<alpine:3> this way
delivered its 8705536 bytes in 266 pieces, md5-identical to what the buffered
call returns for the same request, with no more than one piece held at a time.

With a callback the return value is the summary HashRef
C<< { delivered => N, stopped => 0|1 } >>, not the archive: C<delivered> is
how many pieces went to the callback, C<stopped> is 1 when the callback ended
the transfer. Stopping leaves a B<truncated> archive behind -- the export is
one tar stream, not a sequence of independent records -- so stop only to
abandon it. See
L<API::Docker::Role::HTTP/"Streaming a response as it arrives">.

Options:

=over

=item * C<on_chunk> - CodeRef called with each piece of the archive as it
arrives, instead of the whole thing being returned

=back

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

bigger archive, so this is where not buffering it matters most -- but it can
only be passed with the ArrayRef form:

    my $summary = $images->get_all([ 'alpine:3', 'registry:2' ],
        on_chunk => sub { print {$out} $_[0] });

The list form takes names and nothing else: a trailing option pair in it would
be indistinguishable from two more image names. Options after the ArrayRef
must come in pairs; an odd number croaks.

A transport bound is not one of those options: it goes on the resource class,
which works with either form -- C<< $docker->images->using(read_timeout => 5)
->get_all('alpine:3') >>, see L<API::Docker::Role::Using>.

Measured against Podman 5.4.2 (API 1.41): C<alpine:3> and C<registry:2>
together came to 34725888 bytes in 1060 pieces, none of which had to be held.

=head2 load

    use Path::Tiny;
    my $events = $images->load(path('alpine.tar')->slurp_raw);

    for my $event (@$events) {
        print $event->{stream} if defined $event->{stream};
    }

Import a tar archive produced by L</get> or L</get_all> -- the endpoint behind
C<docker image load>. The archive is the request body; pass it as raw bytes or
as a scalar reference to them, the way L</build> takes its context.

Returns an ArrayRef of progress events, one per object in the engine's
newline-delimited JSON stream, even when the stream carried a single object.
The last of them names what was imported:

    my ($loaded) = grep { ($_->{stream} // '') =~ /^Loaded image/ } @$events;

Options:

=over

=item * C<quiet> - Suppress the per-layer progress detail in the response
stream

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

=back

C<quiet> changes how much the engine says, not what this method returns: the
body stays newline-delimited JSON and the return stays an ArrayRef either way.
B<Podman ignores it entirely> -- measured against 5.4.2 (API 1.41), C<quiet>
unset, C<0> and C<1> all produce the identical single
C<< {"stream":"Loaded image: ..."} >> object. Should an engine answer a quiet
load with a body of no bytes at all, the transport still returns C<[]>, not
C<undef> -- the C<ndjson> branch in L<API::Docker::Role::HTTP/_request> runs
before the empty-body check that would return C<undef>, so a caller that
iterates the result unconditionally needs no guard for this case.

A failed load croaks, but by which route depends on the engine, the same split
L</pull> and L</push> have. Docker reports it as an C<errorDetail> object
inside a 200 stream, which croaks with an L<API::Docker::Error::Stream>
carrying the events. Podman reports it in the status line instead: measured
against 5.4.2, a body that is not an image archive answers C<500 Internal
Server Error> with C<< {"message":"failed to load image: payload does not
match any of the supported image formats: ..."} >>, and the transport's status
handling croaks with an L<API::Docker::Error::HTTP> -- which is that same
string to anything inspecting C<$@> as text -- before any stream is decoded.
Inspect C<$@> as a string rather than testing for the exception class.

The archive is sent as one buffered request body, so loading a large image
costs its full size in RAM.

=head2 commit

    my $result = $images->commit(
        container => $container_id,
        repo      => 'myapp',
        tag       => 'snapshot',
        comment   => 'after the migration ran',
    );
    my $image_id = $result->{Id};

    # With a config override and Dockerfile instructions
    $images->commit(
        container => $container_id,
        repo      => 'myapp',
        tag       => 'v2',
        config    => { Cmd => [ '/bin/sh' ], Labels => { built => 'here' } },
        changes   => [ 'EXPOSE 8080', 'LABEL stage=release' ],
    );

Create an image from a container's current filesystem. This is the one
image-producing path that does not go through a build context, and it is how a
caller snapshots a container it has been exec-ing into.

Returns the raw daemon response, a HashRef with an C<Id> key. Measured against
Podman 5.4.2 (API 1.41) the status is C<201 Created> and C<Id> is a bare hex
digest with no C<sha256:> prefix; Docker prefixes it. Do not compare it
literally against an id from C<inspect> without normalising.

Options:

=over

=item * C<container> - Container id or name to commit (required)

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

image (C<Cmd>, C<Env>, C<Labels>, C<ExposedPorts>, ...), sent as the request
body. Measured against Podman 5.4.2: C<Cmd> replaces the container's, C<Env>
is merged onto the environment the container inherited, and a C<Labels> here
lands alongside a C<LABEL> given in C<changes> -- the two are applied
together, not one instead of the other

=back

=head2 build_prune

    my $result = $images->build_prune(all => 1);
    my $freed  = $result->{SpaceReclaimed};

    # Keep 5 GB of cache
    $images->build_prune(keep_storage => 5 * 1024 * 1024 * 1024);

Clear the BuildKit build cache. B<This is not L</prune>>, and the two are not
interchangeable: L</prune> deletes unused I<images>, this deletes the
intermediate I<build cache> that L</build> writes. Neither touches the other's
storage, and on a machine that builds often the build cache is usually the
larger of the two.

Returns the raw daemon response, a HashRef with C<CachesDeleted> and
C<SpaceReclaimed>.

B<Podman does not implement this endpoint.> Measured against 5.4.2 (API 1.41):
C<POST /build/prune> answers C<404 Not Found> with a C<text/plain> body of
C<Not Found> -- not the JSON C<< {"message":...} >> shape its other errors use
-- at every version prefix tried, and there is no C<libpod> equivalent either.
The transport croaks with C<Docker API error (404): Not Found>, the plain body
verbatim, because it is not JSON to unwrap. A caller that must work on both
engines has to treat that 404 as "no build cache to clear here" rather than as
a transport fault.

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

use namespace::clean;


has client => (
  is       => 'ro',
  required => 1,
  weak_ref => 1,
);


# The class is the caller's argument, as it is on the resource classes whose
# list and inspect really are two definitions -- here both are the swagger's
# one `Network`, and passing it keeps the seam in the same place.
#
# from_data, not new: this is a daemon response, and the two entry points of
# API::Docker::Role::Type read it differently. from_data takes the swagger's
# wire names and nothing else, so a key it has not heard of keeps its own
# spelling instead of being read as the Perl name of one it has, and a value
# that disagrees with the swagger costs its own field rather than the whole
# response. `client` is ours rather than the engine's, so it goes beside the
# data instead of into it.
sub _wrap {
  my ($self, $class, $data) = @_;
  return $class->from_data($data, client => $self->client);
}

sub _wrap_list {
  my ($self, $class, $list) = @_;
  return [ map { $self->_wrap($class, $_) } @$list ];
}

sub list {
  my ($self, %opts) = @_;
  my %params;
  $params{filters} = $self->_normalise_filters($opts{filters})
    if defined $opts{filters};
  my $result = $self->client->get('/networks',
    params => \%params,
    %{ $self->_request_options },
  );
  return $self->_wrap_list('API::Docker::Type::Network', $result // []);
}


sub inspect {
  my ($self, $id) = @_;
  croak "Network ID required" unless $id;
  my $result = $self->client->get("/networks/$id",
    %{ $self->_request_options },
  );
  return $self->_wrap('API::Docker::Type::Network', $result);
}


# The NetworkCreateRequest booleans of spec/v1.51.yaml. The engine rejects a
# number for any of them, so 1/0 is normalised to a JSON boolean on the way
# out; a caller may still pass 1/0 or a JSON boolean and it goes out correctly.
my @NETWORK_CREATE_BOOLS = qw(
  Attachable ConfigOnly EnableIPv4 EnableIPv6 Ingress Internal
);

sub create {
  my ($self, %config) = @_;
  croak "Network name required" unless $config{Name};
  $self->_json_bools(\%config, @NETWORK_CREATE_BOOLS);
  my $result = $self->client->post('/networks/create', \%config);
  return $result;
}


sub remove {
  my ($self, $id) = @_;
  croak "Network ID required" unless $id;
  return $self->client->delete_request("/networks/$id",
    %{ $self->_request_options },
  );
}

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


=head1 VERSION

version 0.004

=head1 SYNOPSIS

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

    # Create a network
    my $result = $docker->networks->create(
        Name   => 'my-network',
        Driver => 'bridge',
    );

    # List networks
    my $networks = $docker->networks->list;
    say $_->name, ' ', $_->driver for @$networks;

    # Connect/disconnect containers
    $docker->networks->connect($network_id, Container => $container_id);

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

=head2 inspect

    my $network = $networks->inspect($id);

Get detailed information about a network. Returns an
L<API::Docker::Type::Network> -- the same class L</list> returns, since the
swagger describes a network one way.

=head2 create

    my $result = $networks->create(
        Name   => 'my-network',
        Driver => 'bridge',
    );

Create a network. Returns hashref with C<Id> and C<Warning>.

Boolean flags (C<Internal>, C<Attachable>, C<Ingress>, C<ConfigOnly>,
C<EnableIPv4>, C<EnableIPv6>) may be given as a Perl C<1>/C<0> or as a JSON
boolean; either goes out as a real JSON C<true>/C<false>, which the engine's
body type-check requires.

=head2 remove

    $networks->remove($id);

Remove a network.

=head2 connect

    $networks->connect($network_id, Container => $container_id);

Connect a container to a network.

=head2 disconnect

    $networks->disconnect($network_id, Container => $container_id, Force => 1);

Disconnect a container from a network. Optional C<Force> parameter, given as a
Perl C<1>/C<0> or a JSON boolean; it goes out as a real JSON C<true>/C<false>,
which the engine's body type-check requires.

=head2 prune

    my $result = $networks->prune;
    my $result = $networks->prune(filters => { until => ['24h'] });

Delete unused networks. Returns hashref with C<NetworksDeleted>.

Options:

=over

=item * C<filters> - HashRef of filter name to ArrayRef of string values; the
engine accepts C<until> and C<label> here. Shape-checked and normalised by
L<API::Docker::Role::Filters>

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

use namespace::clean;


has client => (
  is       => 'ro',
  required => 1,
  weak_ref => 1,
);


# The class is the caller's argument, as it is on the resource classes whose
# list and inspect really are two definitions -- here both are the swagger's
# one `Plugin`, and passing it keeps the seam in the same place.
#
# from_data, not new: this is a daemon response, and the two entry points of
# API::Docker::Role::Type read it differently. from_data takes the swagger's
# wire names and nothing else, so a key it has not heard of keeps its own
# spelling instead of being read as the Perl name of one it has, and a value
# that disagrees with the swagger costs its own field rather than the whole
# response. `client` is ours rather than the engine's, so it goes beside the
# data instead of into it.
sub _wrap {
  my ($self, $class, $data) = @_;
  return $class->from_data($data, client => $self->client);
}

sub _wrap_list {
  my ($self, $class, $list) = @_;
  return [ map { $self->_wrap($class, $_) } @$list ];
}

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

  return () unless defined $opts->{auth};
  return (headers => { 'X-Registry-Auth' => $self->_registry_auth_header($opts->{auth}) });
}

sub _privileges_body {
  my ($self, $method, $remote, %opts) = @_;

  return $self->privileges($remote, %opts) if $opts{accept_privileges};

  my $privileges = $opts{privileges};
  croak __PACKAGE__ . '->' . $method . ' requires privileges: fetch them with '
    . '->privileges(' . $remote . ') and pass them back as privileges => '
    . '$privileges, or pass accept_privileges => 1 to grant whatever the '
    . 'plugin asks for. The engine compares the list you send against the one '
    . 'the plugin demands and fails the operation when they differ'
    unless defined $privileges;

  croak __PACKAGE__ . '->' . $method . ' privileges must be an ArrayRef'
    unless ref $privileges eq 'ARRAY';

  return $privileges;
}

sub list {
  my ($self, %opts) = @_;
  my %params;
  $params{filters} = $self->_normalise_filters($opts{filters})
    if defined $opts{filters};
  my $result = $self->client->get('/plugins',
    params => \%params,
    %{ $self->_request_options },
  );
  return $self->_wrap_list('API::Docker::Type::Plugin', $result // []);
}


sub privileges {
  my ($self, $remote, %opts) = @_;
  croak __PACKAGE__ . '->privileges remote reference required' unless $remote;

  my $result = $self->client->get('/plugins/privileges',
    params => { remote => $remote },
    $self->_auth_headers(\%opts),
    %{ $self->_request_options },
  );

  # A plugin that demands nothing answers a bare `null`: computePrivileges
  # builds its result with `var privileges types.PluginPrivileges` and
  # appends only what the config asks for, so a nil Go slice reaches the
  # wire. The transport decodes that to undef, which no caller can iterate
  # and which accept_privileges => 1 would post straight back to
  # /plugins/pull as a JSON null. Normalised to the empty list it means.
  return [] unless ref $result eq 'ARRAY';
  return $result;
}


sub install {
  my ($self, $remote, %opts) = @_;
  croak __PACKAGE__ . '->install remote reference required' unless $remote;

  my $privileges = $self->_privileges_body('install', $remote, %opts);

  my %params = ( remote => $remote );

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

}


sub configure {
  my ($self, $name, @settings) = @_;
  croak __PACKAGE__ . '->configure plugin name required' unless $name;

  # One ArrayRef or a plain list, and nothing after either: this method reads
  # no options at all. The ArrayRef form used to be where the transport bounds
  # went, because a trailing `read_timeout => 2` in the plain list would be two
  # more settings as far as this method can tell -- they now go on the resource
  # class instead (karr k74), and what is left is a form, not a split.
  if (ref $settings[0] eq 'ARRAY') {
    my $list = shift @settings;
    croak __PACKAGE__ . '->configure takes nothing after the ArrayRef of '
      . 'settings; a transport bound goes on the resource class, as '
      . '$docker->plugins->using(read_timeout => 5)->configure(...)'
      if @settings;
    @settings = @$list;
  }

  croak __PACKAGE__ . '->configure requires at least one setting, as an '
    . 'ArrayRef or a list of "KEY=value" strings' unless @settings;

  croak __PACKAGE__ . '->configure settings must be plain strings'
    if grep { ref $_ } @settings;

  return $self->client->post("/plugins/$name/set", \@settings,
    %{ $self->_request_options },
  );
}

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

This module provides access to the Docker managed-plugin endpoints
(C</plugins>).

Accessed via C<< $docker->plugins >>, or through
L<API::Docker::Role::Using/using> for a run of calls that needs its own
transport bound: C<< $docker->plugins->using(read_timeout => 5) >>.

=head2 Installing is two calls, and the engine enforces it

C<< POST /plugins/pull >> takes the list of privileges the plugin demands
B<in its request body>, and the daemon compares that list against the one it
computes from the plugin's own config. They must match exactly -- same
length, same names, same values -- or the install fails with
C<incorrect privileges>. A plugin runs with the host access it asked for, so
the round trip exists to make somebody look at that access before granting
it.

L</privileges> is the first call, L</install> the second:

    my $privileges = $docker->plugins->privileges('vieux/sshfs:latest');
    # inspect $privileges here -- it is an ArrayRef of
    #   { Name => 'network', Description => '...', Value => ['host'] }
    $docker->plugins->install('vieux/sshfs:latest', privileges => $privileges);

C<install> B<requires> C<privileges> and croaks without it, which is stricter
than the engine: the daemon's own body parser treats a missing body as an
empty privilege list rather than an error, so a blind install of a plugin
that happens to demand nothing would quietly succeed and one that demands
C<network: host> would fail with an error naming neither. Passing
C<< accept_privileges => 1 >> makes C<install> perform the first call itself
and hand the answer straight back -- a blanket grant, spelled out at the call
site so it is greppable.

The same applies to L</upgrade>, which takes the same body.

=head2 Not available on Podman

Measured against the rootless Podman socket (5.4.2, API 1.41): B<none> of the
C</plugins> endpoints exist there. C<< GET /v1.41/plugins >> answers
C<404 Not Found> with
C<< {"cause":"","message":"Path /v1.41/plugins is not supported","response":0} >>
(the C<1.41> there is this client's negotiated API version, echoed back from
the request path -- it moves with negotiation, not a fixed string in the
daemon's error text),
and every other path in this family -- C</plugins/privileges>,
C</plugins/pull>, C</plugins/{name}/json>, C</plugins/{name}/enable> and the
rest -- answers a bare C<404 Not Found> as C<text/plain>, meaning the compat
layer has no route registered for them at all. Managed plugins are a Docker
feature; Podman's own plugin model is not served here. Everything in this
class therefore needs a real Docker daemon.

=head2 What this class returns

L</list> and L</inspect> return L<API::Docker::Type::Plugin> objects carrying
the convenience methods of L<API::Docker::Role::Entity::Plugin>, following
the C<list>/C<inspect> convention every other resource class here follows.
It is B<one> class for both, where containers and images have two: the
swagger answers C<GET /plugins> with an array of the C<Plugin> definition and
C<GET /plugins/{name}/json> with that same definition.

Field names are the swagger's own spelling in snake_case, and the nested
ones are generated classes rather than the raw HashRefs the old entity kept:
C<< $plugin->settings >> is an L<API::Docker::Type::Plugin::Settings> whose
C<< ->env >> is a list of C<KEY=value> strings, and C<< $plugin->config >> an
L<API::Docker::Type::Plugin::Config> whose C<< ->env >> is a list of
L<API::Docker::Type::PluginEnv> objects describing those same variables. The
entity's methods thread the plugin's name back through this class.

Everything else returns the decoded engine response as it came: L</privileges>
an ArrayRef of privilege HashRefs, L</install>, L</upgrade> and L</push> an
ArrayRef of progress events, and L</enable>, L</disable>, L</remove> and
L</configure> C<undef>.

=head2 client

Reference to L<API::Docker> client. Weak reference to avoid circular dependencies.

=head2 list

    my $plugins = $plugins->list;
    my $enabled = $plugins->list(filters => { enabled => ['true'] });

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


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

Get the privileges a plugin demands, without installing it. Returns an
ArrayRef of HashRefs:

    [ { Name => 'network', Description => '', Value => ['host'] },
      { Name => 'mount',   Description => '', Value => ['/var/lib/docker/plugins/'] } ]

This is the first half of the install; see L</"Installing is two calls, and
the engine enforces it">. Reading it is the point -- the result is what you
hand to L</install>, and the daemon accepts the install only if the two
lists agree.

A plugin that demands nothing answers with an empty ArrayRef.

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

Options:

=over

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

=back

=head2 install

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

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


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

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

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

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

=back

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

=head2 Progress as it arrives

Without a callback the whole stream is read before anything is parsed, so
pulling a plugin is silence until it is done. Pass C<on_event> and the events
are handed over as the daemon sends them:

    my $summary = $plugins->install('vieux/sshfs:latest',
        privileges => $privileges,
        on_event   => sub {
            my ($event, $stop) = @_;
            print $event->{status}, "\n" if defined $event->{status};
        },
    );

    $summary;   # { delivered => 18, stopped => 0 }

With a callback the return value is that summary HashRef, not the events:
C<delivered> is how many went to the callback, C<stopped> is 1 when the
callback ended the stream and 0 when the daemon did. Nothing is accumulated.
See L<API::Docker::Role::HTTP/"Streaming a response as it arrives">.

The C<errorDetail> check runs on this path too, per event rather than over the
finished list, so a failure inside the 200 stream still croaks with an
L<API::Docker::Error::Stream> -- at the event that reports it, and carrying
that one event alone rather than the whole stream. It is the difference
L<API::Docker::API::Images/"A failed build still croaks, one event earlier">
describes, and it applies here identically. A caller that wants the progress
that preceded a failure must collect it in the callback.

L</upgrade> and L</push> take C<on_event> on the same terms.

A failed install croaks by one of two routes, exactly as
L<API::Docker::API::Images/pull> does, because the daemon commits to HTTP 200
the moment it flushes the first progress object. A failure before that point
arrives as a real error status -- C<incorrect privileges> is reported this
way, since it is decided before anything is pulled -- and one after it
arrives as an C<errorDetail> object inside the 200 stream, which croaks with
an L<API::Docker::Error::Stream>. C<eval> and inspect C<$@> as a string
rather than testing for the exception class.

=head2 inspect

    my $plugin = $plugins->inspect('vieux/sshfs:latest');
    say $plugin->enabled;

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

Options:

=over

=item * C<force> - Disable the plugin before removing it. Removing a plugin
that containers are still using will break them

=back

Returns C<undef>. The Engine API reference documents a C<Plugin> object as
the 200 response body here; the daemon writes no body at all.

=head2 enable

    $plugins->enable('vieux/sshfs:latest');
    $plugins->enable('vieux/sshfs:latest', timeout => 30);

Enable an installed plugin. Returns C<undef>.

Options:

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

C<accept_privileges> is set

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

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

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

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

=back

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

=head2 push

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

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

Options:

=over

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

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

=back

Unlike L<API::Docker::API::Images/push>, which sends C<X-Registry-Auth> on
every call because the engine rejects an image push without it, this sends
the header only when C<auth> is given: the plugin router decodes the header
and discards a decoding failure, so an anonymous push needs no header. The
Engine API reference documents no header on this endpoint at all; the daemon
reads it.

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

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

=head2 configure

    $plugins->configure('vieux/sshfs:latest', ['DEBUG=1']);
    $plugins->configure('vieux/sshfs:latest', 'DEBUG=1', 'sshkey.source=/tmp');

Set a plugin's user-configurable settings (C<< POST /plugins/{name}/set >>).

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

reports; L</inspect> is how you find out which ones a given plugin has.

The engine replaces nothing it is not told about, and rejects a key the
plugin's config does not declare as mutable.

    $plugins->configure('vieux/sshfs:latest', ['DEBUG=1']);
    $plugins->configure('vieux/sshfs:latest', 'DEBUG=1');

Both forms mean the same call, and this method takes no options in either:
anything after the ArrayRef croaks rather than being read as a setting or
quietly dropped. To bound the request, clone the resource class --
C<< $docker->plugins->using(read_timeout => 5)->configure(...) >>, see
L<API::Docker::Role::Using>.

=head1 SEE ALSO

=over

=item * L<API::Docker::Role::Entity::Plugin> - the convenience methods the
returned objects carry



( run in 3.007 seconds using v1.01-cache-2.11-cpan-5c0b1e786e0 )