defmodule Tank do @moduledoc """ 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`. """ require Logger alias Tank.{Pod, Reconciler, Runtime, Store} @type spec :: Pod.t() | map() | keyword() @doc """ 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 apply(spec()) :: :ok | {:error, term()} def apply(spec) do with {:ok, pod} <- to_pod(spec), :ok <- Store.put_pod(pod) do nudge() end end @doc "Remove a pod's desired state, by name or by `%Tank.Pod{}`." @spec delete(String.t() | Pod.t()) :: :ok | {:error, term()} def delete(name) when is_binary(name), do: with(:ok <- Store.delete_pod(name), do: nudge()) def delete(%Pod{name: name}), do: delete(name) @doc "Fetch one declared pod by name." @spec get(String.t()) :: {:ok, Pod.t()} | {:error, :not_found} def get(name) when is_binary(name), do: Store.get_pod(name) @doc "Every declared pod (a fast read through the store's projection)." @spec list() :: [Pod.t()] def list, do: Store.list_pods() @doc """ 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 {: .info} > > `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 exec(String.t(), [String.t()], keyword()) :: {:ok, {:exited, non_neg_integer()} | {:signaled, pos_integer()} | :detached} | {:error, term()} def exec(pod_name, argv, opts \\ []) when is_binary(pod_name) and is_list(argv) and argv != [] do with {:ok, ctx} <- resolve_exec_context(pod_name), {:ok, session} <- Linx.Process.enter(ctx.host_pid, enter_opts(ctx, argv, opts)) do tty_attach(session) end end # Hand the caller's terminal to `session`, picking the mode that fits the # terminal. `:controlling` (a local tty) is preferred: it reads /dev/tty in # raw mode, so Ctrl-C reaches the *container's* foreground process as SIGINT # rather than tripping the BEAM's own break handler — and it gets real # SIGWINCH resize. Over SSH/`:remsh` there is no local tty, so `attach` # refuses with `:no_local_tty` and we fall back to the universal # `:group_leader` pump (which has its own ssh_cli-aware Ctrl-C handling). # Both modes honour the default Ctrl-P Ctrl-Q detach. defp tty_attach(session) do case Linx.Tty.attach(:controlling, session) do {:error, :no_local_tty} -> Linx.Tty.attach(:group_leader, session) result -> result end end # Resolve a pod name to its container's exec context (host pid + env + cwd) # via the reconciler's view of what's running and the owning Tank.Runtime. defp resolve_exec_context(pod_name) do case Map.fetch(Reconciler.running(), pod_name) do {:ok, runtime} -> Runtime.exec_context(runtime) :error -> {:error, :not_running} end end defp enter_opts(ctx, argv, opts) do [ argv: argv, stdio: :pty, auto_proceed: true, cwd: Keyword.get(opts, :cwd, ctx.working_dir), env: exec_env(ctx.env, Keyword.get(opts, :env, [])) ] end # The container's env, a default TERM when it set none (for a usable shell), # then the caller's :env overrides merged on top -- last writer per key wins. defp exec_env(container_env, overrides) do has_term? = Enum.any?(container_env, &String.starts_with?(&1, "TERM=")) base = if has_term?, do: container_env, else: ["TERM=xterm" | container_env] merge_env(base, overrides) end defp merge_env(base, overrides) do over_keys = MapSet.new(overrides, &env_key/1) Enum.reject(base, &MapSet.member?(over_keys, env_key(&1))) ++ overrides end defp env_key(kv), do: kv |> String.split("=", parts: 2) |> hd() @doc """ 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`. """ @spec attach(String.t()) :: {:ok, {:exited, non_neg_integer()} | {:signaled, pos_integer()} | :detached} | {:error, term()} def attach(pod_name) when is_binary(pod_name) do case Map.fetch(Reconciler.running(), pod_name) do {:ok, runtime} -> attach_session(runtime) :error -> {:error, :not_running} end end defp attach_session(runtime) do with {:ok, session} <- Runtime.begin_attach(runtime, self()) do try do tty_attach(session) after Runtime.end_attach(runtime) end end end @doc false # Bootstrap seed: write each spec create-if-absent, so config never clobbers # runtime-changed state. Invalid specs and write failures are logged, not # raised -- a bad entry in the seed list shouldn't take down the node. @spec seed([spec()]) :: :ok def seed(specs) when is_list(specs) do Enum.each(specs, fn spec -> with {:ok, pod} <- to_pod(spec), result when result in [:ok, {:error, :exists}] <- Store.create_pod(pod) do :ok else {:error, reason} -> Logger.warning("Tank: skipping seed pod: #{inspect(reason)}") end end) end defp to_pod(%Pod{} = pod), do: {:ok, pod} defp to_pod(spec) when is_map(spec) or is_list(spec), do: Pod.new(spec) defp to_pod(other), do: {:error, {:invalid_pod_spec, other}} # Wake the reconciler so a write converges promptly. Best-effort: a no-op when # no reconciler is running (e.g. a consumer driving Tank.Runtime directly). defp nudge, do: Tank.Reconciler.nudge() end