fusion

Fusion

Remote task runner using Erlang distribution over SSH. Zero dependencies.

Fusion connects to remote servers via SSH, sets up port tunnels for Erlang distribution, bootstraps a remote BEAM node, and lets you run Elixir code on it. Think Ansible/Chef but for Elixir - push modules and execute functions on remote machines without pre-installing your application.

Hex.pmHexDocs

Articles

Requirements

Installation

Add fusion to your list of dependencies in mix.exs:

def deps do
  [
    {:fusion, "~> 0.2.0"}
  ]
end

Usage

Your local BEAM must be started as a distributed node:

iex --sname myapp@localhost -S mix

Then connect and run code remotely:

# Define the target
target = %Fusion.Target{
  host: "10.0.1.5",
  port: 22,
  username: "deploy",
  auth: {:key, "~/.ssh/id_ed25519"}
}

# Connect (sets up tunnels, bootstraps remote BEAM, joins cluster)
{:ok, manager} = Fusion.NodeManager.start_link(target)
{:ok, remote_node} = Fusion.NodeManager.connect(manager)

Run functions on the remote:

# Get remote system info
{:ok, version} = Fusion.run(remote_node, System, :version, [])
{:ok, {hostname, 0}} = Fusion.run(remote_node, System, :cmd, ["hostname", []])

Run anonymous functions directly:

{:ok, info} = Fusion.run_fun(remote_node, fn ->
  %{
    node: Node.self(),
    otp: System.otp_release(),
    os: :os.type()
  }
end)

Push and run your own modules — dependencies are resolved automatically:

defmodule RemoteHealth do
  def check do
    %{
      hostname: hostname(),
      elixir_version: System.version(),
      memory_mb: memory_mb()
    }
  end

  defp hostname do
    {name, _} = System.cmd("hostname", [])
    String.trim(name)
  end

  defp memory_mb do
    {meminfo, _} = System.cmd("cat", ["/proc/meminfo"])

    meminfo
    |> String.split("\n")
    |> Enum.find(&String.starts_with?(&1, "MemTotal"))
    |> String.split(~r/\s+/)
    |> Enum.at(1)
    |> String.to_integer()
    |> div(1024)
  end
end

{:ok, health} = Fusion.run(remote_node, RemoteHealth, :check, [])
# => %{hostname: "web-01", elixir_version: "1.18.4", memory_mb: 7982}

Disconnect when done:

Fusion.NodeManager.disconnect(manager)

SSH Backend

Fusion uses Erlang's built-in SSH module by default. No system ssh binary required.

To use the legacy system SSH backend instead:

target = %Fusion.Target{
  host: "10.0.1.5",
  username: "deploy",
  auth: {:key, "~/.ssh/id_ed25519"},
  ssh_backend: Fusion.SshBackend.System  # uses system ssh/sshpass
}

Automatic Dependency Resolution

When you run RemoteHealth remotely, Fusion reads the BEAM bytecode, walks the dependency tree, and pushes everything the module needs. You don't need to manually track the dependency chain.

# You can also push modules explicitly
Fusion.TaskRunner.push_module(remote_node, MyApp.Worker)
Fusion.TaskRunner.push_modules(remote_node, [MyApp.Config, MyApp.Utils])

Standard library modules (Kernel, Enum, String, etc.) are already on the remote and don't need pushing.

How It Works

1. SSH Tunnel Setup

Fusion creates 3 SSH tunnels between local and remote:

Local Machine                         Remote Server
─────────────                         ─────────────
                 ┌─── Reverse ────┐
Local node port ◄┘   tunnel #1    └── Remote can reach local node

                 ┌─── Forward ────┐
localhost:port ──┘   tunnel #2    └►  Remote node's dist port

                 ┌─── Reverse ────┐
Local EPMD     ◄─┘   tunnel #3    └── Remote registers with local EPMD
(port 4369)

2. Remote BEAM Bootstrap

Starts Elixir on the remote via SSH with carefully configured flags:

3. Transparent Connection

Since the remote registered with the local EPMD, Node.connect/1 works as if the remote node were local. All distribution traffic is routed through the SSH tunnels.

4. Code Pushing

Module bytecode is transferred via Erlang distribution:

  1. Read .beam binary locally with :code.get_object_code/1
  2. Parse BEAM atoms table to find non-stdlib dependencies
  3. Push each dependency recursively (bottom-up)
  4. Load on remote with :code.load_binary/3
  5. Execute via :erpc.call/4

Testing

# Unit tests (no external dependencies)
mix test

# Docker integration tests (requires Docker)
cd test/docker && ./run.sh start
elixir --sname fusion_test@localhost -S mix test --include external

# Stop the test container
cd test/docker && ./run.sh stop

Test Tiers

Architecture

Fusion (public API)
├── TaskRunner        - Remote code execution + module pushing + dependency resolution
├── NodeManager       - GenServer: tunnel setup, BEAM bootstrap, connection lifecycle
├── Target            - SSH connection configuration struct (includes ssh_backend selection)
├── SshBackend        - Behaviour for pluggable SSH implementations
│   ├── Erlang        - Default: uses OTP's built-in :ssh module
│   └── System        - Legacy: shells out to system ssh/sshpass binaries
├── SshKeyProvider    - Custom ssh_client_key_api for specific key file paths
├── TunnelSupervisor  - DynamicSupervisor for tunnel processes
├── Net               - Port generation, EPMD utilities
├── Connector         - SSH connection GenServer
├── SshPortTunnel     - SSH port tunnel process wrapper
├── PortRelay         - Port relay process wrapper
├── UdpTunnel         - UDP tunnel process wrapper
└── Utilities
    ├── Ssh           - SSH command string generation
    ├── Exec          - OS process execution (Port/System.cmd)
    ├── Erl           - Erlang CLI command builder
    └── Bash/Socat/Netcat/Netstat/Telnet - CLI tool wrappers

License

MIT