CrowdControl.Backend.Sandboxd (crowd_control v0.2.0)

Copy Markdown View Source

Drives a CLI through the sandboxd agent over HTTP, on any substrate.

Requires the optional :req dependency and a CrowdControl.Provider:

CrowdControl.run("hello",
  backend:
    {CrowdControl.Backend.Sandboxd,
     provider: {CrowdControl.Provider.Docker, image: "crowd_control/sandbox:dev", egress: :allow}}
)

Why this exists next to CrowdControl.Backend.Docker

Backend.Docker is a transport bolted to a substrate: it knows both how to create a container and how to move bytes through a FIFO and a tee file. Backend.Kubernetes had to reimplement the second half for pods. A VM has no exec API at all, so a third substrate meant a third transport.

This backend splits those apart. Bytes always move the same way — one HTTP protocol to one agent — and CrowdControl.Provider owns the substrate. A new substrate is provisioning code and nothing else.

Backend.Docker is not deprecated and is not going anywhere. It works with any image that has sh and tail; this one needs an image containing our agent. Which trade you want is yours to make.

Callback mapping

CallbackImplementation
provision/1CrowdControl.Provider.acquire/1, which returns only once GET /v1/health answers
exec/4CrowdControl.Agent.sandbox_files/1 via PUT /v1/files, then POST /v1/exec with env in the JSON body
start_reader/3GET /v1/stream?offset=N with Req into: :self — plain bytes, no demux
write/2POST /v1/stdin {data: base64}
await_exit/2GET /v1/status?wait_ms=…, long-polled server-side
alive?/1GET /v1/health
destroy/1CrowdControl.Provider.release/1, idempotent
list_live/1CrowdControl.Provider.list_live/1, so CrowdControl.Reaper needs no change
reattach/2CrowdControl.Provider.reconnect/1, then start_reader/3 at the persisted offset
scrub/1drop the endpoint, scrub the config, then CrowdControl.Provider.scrub/1

Agent configuration files land here before the CLI starts

A CLI that reads configuration from disk cannot be configured by argv or by environment alone: CrowdControl.Agent.Omp resolves a custom provider's baseUrl out of models.yml, and on a remote substrate a host temp directory is not visible to it. exec/4 asks the resolved CrowdControl.Agent adapter for its CrowdControl.Agent.sandbox_files/1 and PUTs each one before the POST /v1/exec, so the file is there when the CLI opens it. See push_file/4.

Live and resume are the same code path

start_reader/3 at offset 0 is the resume path. The agent's capture file is byte-for-byte the same artifact as Backend.Docker's tee file, so the cursor stays %{byte_offset:, buffer:} and a line split across a crash rejoins byte-exactly. Offsets are 0-indexed here; tail -c +N is 1-indexed, and that + 1 is a documented hazard this transport simply does not have.

The stream ending is not end-of-output

/v1/stream ends either because the CLI is finished or because nothing new arrived within the agent's idle window. Those cannot be distinguished in-band without injecting a keepalive byte into a stream whose offsets are load-bearing, so the reader asks GET /v1/status: still alive, or more bytes than it has consumed, means re-request from the current offset. Otherwise it is EOF.

Backpressure

Identical in shape to Backend.Docker's, because the underlying problem is identical: Req into: :self gives no backpressure, chunks pile into the reader's mailbox whether or not CrowdControl.Session keeps up, and Req has no pause primitive. It has cancellation, and this read is resumable by construction, so "pause" is cancel and "resume" is re-requesting from the offset already delivered. No new mechanism, and nothing is lost or duplicated because the offset is exact.

Nothing secret is persisted

The handle goes into CrowdControl.Store. The endpoint does not: it holds a derived token, a base URL whose port is reassigned on every reconnect, and possibly a live tunnel. scrub/1 drops it wholesale and the token is re-derived from the persisted session_key on reattach. Rotating :sandboxd_secret therefore fails reattach closed with {:sandboxd, :unauthorized}, which is the intended trade against a live credential at rest.

Summary

Functions

Age of the sandbox in milliseconds, delegated to the provider.

Write a file inside the sandbox.

Types

t()

@type t() :: %CrowdControl.Backend.Sandboxd{
  config: keyword(),
  endpoint: CrowdControl.Provider.Endpoint.t() | nil,
  owner: String.t() | nil,
  provider: module() | nil,
  provider_handle: term(),
  session_key: String.t() | nil
}

Functions

age_ms(handle)

@spec age_ms(t()) :: non_neg_integer() | nil

Age of the sandbox in milliseconds, delegated to the provider.

CrowdControl.Reaper uses this for the grace period that keeps a mid-provision sandbox from being reaped before its store record exists.

push_file(handle, path, body, mode \\ 384)

@spec push_file(t(), Path.t(), iodata(), non_neg_integer()) :: :ok | {:error, term()}

Write a file inside the sandbox.

Exists for CrowdControl.Agent.Omp's :agent_dir obligation: a rendered provider config has to land inside the sandbox, and on a remote substrate a host temp directory is not visible there. It is deliberately not CrowdControl.Backend.push_workspace/2 — this ships one rendered file, not a workspace, and general workspace transfer stays out of scope.

exec/4 calls this for each CrowdControl.Agent.sandbox_files/1 entry, so an adapter does not have to reach for it. It stays public because a caller driving this backend directly has no other way in.