defmodule BeamDeploy do @moduledoc """ Blue-green release swaps and in-process hot upgrades for a single Elixir node. `BeamDeploy` keeps a long-lived parent node running locally and serves traffic from a child peer node started with OTP's `:peer` module. When you hand the parent a new `mix release` tarball, it boots a new peer with the new release, lets both peers overlap on the same socket via `SO_REUSEPORT`, then gracefully shuts down the old one. The package is intentionally small: - no storage, polling, or orchestration layer - no Docker or platform coupling - local tarball input only You copy a release tarball onto the host and call either: - `BeamDeploy.upgrade/1` for a blue-green peer swap - `BeamDeploy.hot_upgrade/2` for an in-process hot code reload ## Integration defmodule MyApp.Application do use Application def start(type, args) do BeamDeploy.start_link( otp_app: :my_app, start: {__MODULE__, :start_app, [type, args]}, endpoint: MyAppWeb.Endpoint ) end def start_app(_type, _args) do children = [MyApp.Repo, MyAppWeb.Endpoint] Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) end end Enable parent/peer mode in production with either: config :beam_deploy, enabled: true or: BEAM_DEPLOY=true In environments where BeamDeploy is not enabled, `start_link/1` and `start_link/2` just call your `start_app` MFA directly. ## Upgrades BeamDeploy.upgrade("/tmp/my_app-0.2.0.tar.gz") # => :ok BeamDeploy.hot_upgrade("/tmp/my_app-0.2.0.tar.gz", otp_app: :my_app) # => :ok The tarball must be a standard `mix release` archive built with the same OTP version as the running node. Hot upgrades are only safe for compatible code changes. Use a cold deploy or the blue-green path instead when changing supervision tree shape, upgrading Erlang/OTP, changing major runtime topology, or touching NIFs. """ require Logger @doc """ Starts BeamDeploy with optional parent-level children. Parent children run on the long-lived parent node before the peer manager. This is useful for services that should not restart on every cutover. """ @spec start_link([Supervisor.child_spec()], keyword()) :: Supervisor.on_start() def start_link(children, opts) when is_list(children) and is_list(opts) do do_start_link(children, opts) end @doc """ Starts BeamDeploy without parent-level children. """ @spec start_link(keyword()) :: Supervisor.on_start() def start_link(opts) when is_list(opts) do do_start_link([], opts) end @doc """ Returns `true` when BeamDeploy parent/peer mode is enabled for this runtime. """ @spec enabled?() :: boolean() def enabled? do truthy?(System.get_env("BEAM_DEPLOY")) || Application.get_env(:beam_deploy, :enabled, false) end @doc """ Performs a blue-green upgrade from a local release tarball path. The call can be made from either the parent or active peer node. """ @spec upgrade(Path.t()) :: :ok | {:error, term()} def upgrade(tarball_path) when is_binary(tarball_path) do call_peer_manager(:upgrade, [tarball_path], {:error, :not_running}) end @doc """ Performs an in-process hot upgrade from a local release tarball path. This path does not depend on the parent/peer runtime. It reloads code inside the current node, suspends affected processes, runs `code_change/3`, and resumes them. Supported changes are limited to compatible hot-code updates built with the same Erlang/OTP version. NIF upgrades are skipped. """ @spec hot_upgrade(Path.t(), keyword()) :: :ok | {:error, term()} def hot_upgrade(tarball_path, opts) when is_binary(tarball_path) and is_list(opts) do otp_app = Keyword.fetch!(opts, :otp_app) case BeamDeploy.HotUpgrader.hot_upgrade(tarball_path, otp_app, opts) do {:ok, _stats} -> :ok {:error, reason} -> {:error, reason} end end @doc """ Returns the current blue-green status map. """ @spec status() :: %{active_node: node() | nil, upgrading: boolean()} def status do case call_peer_manager(:get_info, [], nil) do nil -> %{active_node: nil, upgrading: false} info -> info end end @doc """ Returns `true` if a release swap is currently running. """ @spec upgrading?() :: boolean() def upgrading? do case call_peer_manager(:upgrading?, [], false) do value when is_boolean(value) -> value _ -> false end end @doc """ Returns the active peer node, or `nil` when BeamDeploy is not running. """ @spec peer_node() :: node() | nil def peer_node do call_peer_manager(:peer_node, [], nil) end @doc """ Stores handoff data that survives peer transitions. """ @spec put_handoff(term(), term()) :: :ok def put_handoff(key, value) do call_peer_manager(:put_handoff, [key, value], :ok) end @doc """ Reads handoff data from the parent node. """ @spec get_handoff(term()) :: term() | nil def get_handoff(key) do call_peer_manager(:get_handoff, [key], nil) end @doc """ Returns all handoff data as a map. """ @spec get_all_handoff() :: map() def get_all_handoff do call_peer_manager(:get_all_handoff, [], %{}) end @doc """ Returns the incoming replacement peer for the current peer, if one exists. """ @spec incoming_peer() :: node() | nil def incoming_peer do Application.get_env(:beam_deploy, :__incoming_peer__) end @doc """ Returns the outgoing peer being replaced by the current peer, if one exists. """ @spec outgoing_peer() :: node() | nil def outgoing_peer do Application.get_env(:beam_deploy, :__outgoing_peer__) end defp do_start_link(children, opts) do {mod, fun, args} = Keyword.fetch!(opts, :start) otp_app = Keyword.fetch!(opts, :otp_app) cond do Application.get_env(:beam_deploy, :__role__) == :peer -> start_as_peer(mod, fun, args) enabled?() -> start_as_parent(otp_app, children, opts) true -> apply(mod, fun, args) end end defp start_as_peer(mod, fun, args) do if parent = Application.get_env(:beam_deploy, :__parent_node__) do :persistent_term.put({__MODULE__, :parent_node}, parent) end Supervisor.start_link( [ BeamDeploy.Sentinel, %{id: :user_app, start: {mod, fun, args}, type: :supervisor, shutdown: :infinity} ], strategy: :one_for_one, name: BeamDeploy.PeerSupervisor ) end defp start_as_parent(otp_app, children, opts) do if !Node.alive?() do raise ArgumentError, "BeamDeploy requires a distributed node. Start the release with name/sname distribution enabled." end Logger.info("[BeamDeploy] Starting parent node for #{otp_app}") BeamDeploy.Supervisor.start_link( otp_app: otp_app, children: children, endpoint: Keyword.get(opts, :endpoint), shutdown_timeout: Keyword.get(opts, :shutdown_timeout), before_cutover: Keyword.get(opts, :before_cutover), after_cutover: Keyword.get(opts, :after_cutover) ) end defp call_peer_manager(function, args, fallback) do parent = parent_node() cond do is_atom(parent) and parent != node() -> case remote_peer_manager_call(parent, function, args) do {:ok, result} -> result :error -> local_peer_manager_call(function, args, fallback) end Process.whereis(BeamDeploy.PeerManager) -> apply(BeamDeploy.PeerManager, function, args) true -> fallback end catch :exit, _ -> fallback _, _ -> fallback end defp remote_peer_manager_call(parent, function, args) do {:ok, :erpc.call(parent, BeamDeploy.PeerManager, function, args)} catch :exit, _ -> :error _, _ -> :error end defp local_peer_manager_call(function, args, fallback) do if Process.whereis(BeamDeploy.PeerManager) do apply(BeamDeploy.PeerManager, function, args) else fallback end end defp parent_node do case :persistent_term.get({__MODULE__, :parent_node}, nil) do node when is_atom(node) and node != nil -> node _ -> Application.get_env(:beam_deploy, :__parent_node__) end end defp truthy?(value) when value in [nil, "", "0"], do: false defp truthy?(value) when is_binary(value) do value |> String.downcase() |> Kernel.in(["1", "true", "yes", "on"]) end defp truthy?(value), do: value not in [false, nil] end