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.Pod and 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

spec()

@type spec() :: Tank.Pod.t() | map() | keyword()

Functions

apply(spec)

@spec apply(spec()) :: :ok | {:error, term()}

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(pod_name)

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

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

delete(name)

@spec delete(String.t() | Tank.Pod.t()) :: :ok | {:error, term()}

Remove a pod's desired state, by name or by %Tank.Pod{}.

exec(pod_name, argv, opts \\ [])

@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's working_dir (the image WorkingDir).
  • :env — extra environment as ["KEY=VAL", …], merged over the container's own environment. By default the exec session inherits the container's resolved env (image Env + the spec's), exactly like docker exec — so PATH resolves inside the rootfs — plus a default TERM=xterm when 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.

get(name)

@spec get(String.t()) :: {:ok, Tank.Pod.t()} | {:error, :not_found}

Fetch one declared pod by name.

list()

@spec list() :: [Tank.Pod.t()]

Every declared pod (a fast read through the store's projection).