One volume-mount shape, shared by every substrate that can mount one.
A sandbox is deliberately empty: the CLI, a FIFO, a tee file, and nothing else. Mounting is how real work gets in and out — a workspace to edit, a package cache worth reusing across sessions, a directory of fixtures.
The shape is the same for Docker, Compose and Kubernetes, so a mount written for one substrate reads the same on the next:
volumes: [
%{name: "workspace", target: "/workspace"},
%{host_path: "/srv/fixtures", target: "/fixtures", read_only: true}
]| Key | Meaning |
|---|---|
:name | A managed volume: a Docker named volume, or a Kubernetes PersistentVolumeClaim of that name |
:host_path | A path on the machine running the container: a Docker bind mount, or a Kubernetes hostPath |
:target | Absolute path inside the sandbox. Required |
:read_only | Default false |
Exactly one of :name or :host_path. They are separate keys rather than one
overloaded :source because they are not the same decision: a named volume is
storage this stack owns, and a host path is a hole into the machine. Code
review can grep for one and not the other.
What this does not do
It does not create anything. A :name must already exist as a Docker volume
or a bound PVC — Provider.Compose is the exception, since a Compose stack
declares and owns its named volumes. Provisioning storage needs privileges
running sandboxes does not justify, the same reasoning that keeps this library
from creating Kubernetes namespaces.
Security
:host_path hands the sandbox part of the host filesystem. Under
read_only: true that is a disclosure boundary; without it, it is a write
boundary, and /var/run/docker.sock or a kubelet directory is a full escape.
Untrusted model-driven code plus a writable host mount is not a sandbox. See
SECURITY.md.
A target may not collide with the paths the transport owns (:fifo_path,
:tee_path, :env_path) or shadow their directories. Mounting over the FIFO
does not fail loudly, it produces a sandbox whose reader never delivers a
byte, so it is refused here instead.
Summary
Types
Functions
@spec normalize( keyword(), [String.t()] ) :: {:ok, [normalized()]} | {:error, term()}
Validates and normalizes :volumes from an option list.
reserved are transport-owned paths the mounts must not collide with; pass
the substrate's fifo, tee and env paths. Returns {:ok, []} when :volumes
is absent, so a caller can thread this unconditionally.
Errors are tagged by the caller's substrate, e.g.
{:error, {:docker, {:invalid_volume, reason}}}, by matching on the reason
this returns.
iex> CrowdControl.Volume.normalize([volumes: [%{name: "w", target: "/w"}]], [])
{:ok, [%{kind: :volume, source: "w", target: "/w", read_only: false}]}
iex> CrowdControl.Volume.normalize([volumes: [%{target: "/w"}]], [])
{:error, {:invalid_volume, %{target: "/w"}, :source_required}}
iex> CrowdControl.Volume.normalize([volumes: [%{name: "w", target: "/f"}]], ["/f"])
{:error, {:invalid_volume, %{name: "w", target: "/f"}, {:reserved_target, "/f"}}}
@spec normalize!( keyword(), [String.t()] ) :: [normalized()]
normalize/2 or raise.
For call sites downstream of a provision/1 that already validated, where a
bad mount means the validation was skipped rather than that a caller passed
something wrong — a bug worth a readable crash instead of a MatchError.