Skip to main content

lib/tank.ex

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