lib/openagents/forge/relup_topology.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.Forge.RelupTopology do
  @moduledoc """
  Classifies the running application topology for OTP release handling.

  `:release_handler.install_release/1` starts by building the set of processes
  it will suspend, code-change, and resume. For each running application
  `release_handler_1:get_master_procs/3` asks
  `:supervisor.get_callback_module/1` for the application's top supervisor. That
  function reads the process state as a `supervisor` record, so it raises
  `badrecord` for any application whose top process is not an OTP `supervisor` —
  an Elixir `DynamicSupervisor`, a Horde supervisor, or a bare `GenServer`
  returned from `Application.start/2`.

  `libring` is the concrete case. `HashRing.App.start/2` returns the pid of a
  `DynamicSupervisor`, so OTP logs `cannot find top supervisor for application
  libring` and drops that supervisor from the set it upgrades. The application's
  own top process is then never suspended and never code-changed while the
  release installs around it, and `:release_handler.check_install_release/1`
  never looks, so nothing refuses before the install begins.

  Neither outcome is a correct upgrade, so this module refuses first. It runs
  on the target node, reads only process structure, and returns a bounded list
  naming the application and its registered supervisor. A candidate it refuses
  is still eligible for the immutable rolling replacement path, which does not
  depend on OTP release handling.
  """

  @schema "openagents.relup-topology.v1"

  # A refusal is an operational message, not a dump. Enough entries to see the
  # shape of the problem, each short enough to survive a bounded receipt.
  @maximum_entries 16
  @maximum_entry_bytes 96

  @doc """
  Report every running application OTP release handling cannot inspect.

  Returns a bounded, content-free map. `"incompatible"` names each application
  whose top supervisor `:supervisor.get_callback_module/1` refuses to identify.
  """
  def report(opts \\ []) do
    applications = applications(opts)

    incompatible =
      applications
      |> Enum.flat_map(&describe(&1, opts))
      |> Enum.sort()
      |> Enum.take(@maximum_entries)

    %{
      "schema" => @schema,
      "applications" => length(applications),
      "incompatible" => incompatible
    }
  end

  @doc """
  Refuse when any running application has a top supervisor OTP cannot identify.

  Returns `:ok`, or `{:error, {:incompatible_topology, entries}}` where each
  entry is `"application:Supervisor"`.
  """
  def refuse(opts \\ []) do
    case report(opts)["incompatible"] do
      [] -> :ok
      entries -> {:error, {:incompatible_topology, entries}}
    end
  end

  @doc """
  Identify one running application's top supervisor the way OTP does.

  Returns `{:ok, callback_module}` for an application OTP can inspect,
  `:no_supervision_tree` for a library application or one without an
  application master, and `{:error, reason}` when the top process is not an OTP
  supervisor.
  """
  def top_supervisor(application, opts \\ []) when is_atom(application) do
    case master(application, opts) do
      master when is_pid(master) -> root_callback(master, opts)
      _other -> :no_supervision_tree
    end
  end

  defp describe(application, opts) do
    case top_supervisor(application, opts) do
      {:error, _reason} -> [entry(application, opts)]
      _compatible -> []
    end
  end

  defp entry(application, opts) do
    supervisor =
      case master(application, opts) do
        master when is_pid(master) -> root_name(master, opts)
        _other -> "unknown"
      end

    "#{application}:#{supervisor}"
    |> String.slice(0, @maximum_entry_bytes)
  end

  defp root_callback(master, opts) do
    with {:ok, root} <- root(master, opts) do
      callback = Keyword.get(opts, :callback_module, &:supervisor.get_callback_module/1)

      try do
        case callback.(root) do
          module when is_atom(module) -> {:ok, module}
          _other -> {:error, :unreadable_top_supervisor}
        end
      rescue
        _error -> {:error, :unreadable_top_supervisor}
      catch
        _kind, _value -> {:error, :unreadable_top_supervisor}
      end
    end
  end

  defp root_name(master, opts) do
    with {:ok, root} <- root(master, opts) do
      case Process.info(root, :registered_name) do
        {:registered_name, name} when is_atom(name) and name != nil ->
          name |> Atom.to_string() |> String.replace_prefix("Elixir.", "")

        _other ->
          "unregistered"
      end
    end
    |> case do
      name when is_binary(name) -> name
      _other -> "unknown"
    end
  end

  defp root(master, opts) do
    child = Keyword.get(opts, :application_child, &:application_master.get_child/1)

    try do
      case child.(master) do
        {root, _module} when is_pid(root) -> {:ok, root}
        _other -> {:error, :no_top_supervisor}
      end
    rescue
      _error -> {:error, :no_top_supervisor}
    catch
      _kind, _value -> {:error, :no_top_supervisor}
    end
  end

  defp master(application, opts) do
    lookup = Keyword.get(opts, :application_master, &:application_controller.get_master/1)

    try do
      lookup.(application)
    rescue
      _error -> :undefined
    catch
      _kind, _value -> :undefined
    end
  end

  defp applications(opts) do
    Keyword.get(opts, :applications, &running_applications/0).()
  end

  # The same list `release_handler_1:get_application_names/0` folds over.
  defp running_applications do
    for {application, _description, _version} <- Application.started_applications(),
        do: application
  end
end