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) andchanges/top. - Exec.
POST /containers/{id}/execcreates,/exec/{id}/startruns,/exec/{id}/resizeresizes, and/exec/{id}/jsoninspects. - Images, networks, and volumes. List, create, inspect, remove, and
prune, plus imagetag/historyandPOST /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/eventswrite a continuing body.logsand non-TTYattachuse Docker'sstdcopymultiplexing (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}/attachandPOST /exec/{id}/startanswer101 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 theRespond.hijackroute 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: denyPOST /containers/createwhenPrivileged: true.unprivilege: rewrite such a request, flipping the flag tofalsein 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 realdockerCLI 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 undertraces/:/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.