CrowdControl.Backend.Docker (crowd_control v0.1.1)

Copy Markdown View Source

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=true

Reading 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 +N is 1-indexed, hence byte_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 infinity a killed CLI left the container Running, alive?/1 answering true, await_exit/2 answering :timeout forever, and tail -f never ending — so no :eof reached the session and the container billed on. The launcher writes the CLI's own status after tee drains, 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 to CrowdControl.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 — default unix:///var/run/docker.sock
  • :network_mode — default "none"; required when :proxy_url or :api_url is 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 in SECURITY.md

Hardening (see Isolation):

  • :cap_drop — default ["ALL"]
  • :security_opt — default ["no-new-privileges:true"]
  • :pids_limit — default 512
  • :user — e.g. "1000:1000"; unset means the image's own user
  • :readonly_rootfs — default false
  • :tmpfs — mounts used when :readonly_rootfs is 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

t()

@type t() :: %CrowdControl.Backend.Docker{
  config: keyword(),
  container_id: String.t() | nil,
  fifo_path: String.t(),
  image: String.t() | nil,
  owner: String.t() | nil,
  session_key: String.t() | nil,
  tee_path: String.t()
}

Functions

age_ms(handle)

@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.

apply_credentials(env, config)

@spec apply_credentials(
  map(),
  keyword()
) :: map()

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.