Behaviour for the sandbox that a CrowdControl.Session drives.
A backend owns everything transport-specific about running a CLI: where the
process lives, how bytes get in and out of it, and how it is torn down.
CrowdControl.Session owns everything else — line splitting, JSON decoding,
message accumulation, subscriber broadcast, timeouts — and never learns which
backend it is talking to.
Three implementations ship:
CrowdControl.Backend.Local— a local subprocess viaNetRunner. The default, and behaviourally identical to pre-behaviour CrowdControl.CrowdControl.Backend.Docker— a container over the Docker Engine API.CrowdControl.Backend.Kubernetes— a Pod over the Kubernetes API server, session-facing semantics indistinguishable from the Docker one.
Selecting a backend
CrowdControl.Session.start_link(backend: CrowdControl.Backend.Local)
CrowdControl.Session.start_link(
backend: {CrowdControl.Backend.Docker, image: "my-cli:latest"}
)The {module, config} form merges config into the session opts before
provision/1 is called. A bare module is equivalent to {module, []}.
Two callbacks that are deliberately not what you would guess
read/1 is not a callback. A blocking synchronous read is the right shape
for a NIF-backed pipe and the wrong shape for a streamed HTTP body. Instead,
start_reader/3 inverts the control flow: the backend is handed the session
pid and becomes responsible for delivering data to it. How it does that is
opaque — a blocking loop in a linked process, an async HTTP stream, anything.
kill/2 is not a callback. :sigterm/:sigkill is POSIX vocabulary that
a remote sandbox does not have. destroy/1 is the only teardown primitive;
a backend that does have signals (like Local) implements its own
escalation behind it.
The reader contract
start_reader/3 must:
- deliver output as
GenServer.cast(session_pid, {:stdout_data, binary}) - deliver end-of-stream as
GenServer.cast(session_pid, :eof)— exactly once, and also on transport error, so the session is never left hanging - return a pid that is linked to the calling process, preserving the
crash semantics of the original
spawn_linkreader: if the reader dies, the session dies with it rather than silently going deaf
The destroy contract
destroy/1 must be idempotent. Session calls it from both
handle_cast(:eof, _) and terminate/2, and those can both run for a single
session. It must also tolerate a handle whose underlying resource is already
gone — a 404 from a remote API is success, not failure.
Error normalization
Callbacks should return tagged tuples, not raise. Remote backends see failure
shapes that local ones do not (%Req.TransportError{}, :timeout, HTTP 5xx),
and normalizing them is the backend's job so that Session only ever has
to reason about one vocabulary. See safe/2.
Summary
Callbacks
Whether the sandbox is still running.
Wait for the CLI to exit. nil status means exited-but-unknown.
Tear down the sandbox. Must be idempotent — see the module doc.
Start the CLI inside the sandbox.
List sandboxes this backend currently has running.
Create the sandbox. Called once, before exec/4.
Copy artifacts out of the sandbox. Optional; no backend ships this yet.
Copy a local workspace into the sandbox. Optional; no backend ships this yet.
Re-establish control of a sandbox that outlived its session.
Return a copy of handle with credentials removed, for persistence.
Begin delivering output to session_pid, resuming from cursor.
Write to the CLI's stdin.
Types
@type cursor() :: %{byte_offset: non_neg_integer(), buffer: binary()}
Where a reader should resume from.
byte_offset counts bytes already delivered to the session; buffer is the
partial line left over from the last delivery. A backend consumes only
byte_offset — Session re-seeds buffer itself before the reader starts.
Splitting it this way is what makes mid-line resume byte-exact.
@type handle() :: term()
Backend-opaque session handle.
Session never inspects this. It must survive :erlang.term_to_binary/1 if
the backend supports reattach, since CrowdControl.Store persists it.
Callbacks
Whether the sandbox is still running.
Wait for the CLI to exit. nil status means exited-but-unknown.
@callback destroy(handle()) :: :ok
Tear down the sandbox. Must be idempotent — see the module doc.
@callback exec(handle(), executable :: String.t(), args :: [String.t()], env :: map()) :: {:ok, handle()} | {:error, term()}
Start the CLI inside the sandbox.
Returns an updated handle so backends can thread exec-specific state (a Docker exec id, a tee path) without a second struct.
List sandboxes this backend currently has running.
Used by CrowdControl.Reaper for boot reconciliation, so the result must
be scoped to this node's owner id — a global list would let one node reap
another's sandboxes. Backends that cannot outlive their session return
{:ok, []}.
Create the sandbox. Called once, before exec/4.
Copy artifacts out of the sandbox. Optional; no backend ships this yet.
Copy a local workspace into the sandbox. Optional; no backend ships this yet.
Re-establish control of a sandbox that outlived its session.
Backends without durable sandboxes return {:error, :not_supported}.
Return a copy of handle with credentials removed, for persistence.
A handle often carries the backend config it was built from, and that config
can contain an API key. CrowdControl.Store records outlive the VM — on disk,
with Store.DETS — so a handle must be safe to write down. Nothing about
reattaching needs a credential: the sandbox already has its environment.
Optional; backends whose handles hold nothing sensitive can omit it.
Begin delivering output to session_pid, resuming from cursor.
See "The reader contract" in the module doc — the linked-pid and cast-shape requirements are load-bearing.
Write to the CLI's stdin.
Functions
@spec new_cursor() :: cursor()
A cursor pointing at the start of the stream.
iex> CrowdControl.Backend.new_cursor()
%{byte_offset: 0, buffer: ""}
Whether module can reattach to a sandbox that outlived its session.
Session uses this to decide whether persisting to CrowdControl.Store is
worth the per-chunk write. A backend whose sandbox dies with the session has
nothing to reattach to, so the write would be pure overhead.
Resolve the :backend option into {module, opts}.
Accepts a bare module or a {module, config} tuple, and merges any config
into opts. Defaults to CrowdControl.Backend.Local.
iex> CrowdControl.Backend.resolve([])
{CrowdControl.Backend.Local, []}
iex> CrowdControl.Backend.resolve(backend: {CrowdControl.Backend.Local, image: "x"})
{CrowdControl.Backend.Local, [image: "x"]}
Run fun, returning default if it exits.
Every teardown-path call into a backend goes through this. The discipline it encodes is narrow on purpose:
NetRunner.Process.{await_exit,alive?,kill}areGenServer.calls, so a dead or stale daemon raises an:exit, never an:error. Catching that is the difference between a tidy shutdown and a crashed session.- It catches only
:exit. Arescuehere would swallow genuine bugs —UndefinedFunctionError,FunctionClauseError, a typo in a backend — and turn them into silent "sandbox unavailable". Those must surface.
Remote backends have failure shapes that are not exits at all
({:error, %Req.TransportError{}}, an HTTP 500, a :timeout). Normalize
those inside the backend, before they reach Session, so that Session
keeps having exactly one failure vocabulary to handle.
Scrub handle via the backend's scrub/1, if it defines one.
Returns the handle untouched for backends that do not.