Runs the CLI inside a Docker container over the Engine API.
Requires the optional :req dependency:
{:req, "~> 0.5"}How I/O works, and why there is no hijacked stream
The obvious way to drive a container's stdin/stdout is
POST /containers/{id}/attach, which needs a hijacked TCP connection that
Req cannot speak. This backend avoids that entirely by routing both
directions through the filesystem:
provision POST /containers/create
entrypoint: mkfifo <fifo> && mkdir -p <teedir>
&& wait for <status>, then exit with it
exec POST /containers/{id}/exec (started detached)
sh -c 'echo $$ > <launcher>; exec 3<> <fifo>;
{ <cli> ... <&3; echo $? > <status>.partial; }
| tee <teefile>;
mv -f <status>.partial <status>'
write POST /containers/{id}/exec (started detached)
sh -c 'printf %s <escaped> >> <fifo>'
read POST /containers/{id}/exec (started attached)
tail -c +<byte_offset + 1> -f <teefile>
destroy DELETE /containers/{id}?force=true&v=trueReading a file with tail returns a plain HTTP 200 with
Content-Type: application/vnd.docker.raw-stream, which Req streams
happily. No 101 Upgrade, no raw socket handling, no Mint.WebSocket.
Three details are load-bearing and were all established empirically:
- The FIFO is held open read-write (
exec 3<> <fifo>). A plain< <fifo>redirect sees EOF the moment the first writer detaches, which collapses the pipeline and kills the container — so the second prompt of every session would be lost. tail -c +Nis 1-indexed, hencebyte_offset + 1. Off by one here duplicates a byte per resume, which corrupts the JSON line stream.- PID 1 relays the CLI's exit status, and is not the CLI itself. The CLI
is started later by a detached exec, so PID 1 never spawned it and cannot
reap it; while PID 1 was
sleep infinitya killed CLI left the containerRunning,alive?/1answeringtrue,await_exit/2answering:timeoutforever, andtail -fnever ending — so no:eofreached the session and the container billed on. The launcher writes the CLI's own status afterteedrains, never before, or PID 1 could exit while bytes were still buffered and truncate the tail of the session. A launcher killed before it can report is detected through its pid file, because that hang is the same bug one level up. Identical in shape toCrowdControl.Backend.Kubernetes, and found by asking whether that defect had a twin.
Live and resume are the same code path
start_reader/3 is reattach/2 at offset 0. There is no separate resume
implementation to keep correct: reading from a persisted offset is the only
thing that differs, and CrowdControl.Session re-seeds the partial-line
buffer itself. A line split across a crash therefore rejoins byte-exactly.
The tee file is capped, never rotated
:max_stream_bytes destroys the sandbox when output exceeds it. Rotating the
tee file instead would invalidate every persisted byte offset and silently
corrupt resume — the exact failure the offset cursor exists to prevent. A hard
cap is the correct answer for a bounded resource. Do not "fix" this later.
Options
:image— container image (required):docker_host— defaultunix:///var/run/docker.sock:network_mode— default"none"; required when:proxy_urlor:api_urlis set (see Isolation below):cpus— fractional CPU limit, e.g.1.5:memory— byte limit, e.g.512 * 1024 * 1024:tee_path— default/var/log/cc/out.jsonl:fifo_path— default/var/run/cc.fifo:max_stream_bytes— cap on total output;nil(default) is unbounded:max_inflight_bytes— reader backpressure watermark, default 4 MiB:proxy_url,:session_token— see the egress proxy contract inSECURITY.md
Hardening (see Isolation):
:cap_drop— default["ALL"]:security_opt— default["no-new-privileges:true"]:pids_limit— default512:user— e.g."1000:1000"; unset means the image's own user:readonly_rootfs— defaultfalse:tmpfs— mounts used when:readonly_rootfsis on
Isolation
:network_mode defaults to "none" — a provisioned sandbox has no network
at all. Reaching an egress proxy requires widening it, and that is the moment
the isolation boundary weakens, so the backend refuses to guess: setting
:proxy_url or :api_url without an explicit :network_mode returns
{:error, {:docker, :network_mode_required}}.
Name a network that routes only to your proxy. Never bridge — it grants
general outbound access, which makes the proxy advisory rather than
enforcing, and a sandbox can simply route around it.
Capability hardening is on by default (CapDrop: ALL,
no-new-privileges, PidsLimit: 512) because the code running in here is
model-driven and untrusted, and none of the three breaks an ordinary CLI.
Note that :memory and :cpus do not bound PIDs, so the fork-bomb ceiling
has to be set separately — that is what :pids_limit is for.
:user and :readonly_rootfs are opt-in, because both genuinely break
images that expect root or write outside the tmpfs mounts. Enable them where
your image supports it; SECURITY.md recommends both.
Secrets never enter argv
Environment variables are passed through the exec API's first-class Env
array. They are deliberately not interpolated into the sh -c command
string as export KEY=..., which would place every secret in the shell's
argv — readable by ps inside the container (where untrusted model-driven
code runs) and retrievable afterwards from GET /exec/{id}/json. This is the
remote equivalent of Backend.Local's env-file indirection, and
docker_test.exs greps the container's own ps output to keep it honest.
Backend.Local's env-file mechanism is never used here: the exec API's
Env array already solves the same problem without writing a file into the
sandbox.
Summary
Functions
Milliseconds since the container was created, from its label.
Rewrite the CLI's credential env for egress-proxy mode.
Types
Functions
@spec age_ms(t()) :: non_neg_integer() | nil
Milliseconds since the container was created, from its label.
nil when the label is missing or unparseable. CrowdControl.Reaper uses
this for the grace period that keeps a mid-provision container from being
reaped before its store record exists.
Rewrite the CLI's credential env for egress-proxy mode.
Delegates to CrowdControl.Backend.Credentials.apply_credentials/2, which
Backend.Kubernetes shares — see that module for why there is exactly one
implementation. Kept here as part of the Docker backend's public API.