CrowdControl.Backend behaviour (crowd_control v0.1.0)

Copy Markdown View Source

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:

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_link reader: 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

Types

Where a reader should resume from.

Backend-opaque session handle.

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.

Functions

A cursor pointing at the start of the stream.

Whether module can reattach to a sandbox that outlived its session.

Resolve the :backend option into {module, opts}.

Run fun, returning default if it exits.

Scrub handle via the backend's scrub/1, if it defines one.

Types

cursor()

@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_offsetSession re-seeds buffer itself before the reader starts. Splitting it this way is what makes mid-line resume byte-exact.

handle()

@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

alive?(handle)

@callback alive?(handle()) :: boolean()

Whether the sandbox is still running.

await_exit(handle, timeout)

@callback await_exit(handle(), timeout()) :: {:ok, integer() | nil} | :timeout

Wait for the CLI to exit. nil status means exited-but-unknown.

destroy(handle)

@callback destroy(handle()) :: :ok

Tear down the sandbox. Must be idempotent — see the module doc.

exec(handle, executable, args, env)

@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_live(opts)

@callback list_live(opts :: keyword()) :: {:ok, [handle()]} | {:error, term()}

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, []}.

provision(opts)

@callback provision(opts :: keyword()) :: {:ok, handle()} | {:error, term()}

Create the sandbox. Called once, before exec/4.

pull_artifacts(handle, t)

(optional)
@callback pull_artifacts(handle(), Path.t()) :: :ok | {:error, term()}

Copy artifacts out of the sandbox. Optional; no backend ships this yet.

push_workspace(handle, t)

(optional)
@callback push_workspace(handle(), Path.t()) :: :ok | {:error, term()}

Copy a local workspace into the sandbox. Optional; no backend ships this yet.

reattach(handle, cursor)

@callback reattach(handle(), cursor()) :: {:ok, handle()} | {:error, term()}

Re-establish control of a sandbox that outlived its session.

Backends without durable sandboxes return {:error, :not_supported}.

scrub(handle)

(optional)
@callback scrub(handle()) :: handle()

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.

start_reader(handle, session_pid, cursor)

@callback start_reader(handle(), session_pid :: pid(), cursor()) ::
  {:ok, pid()} | {:error, term()}

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(handle, iodata)

@callback write(handle(), iodata()) :: :ok | {:error, term()}

Write to the CLI's stdin.

Functions

new_cursor()

@spec new_cursor() :: cursor()

A cursor pointing at the start of the stream.

iex> CrowdControl.Backend.new_cursor()
%{byte_offset: 0, buffer: ""}

reattachable?(module)

@spec reattachable?(module()) :: boolean()

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(opts)

@spec resolve(keyword()) :: {module(), keyword()}

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"]}

safe(fun, default)

@spec safe((-> result), default) :: result | default
when result: term(), default: term()

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} are GenServer.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. A rescue here 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(module, handle)

@spec scrub(module(), handle()) :: handle()

Scrub handle via the backend's scrub/1, if it defines one.

Returns the handle untouched for backends that do not.