Docker Engine API types and codecs in pure OCaml
0

Configure Feed

Select the types of activity you want to include in your feed.

75 1 0

Clone this repository

https://tangled.org/gazagnaire.org/ocaml-docker https://tangled.org/did:plc:pvd5axhqrgia2rapesukj3jd
git@git.recoil.org:gazagnaire.org/ocaml-docker git@git.recoil.org:did:plc:pvd5axhqrgia2rapesukj3jd

For self-hosted knots, clone URLs may differ based on your setup.



README.md

ocaml-docker#

A Docker Engine API in pure OCaml: IO-free request/response types, an Eio client, an Eio server, and a content-aware proxy.

The docker library maps the Engine API JSON bodies to OCaml values and has no IO dependency, so the same definitions back the client, the server, and the conformance tests. The container side of the server is backed by a pluggable runtime (Docker.Runner.BACKEND), with an in-memory store today. The served surface:

  • System. GET /version, /_ping (capabilities carried in headers), /info, /events, and /system/df.
  • Containers. List, create, inspect, remove, rename, and prune, plus the lifecycle verbs (start, stop, kill, restart, pause, unpause, wait) and changes/top.
  • Exec. POST /containers/{id}/exec creates, /exec/{id}/start runs, /exec/{id}/resize resizes, and /exec/{id}/json inspects.
  • Images, networks, and volumes. List, create, inspect, remove, and prune, plus image tag/history and POST /build.

All endpoints are also served under a /v<api> version prefix. A failed request is captured as a typed Docker.Error (HTTP status plus the daemon's message).

Streaming and hijacked routes#

Some endpoints do not return a single JSON body; they stream, and attach/ exec go further by taking over the raw connection. These are served as such, not buffered:

  • Streaming responses. GET /containers/{id}/logs, /stats, and /events write a continuing body. logs and non-TTY attach use Docker's stdcopy multiplexing (Docker.Stdcopy): each frame carries an 8-byte header tagging it stdout or stderr, so the two streams stay separable over one socket.
  • Hijacked connections. POST /containers/{id}/attach and POST /exec/{id}/start answer 101 UPGRADED, after which the socket is no longer HTTP: bytes flow bidirectionally between the client and the process (stdin up, stdout/stderr down). They are served through the Respond.hijack route primitive, which hands the route the raw flow after the upgrade.

The proxy follows these without interpreting them: an upgrade request keeps its Upgrade headers and is byte-spliced both directions, so a hijacked docker exec -it or docker attach passes through transparently and can still be taped for audit.

The container backend is a Docker.Runner.BACKEND. The only one today is the in-memory simulator: it tracks metadata and a simulated lifecycle (created -> running -> exited) but runs no process, so dockr serve requires an explicit --fake flag and refuses to start otherwise. docker ps shows the simulated state plainly (Up (simulated)). A backend that runs real processes is a separate implementation of the same BACKEND signature.

Install#

Requires OCaml >= 5.1.

opam install docker docker-client docker-server

If opam cannot find the packages, they may not yet be released in the public opam-repository. Add the overlay repository, then install the packages you need:

$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install docker docker-client docker-server

The packages are:

Package Module What it is
docker Docker IO-free Engine API types plus nox-json codecs (no IO dep)
docker-client Client Eio client over a Unix socket, plus the dockr CLI
docker-runner Docker.Runner The BACKEND runtime interface and the in-memory simulator
docker-server Server Eio server plus the dockr CLI (serve, proxy, client) and the Proxy/Authz gateway

Usage#

The IO-free docker library decodes an Engine API body into a typed value with of_string, ignoring unknown members so a newer daemon stays readable:

# let body =
    {|{"Version":"29.2.1","ApiVersion":"1.53","Os":"linux","Arch":"arm64"}|}
val body : string =
  "{\"Version\":\"29.2.1\",\"ApiVersion\":\"1.53\",\"Os\":\"linux\",\"Arch\":\"arm64\"}"
# let summary = function
    | Ok (v : Docker.Version.t) ->
        Printf.sprintf "%s (API %s) on %s/%s" v.version v.api_version v.os v.arch
    | Error e -> "error: " ^ e
val summary : (Docker.Version.t, string) result -> string = <fun>
# summary (Docker.Version.of_string body)
- : string = "29.2.1 (API 1.53) on linux/arm64"

The dockr client and the dockr serve simulator drive the same types over a socket:

# Talk to a real Docker daemon
dockr version
dockr ping
dockr info
dockr version -H /var/run/docker.sock

# Run our own daemon (simulator), then point a real client at it
dockr serve --fake -H /tmp/dockr.sock &
curl --unix-socket /tmp/dockr.sock http://localhost/version
DOCKER_HOST=unix:///tmp/dockr.sock docker version

Proxy#

Server.Proxy is a content-aware Docker API gateway: it sits between a client and a real daemon, parses each request head, applies a policy, then byte-splices the body and the response through unchanged. Because it reads the request line, headers, and (optionally) the JSON body, it can do what a path-only proxy (docker-socket-proxy, CetusGuard) cannot: gate on HostConfig.Privileged, rewrite a request body, filter a response, and follow hijacked attach/exec streams without ever interpreting their framing.

A policy is request -> Allow | Deny of int * string | Rewrite of string. The built-ins are:

  • allow_all / read_only: pass everything, or refuse anything that mutates.
  • no_privileged: deny POST /containers/create when Privileged: true.
  • unprivilege: rewrite such a request, flipping the flag to false in place rather than rejecting it.
  • rego engine: delegate the decision to an OPA/Rego policy.

Beyond the verdict it exposes an audit line, a Prometheus metrics collector, a per-request response rewriter, a stream tap, an optional TLS listener, and upstream keep-alive pooling.

# Read-only gateway in front of the real daemon
dockr proxy --listen /tmp/guard.sock --upstream /var/run/docker.sock \
           --policy read-only
DOCKER_HOST=unix:///tmp/guard.sock docker ps      # works
DOCKER_HOST=unix:///tmp/guard.sock docker run ... # denied

# Decide with a Rego/OPA policy instead of a named one
dockr proxy --listen /tmp/guard.sock --rego-file policy.rego

# Terminate TLS for a remote daemon
dockr proxy --listen 0.0.0.0:2376 --tls-cert server.pem --tls-key key.pem

The same policies also run inside the daemon. With --authz, dockr proxy speaks Docker's authorization-plugin protocol (Plugin.Activate, AuthZPlugin.AuthZReq/AuthZRes) on --listen, so a single policy can gate requests either as a standalone gateway or as an in-daemon plugin (Server.Authz).

API#

The interface files are the reference: lib/*.mli for the types and codecs (version.mli, container.mli, exec.mli, ...), and lib/server/proxy.mli for the gateway. Each codec maps one Engine API object and documents its JSON field names.

Tests#

  • test/: unit tests for the codecs (decode, roundtrip, unknown-member tolerance, error capture).

  • test/client/: our client against a real Docker daemon (skips if absent).

  • test/server/: our server driven by our client and by the real docker CLI pointed at our socket (skips if docker is absent), plus the proxy policies, response rewrite, TLS listener, and AuthZ plugin protocol.

  • test/interop/dockerd/: replays responses captured from a real Docker daemon (committed under traces/: /version, /_ping, the versioned /v1.53/version, and a 404 error body) and checks our codecs decode them. The daemon is only contacted to refresh the traces:

    REGEN=1 dune build @regen
    

This is the two-sided conformance harness: the committed traces validate our client against a real server offline, and dockr serve lets a real client (the docker CLI, curl) drive our server.

License#

ISC, see LICENSE.md.