lib/docker_availability.ex

defmodule DockerAvailability do
  @moduledoc """
  Detects whether Docker is installed and usable on the host system.

  This module provides a small probe for Docker availability. It checks both
  the Docker client executable and the Docker daemon because a host may have
  the `docker` command installed while the daemon is stopped, unreachable, or
  inaccessible to the current user.

  `executable/0` only checks whether the `docker` executable can be found in
  `PATH`. `check/0` performs the full availability check by running Docker
  version commands and collecting the client and server versions. `available?/0`
  is a boolean convenience wrapper around `check/0`.

  The functions in this module do not install Docker, start the Docker daemon,
  or modify Docker state.
  """

  @type check_result ::
          {:ok,
           %{
             executable: Path.t(),
             client_version: String.t() | nil,
             server_version: String.t() | nil
           }}
          | {:error, reason()}

  @type reason ::
          :docker_not_found
          | {:docker_command_failed, non_neg_integer(), String.t()}
          | {:docker_unavailable, non_neg_integer(), String.t()}

  @doc """
  Returns the path to the Docker executable.

  This function searches for `docker` in the current process `PATH` by using
  `System.find_executable/1`.

  Returns `{:ok, path}` when the executable is found.

  Returns `{:error, :docker_not_found}` when the executable is not available in
  `PATH`.

  This function does not check whether the Docker daemon is running. Use
  `check/0` or `available?/0` when daemon connectivity also matters.
  """
  @spec executable() :: {:ok, Path.t()} | {:error, :docker_not_found}
  def executable() do
    case System.find_executable("docker") do
      nil -> {:error, :docker_not_found}
      path -> {:ok, path}
    end
  end

  @doc """
  Returns whether Docker is installed and usable.

  This is a boolean convenience wrapper around `check/0`.

  Returns `true` only when all of the following conditions are satisfied:

    * the `docker` executable is found in `PATH`
    * the Docker client version can be queried
    * the Docker server version can be queried, which implies that the Docker
      daemon is reachable by the current process

  Returns `false` for all error cases, including a missing executable, a failed
  Docker client command, or an unreachable Docker daemon.

  Use `check/0` instead when the caller needs diagnostic details.
  """
  @spec available?() :: boolean()
  def available?() do
    match?({:ok, _}, check())
  end

  @doc """
  Checks whether Docker is installed and usable.

  This function performs the full Docker probe. It first locates the Docker
  executable with `executable/0`, then runs Docker version commands to obtain
  both the client and server versions.

  Returns `{:ok, info}` when Docker is usable. The returned map contains:

    * `:executable` - the resolved path to the Docker executable
    * `:client_version` - the Docker client version reported by the executable
    * `:server_version` - the Docker server version reported by the daemon

  Returns one of the following error tuples:

    * `{:error, :docker_not_found}` when the `docker` executable cannot be found
      in `PATH`
    * `{:error, {:docker_command_failed, status, output}}` when a Docker client
      command fails before daemon availability is established
    * `{:error, {:docker_unavailable, status, output}}` when the Docker server
      version cannot be queried, typically because the Docker daemon is stopped,
      unreachable, or inaccessible to the current user

  `status` is the command exit status and `output` is the trimmed combined
  standard output and standard error from the Docker command.
  """
  @spec check() :: check_result()
  def check() do
    with {:ok, docker} <- executable(),
         {:ok, client_version} <- docker_version(docker, "Client.Version"),
         {:ok, server_version} <- docker_version(docker, "Server.Version") do
      {:ok,
       %{
         executable: docker,
         client_version: client_version,
         server_version: server_version
       }}
    end
  end

  defp docker_version(docker, field) do
    args = ["version", "--format", "{{." <> field <> "}}"]

    case System.cmd(docker, args, stderr_to_stdout: true) do
      {output, 0} ->
        {:ok, String.trim(output)}

      {output, status} when field == "Server.Version" ->
        {:error, {:docker_unavailable, status, String.trim(output)}}

      {output, status} ->
        {:error, {:docker_command_failed, status, String.trim(output)}}
    end
  rescue
    e in ErlangError ->
      {:error, {:docker_command_failed, 127, Exception.message(e)}}
  end
end