CrowdControl.Volume (crowd_control v0.3.0)

Copy Markdown View Source

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}
]
KeyMeaning
:nameA managed volume: a Docker named volume, or a Kubernetes PersistentVolumeClaim of that name
:host_pathA path on the machine running the container: a Docker bind mount, or a Kubernetes hostPath
:targetAbsolute path inside the sandbox. Required
:read_onlyDefault 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

A normalized mount: source kind, source, absolute target, read-only.

t()

Functions

Validates and normalizes :volumes from an option list.

Types

normalized()

@type normalized() :: %{
  kind: :volume | :host_path,
  source: String.t(),
  target: String.t(),
  read_only: boolean()
}

A normalized mount: source kind, source, absolute target, read-only.

t()

@type t() :: %{
  optional(:name) => String.t(),
  optional(:host_path) => String.t(),
  :target => String.t(),
  optional(:read_only) => boolean()
}

Functions

normalize(opts, reserved)

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

normalize!(opts, reserved)

@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.