CrowdControl.Backend.Sandboxd.API (crowd_control v0.2.0)

Copy Markdown View Source

Every HTTP call to a sandboxd agent, and the one error vocabulary they produce.

Client confinement, exactly as CrowdControl.Backend.Docker.API and CrowdControl.Backend.Kubernetes.API do it: no other module builds a Req request to an agent, and no other module invents an error shape. CrowdControl.Session only ever sees {:error, {:sandboxd, _}}.

Vocabulary

  • {:sandboxd, :unauthorized}401. On reattach this most likely means :sandboxd_secret was rotated, so the derived token no longer matches the one the sandbox was started with. That fails closed, deliberately.
  • {:sandboxd, :not_found}404.
  • {:sandboxd, :already_executed}409 from POST /v1/exec. One exec per sandbox lifetime.
  • {:sandboxd, :not_started}409 from POST /v1/stdin before any exec.
  • {:sandboxd, {:conflict, error}} — any other 409.
  • {:sandboxd, {:http_status, status, message}} — anything else non-2xx.
  • {:sandboxd, {:transport, reason}} — the connection failed.
  • {:sandboxd, {:unexpected_body, body}} — a 2xx whose shape is wrong.
  • {:sandboxd, :ready_timeout} — from await_health/2 only.

Authentication

authorization: Bearer <endpoint.token>, then endpoint.headers merged over it. That order matters: a provider whose transport claims authorization for its own authentication (the Kubernetes API server's pod proxy) sets it in headers and moves the agent token to x-cc-authorization, which the agent accepts identically.

Test seam

Transport configuration, including :adapter, rides in endpoint.req_options and is merged into the Req call. A hermetic test builds an endpoint with req_options: [adapter: fn req -> ... end] and needs no daemon, no container, and no socket. CrowdControl.ReqAdapter is what makes a function legal there under Req 0.7, which deprecated it.

Retries are off. Req's default retry: :safe_transient turns one refused connection into 1s + 2s + 4s of backoff, and every caller here treats {:error, _} as authoritative — the reaper's fail-open path most of all.

Summary

Types

Agent status, as reported by GET /v1/status.

Functions

Poll GET /v1/health until it answers, or timeout elapses.

POST /v1/exec. Env travels in the JSON body, never in argv or a query string, and is never logged.

GET /v1/health. The only unauthenticated route, and it returns no state.

PUT /v1/files/*path. Writes body inside the sandbox at mode.

Reject a path with ./.. segments or a null byte, and require it absolute.

POST /v1/shutdown. Kills the CLI and halts the agent.

GET /v1/status, optionally long-polled for up to wait_ms server-side.

GET /v1/stream?offset=N as an into: :self response.

Fold a mid-stream failure into this module's error vocabulary.

POST /v1/stdin, base64-encoded so arbitrary bytes survive JSON.

Types

status()

@type status() :: %{
  alive: boolean(),
  exit_status: integer() | nil,
  bytes: non_neg_integer(),
  started: boolean()
}

Agent status, as reported by GET /v1/status.

Functions

await_health(endpoint, timeout)

@spec await_health(CrowdControl.Provider.Endpoint.t(), timeout()) ::
  :ok | {:error, term()}

Poll GET /v1/health until it answers, or timeout elapses.

This is what makes CrowdControl.Provider.acquire/1's contract satisfiable: provisioning that reports success before the agent answers is the single largest source of flaky remote backends.

exec(endpoint, executable, args, env)

@spec exec(CrowdControl.Provider.Endpoint.t(), String.t(), [String.t()], %{
  optional(String.t()) => String.t()
}) :: :ok | {:error, term()}

POST /v1/exec. Env travels in the JSON body, never in argv or a query string, and is never logged.

health(endpoint, opts \\ [])

@spec health(
  CrowdControl.Provider.Endpoint.t(),
  keyword()
) :: :ok | {:error, term()}

GET /v1/health. The only unauthenticated route, and it returns no state.

put_file(endpoint, path, body, mode \\ 384)

@spec put_file(
  CrowdControl.Provider.Endpoint.t(),
  Path.t(),
  iodata(),
  non_neg_integer()
) ::
  :ok | {:error, term()}

PUT /v1/files/*path. Writes body inside the sandbox at mode.

Traversal is rejected here as well as by the agent. Two checks for one property is not redundancy: the client-side one is the only reason a caller gets a comprehensible error instead of a 400, and the server-side one is the only one that holds against a caller that is not this library.

safe_path(path)

@spec safe_path(Path.t()) :: {:ok, String.t()} | {:error, term()}

Reject a path with ./.. segments or a null byte, and require it absolute.

Public and pure so the check is testable without an agent.

shutdown(endpoint)

@spec shutdown(CrowdControl.Provider.Endpoint.t()) :: :ok

POST /v1/shutdown. Kills the CLI and halts the agent.

Destroying the sandbox is the provider's job. A 404 or a transport error is success here: an agent that cannot be reached is already not running.

status(endpoint, wait_ms \\ 0)

@spec status(CrowdControl.Provider.Endpoint.t(), non_neg_integer()) ::
  {:ok, status()} | {:error, term()}

GET /v1/status, optionally long-polled for up to wait_ms server-side.

A long poll is what stops a session from spinning on a live sandbox that has simply not produced output yet.

stream(endpoint, offset)

@spec stream(CrowdControl.Provider.Endpoint.t(), non_neg_integer()) ::
  {:ok, Req.Response.t()} | {:error, term()}

GET /v1/stream?offset=N as an into: :self response.

Returns the raw Req.Response because the caller must keep it to call Req.parse_message/2 and Req.cancel_async_response/1 — the same cancel-and-resume backpressure loop CrowdControl.Backend.Docker uses. Offsets are 0-indexed: the agent serves a byte offset directly, with none of tail -c +N's 1-indexed hazard.

:receive_timeout is the silent-sandbox watchdog

It is the only thing that turns "container is up, agent answers nothing, ever" into an :eof — nothing else in the stack notices, because the connection stays open and no bytes are owed. When it fires, Req delivers a normal {:error, %Finch.TransportError{reason: :timeout}} message rather than raising, so the reader's existing error path handles it.

It is set to 40s, deliberately longer than the agent's own idle stream window: the agent should end an idle response first, so an ordinary interactive pause produces a clean :done and a re-request rather than looking like a dead sandbox.

stream_error(reason)

@spec stream_error(term()) :: {:sandboxd, term()}

Fold a mid-stream failure into this module's error vocabulary.

Req.parse_message/2 hands back a raw %Finch.TransportError{} for a connection that died under an open stream, and that is the one path where a failure reaches the reader without passing through normalize/1. Without this, the module's promise that callers only ever see {:sandboxd, _} would hold everywhere except the failure mode most likely to end up in a log.

write(endpoint, data)

@spec write(CrowdControl.Provider.Endpoint.t(), iodata()) :: :ok | {:error, term()}

POST /v1/stdin, base64-encoded so arbitrary bytes survive JSON.