Tank — an opinionated, declarative container orchestrator built on Linx.
You describe the pods that should run as Elixir data; Tank persists that desired state in Khepri and a level-triggered loop converges the machine to it. This module is the runtime write API over the desired state:
Tank.apply(%{
name: "web",
containers: [%{name: "app", image: "nginx:1.27"}]
})
Tank.list() #=> [%Tank.Pod{name: "web", …}]
Tank.delete("web")apply/1 accepts a %Tank.Pod{} or a plain spec map (validated via
Tank.Pod.new/1); it writes to [:tank, :pods, name] in the store. You never
imperatively start a container — you state intent and the reconciler converges.
Architecture
Tank.Podand friends — the typed desired-state model.Tank.Store— the Khepri seam (the source of truth) + an ETS projection.Tank.Runtime— the per-container actuator (Linx.Process+Rtnl), the M2 proof of concept that M4 grows into the pod actuator.
Tank is a separate mix app with a path dependency on Linx, so it reaches only Linx's public API; a gap in the primitives surfaces here, early.
Bootstrap vs. runtime
Khepri is the source of truth. config/runtime.exs only seeds pods
create-if-absent on a fresh store, so the boot seed never clobbers state
changed at runtime via apply/1 / delete/1.
Summary
Functions
Declare a pod's desired state — create it or replace it. Accepts a
%Tank.Pod{} or a spec map/keyword list (validated via Tank.Pod.new/1).
Attach to a tty: true pod's main process — docker attach.
Remove a pod's desired state, by name or by %Tank.Pod{}.
Run an interactive command inside a running pod — docker exec -it.
Fetch one declared pod by name.
Every declared pod (a fast read through the store's projection).
Types
@type spec() :: Tank.Pod.t() | map() | keyword()
Functions
Declare a pod's desired state — create it or replace it. Accepts a
%Tank.Pod{} or a spec map/keyword list (validated via Tank.Pod.new/1).
@spec attach(String.t()) :: {:ok, {:exited, non_neg_integer()} | {:signaled, pos_integer()} | :detached} | {:error, term()}
Attach to a tty: true pod's main process — docker attach.
Where exec/3 runs a second process inside the pod, attach/1 takes over
the pod's main process's terminal: the container is the interactive
program (declare its container with tty: true). Because ending that program
stops the container, leave without killing it by pressing the detach sequence
— Ctrl-P Ctrl-Q — which returns {:ok, :detached} with the pod still
running, ready to re-attach.
Tank.apply(%{
name: "console",
containers: [%{name: "sh", image: "debian:13",
command: ["/bin/bash"], tty: true}]
})
Tank.attach("console") #=> your terminal becomes the pod's bashReturns the session's terminal result — {:ok, {:exited, code}} /
{:ok, {:signaled, signum}} (the program ended — the pod stops and the
reconciler applies its restart policy), {:ok, :detached} (you detached), or
{:error, reason} (:not_running if the pod has no live workload,
:not_a_tty if its container wasn't declared tty: true).
Like exec/3, this runs in and blocks the caller's process and routes the PTY
through it — call it straight from iex.
@spec delete(String.t() | Tank.Pod.t()) :: :ok | {:error, term()}
Remove a pod's desired state, by name or by %Tank.Pod{}.
@spec exec(String.t(), [String.t()], keyword()) :: {:ok, {:exited, non_neg_integer()} | {:signaled, pos_integer()} | :detached} | {:error, term()}
Run an interactive command inside a running pod — docker exec -it.
Resolves the pod's running workload, starts a second process that enters
the container's namespaces (mount → its rootfs, pid → its procs, net/uts/ipc)
with a PTY, and hands the caller's terminal to it. Typing exit ends only
this exec session; the pod's main process keeps running. Exec again, or run
several at once.
Tank.exec("web", ["/bin/bash"])
Tank.exec("web", ["/bin/sh", "-c", "ps aux"], cwd: "/tmp")argv is the command to run (its first element is the program). opts:
:cwd— working directory inside the container. Defaults to the container'sworking_dir(the imageWorkingDir).:env— extra environment as["KEY=VAL", …], merged over the container's own environment. By default the exec session inherits the container's resolved env (imageEnv+ the spec's), exactly likedocker exec— soPATHresolves inside the rootfs — plus a defaultTERM=xtermwhen the container set none, for a usable shell.
Returns the exec's terminal result — {:ok, {:exited, code}} /
{:ok, {:signaled, signum}} — or {:error, reason} (:not_running when the
pod has no live workload, or a Linx.Process / Linx.Tty setup error).
Runs in the caller's process
exec/3 blocks the calling process for the life of the session and routes
the PTY through it, so call it straight from iex (or a process that owns a
terminal). It is deliberately not a cast into another process — the byte
pump must live where the terminal is.
@spec get(String.t()) :: {:ok, Tank.Pod.t()} | {:error, :not_found}
Fetch one declared pod by name.
@spec list() :: [Tank.Pod.t()]
Every declared pod (a fast read through the store's projection).