Skip to content

Repository files navigation

docker-api-ng

A Ruby client for the modern Docker Engine API.

  • No runtime dependencies. Everything it needs ships with Ruby.
  • Complete API coverage, by construction. All 108 Engine API operations are generated from Docker's own OpenAPI specification, so nothing is missing and keeping up with Docker is a reviewable diff rather than manual archaeology.
  • An ergonomic layer on top. Collections and resource objects for the things you touch every day, with the generated layer underneath whenever you need something it has not grown sugar for.
  • Testable without a daemon. The whole client can be driven by a scripted transport, so tests are fast, hermetic and honest.
require "docker/api"

client = Docker::API::Client.new

client.images.pull("alpine:3.20")
container = client.containers.create(image: "alpine:3.20", name: "hello", cmd: %w{sleep 60})
container.start

result = container.exec(%w{cat /etc/alpine-release})
result.stdout   #=> "3.20.3\n"
result.exit_code #=> 0

container.remove(force: true)

Installation

gem "docker-api-ng"

Requires Ruby 3.1 or newer and a daemon speaking Engine API v1.41 or newer (Docker 20.10+).

Connecting

With no arguments, a client resolves its daemon the way the docker CLI does: DOCKER_HOST, then the active Docker context (DOCKER_CONTEXT, then currentContext in ~/.docker/config.json), falling back to the platform's default socket. TLS material comes from DOCKER_CERT_PATH and DOCKER_TLS_VERIFY, or from the context itself.

client = Docker::API::Client.new

client = Docker::API::Client.new(url: "unix:///var/run/docker.sock")
client = Docker::API::Client.new(url: "npipe:////./pipe/docker_engine")   # Windows
client = Docker::API::Client.new(
  url: "tcp://build.internal:2376",
  tls: { ca_file: "ca.pem", cert_file: "cert.pem", key_file: "key.pem" }
)

A client owns its configuration and its connection, and there is no global state behind it. Two clients talking to two daemons share nothing:

local = Docker::API::Client.new
build = Docker::API::Client.new(url: "tcp://build.internal:2376")

See docs/connecting.md for version negotiation, timeouts, logging and TLS in detail.

Containers

client.containers.all(all: true)          # => [Container]
client.containers.get("web")              # => Container, raises NotFound
client.containers.find("web")             # => Container or nil

container = client.containers.create(
  image: "alpine:3.20",
  name: "worker",
  cmd: %w{sleep 3600},
  env: ["LOG_LEVEL=debug"],
  host_config: { "Binds" => ["/data:/data:ro"] }
)

container.start
container.stop(timeout: 10)
container.remove(force: true, volumes: true)

Body attributes may be written in snake_case and are translated to the daemon's spelling. Keys already in Docker's convention pass through untouched, so a configuration copied out of Docker's documentation works as-is.

Resources answer the same question the same way

GET /containers/json reports Names: ["/web"]. GET /containers/{id}/json reports Name: "/web". Resources here normalise that, so code does not have to know which call produced the object it is holding:

listed    = client.containers.all.first    # from a list
inspected = client.containers.get("web")   # from an inspect

listed.name    == inspected.name    # => true
listed.state   == inspected.state   # => true
listed.ports   == inspected.ports   # => true

An object built from a list marks itself partial?. The first accessor that needs detail the list did not carry fetches it once, rather than returning nil and leaving you to guess why. The untouched payload is always available as #raw.

Running commands

result = container.exec(%w{chef-client -z}, env: { "TERM" => "xterm" }) do |stream, chunk|
  logger << chunk if stream == :stdout
end

result.stdout
result.stderr
result.exit_code
result.success?

Without a TTY the daemon multiplexes both streams down one connection with an eight-byte frame header. That is decoded for you, so :stdout and :stderr arrive separately instead of interleaved. A non-zero exit is returned rather than raised — whether a failing command is an error depends on why you ran it. Use result.check! when it is.

Images

client.images.pull("alpine:3.20", platform: "linux/arm64") { |event| puts event["status"] }
client.images.build(context: "./app", tag: "app:dev") { |event| print event["stream"] }
client.images.build(dockerfile: "FROM alpine\nRUN apk add curl\n", tag: "curl:dev")
client.images.ensure("alpine:3.20")   # pull only if absent

Registry credentials are resolved per call from ~/.docker/config.json, including credsStore and credHelpers, so pulling from two private registries in one process needs no setup between calls.

Networks and volumes

network = client.networks.ensure("dokken", ipv6: true)
network.connect(container, aliases: %w{web})
network.disconnect(container)

volume = client.volumes.create("cache", labels: { "team" => "infra" })
volume.remove

ensure treats losing a creation race as success, because two processes racing to create the same shared network is ordinary rather than exceptional.

The daemon itself

client.system.info
client.system.version
client.system.ping?      # => true / false, never raises
client.system.podman?    # Podman wearing Docker's API
client.system.rootless?

client.system.events(filters: { "type" => ["container"] }) do |event|
  puts "#{event["Action"]} #{event.dig("Actor", "Attributes", "name")}"
end

Everything else

The generated layer is public API, not an escape hatch. Every one of the 108 Engine API operations is there, documented, typed, and named after the specification's own operationId:

client.operations.container_prune(filters: { "until" => ["24h"] })
client.operations.swarm_init(body: { "ListenAddr" => "0.0.0.0:2377" })
client.operations.service_list(filters: { "name" => ["web"] })
client.operations.node_list
client.operations.secret_create(body: { "Name" => "token", "Data" => encoded })
client.operations.plugin_list

The ergonomic collections above cover what most code reaches for. Anything they have not grown sugar for is reachable here with no loss of capability, and the list of ergonomic wrappers grows over time without ever being a coverage bottleneck.

Errors

Everything this gem raises descends from Docker::API::Error, and nothing from beneath the abstraction escapes — no Errno, no OpenSSL, no Net:: class reaches your rescue clause. The original is always kept as #cause.

begin
  client.containers.get("missing")
rescue Docker::API::NotFound => e
  e.operation  #=> "container_inspect"
  e.status     #=> 404
  e.message    #=> "container_inspect failed (HTTP 404): No such container: missing"
rescue Docker::API::ConnectionError => e
  e.cause      #=> #<Errno::ENOENT ...>
end

See docs/errors.md for the full hierarchy.

Testing code that uses this gem

Docker::API::Transport::Fake scripts a daemon over a real socket pair, so tests exercise the actual request path with no network and no daemon:

fake = Docker::API::Transport::Fake.new([
  "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 26\r\n\r\n" \
  '{"Id":"abc","Name":"/web"}',
])
client = Docker::API::Client.new(transport: fake, api_version: "1.55")

client.containers.get("web").name  #=> "web"
fake.requests.first                #=> "GET /v1.55/containers/web/json HTTP/1.1\r\n..."

The socket is genuine on purpose: Net::BufferedIO calls read_nonblock, write and to_io on whatever it is handed, and a StringIO does not honour that contract. Faking it produces tests that pass against something the real code could never drive.

How it stays current

The API surface is generated from data/swagger/v1.55.yaml, a vendored copy of Docker's own specification:

$ bundle exec rake api:sync[1.56]     # fetch a newer spec and regenerate
$ git diff --stat                     # review what changed

A new endpoint appears as a new method. A removed parameter vanishes from the signature and the type checker names every caller that still passes it. Generated files are committed, so installing the gem needs no toolchain, and upgrading the supported API version is a pull request somebody reads.

See docs/extending.md.

Documentation

Guide What it covers
Connecting URLs, TLS, named pipes, version negotiation, timeouts, logging
Errors The hierarchy, what each one means, retrying
Running commands exec, attach, logs, stream multiplexing
Building images Build contexts, .dockerignore, build args, registries
Streaming The three wire formats and how they are decoded
Extending The generator, adding ergonomics, upgrading the API version
Migrating from docker-api A call-by-call table

Relationship to the docker-api gem

This is not a fork and keeps no compatibility with docker-api. It was written because a few of that gem's design decisions are difficult to work around from outside: process-global connection and credential state, hand-transcribed parameters that silently drop what they miss, Excon exceptions reaching callers, and no Windows named pipe support.

Everything here lives under Docker::API and nothing is added to ::Docker, so both gems can be loaded into one process while a migration is in progress.

Licence

Apache-2.0. See LICENSE.

About

A modern Docker API implementation in Ruby

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages