Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f4288f4
docs: add design for replacing system SSH with Erlang :ssh
Mar 7, 2026
f482e46
docs: add implementation plan for Erlang SSH backend
Mar 7, 2026
457eee6
feat: define SshBackend behaviour
Mar 7, 2026
0812ed6
feat: add ssh_backend field to Target with Erlang default
Mar 7, 2026
8f646c3
feat: add SshKeyProvider for specific key file paths
Mar 7, 2026
0bec474
feat: implement Erlang SSH backend
Mar 7, 2026
95e1ca0
feat: extract System SSH backend from existing code
Mar 7, 2026
b4d8adb
refactor: NodeManager uses pluggable SshBackend
Mar 7, 2026
b0b287c
test: add integration tests for both SSH backends
Mar 7, 2026
3bd5ecd
docs: update README for Erlang SSH backend
Mar 7, 2026
aa7df9b
fix: format code and fix function_exported? test
Mar 7, 2026
653fa0b
fix: remove tmp test artifacts and add to gitignore
Mar 7, 2026
cd3e11b
fix: address code review findings
Mar 7, 2026
40ec5a5
fix: exec_async process safety — no spawn_link, add monitor, remove p…
Mar 7, 2026
19ee565
fix: NodeManager disconnect cleanup and error handling
Mar 7, 2026
4f58a66
fix: sign/3 hash dispatch, System backend docs, exec_async return type
Mar 7, 2026
a87e2fc
fix: collect stderr in exec, add logging to Erlang backend
Mar 7, 2026
b6b6910
test: add mock backends and comprehensive unit tests
Mar 7, 2026
4f5845a
fix: use @moduletag :skip for integration tests when SSH unavailable
Mar 7, 2026
3f8f3d8
fix: address round 2 review findings
Mar 7, 2026
7441c34
refactor: address round 3 review findings (22 items)
Mar 8, 2026
dad346a
refactor: address round 4 review findings (19 items)
Mar 8, 2026
7383c21
refactor: address review rounds 5-10 findings
Mar 8, 2026
aee6f96
chore: add cover/ and erl_crash.dump to .gitignore
Mar 8, 2026
f2efb14
fix: SshKeyProvider key_cb_private opts, external test fixes
Mar 8, 2026
e249d51
fix: docker run.sh SSH readiness check for Alpine
Mar 8, 2026
b41eae7
ci: add external SSH tests with Docker container
Mar 8, 2026
fafed90
fix: external tests fail loudly when Docker unavailable
Mar 8, 2026
f212c17
Merge integration tests into external tests, fix System backend tunne…
Mar 8, 2026
7a19376
fix: retry tunnel setup on :not_accepted for robustness
Mar 8, 2026
da6eab7
fix: increase tunnel retry, add cleanup pause, fix CI stale cache
Mar 8, 2026
176d83f
fix: increase tunnel retry to 20 attempts, 1s post-disconnect pause
Mar 8, 2026
dfabfbb
fix: use clean SSH disconnect instead of SIGKILL for tunnel cleanup
Mar 8, 2026
591edb2
test: add comprehensive external test coverage
Mar 8, 2026
db5f148
fix: remove System backend from NodeManager tests to fix CI tunnel co…
Mar 8, 2026
9a68321
fix: keep System backend e2e NodeManager test, limit to one auth mode
Mar 8, 2026
dee8401
fix: remove System backend NodeManager test that causes CI tunnel con…
Mar 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,17 @@ jobs:
- name: Compile with warnings as errors
run: mix compile --warnings-as-errors

- name: Run tests
- name: Run unit tests
run: mix test

- name: Start SSH test container
run: cd test/docker && bash run.sh start

- name: Verify SSH container is ready
run: |
ssh -i test/docker/.keys/test_key -p 2222 \
-o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
fusion_test@localhost echo ok

- name: Run external tests
run: elixir --sname ci_test@localhost -S mix test --include external
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
*.beam
test/docker/.keys/
.worktrees/
tmp/
/cover/
erl_crash.dump
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Fusion connects to remote servers via SSH, sets up port tunnels for Erlang distr

- Elixir ~> 1.18 / OTP 28+
- Remote server with Elixir/Erlang installed
- SSH access (key-based or password via `sshpass`)
- SSH access (key-based or password)

## Installation

Expand Down Expand Up @@ -119,6 +119,21 @@ 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:

```elixir
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.
Expand Down Expand Up @@ -199,7 +214,11 @@ cd test/docker && ./run.sh stop
Fusion (public API)
├── TaskRunner - Remote code execution + module pushing + dependency resolution
├── NodeManager - GenServer: tunnel setup, BEAM bootstrap, connection lifecycle
├── Target - SSH connection configuration struct
├── 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
Expand Down
87 changes: 87 additions & 0 deletions docs/plans/2026-03-07-erlang-ssh-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Replace System SSH with Erlang :ssh Module — Design

## Goal

Replace all usage of system `ssh`/`sshpass` binaries with Erlang's built-in `:ssh` module while preserving the old approach as a pluggable alternative.

## Architecture

### Pluggable SSH Backend

A behaviour that both backends implement:

```elixir
defmodule Fusion.SshBackend do
@callback connect(target) :: {:ok, conn} | {:error, reason}
@callback forward_tunnel(conn, listen_port, remote_host, remote_port) :: {:ok, port} | {:error, reason}
@callback reverse_tunnel(conn, remote_port, local_host, local_port) :: {:ok, port} | {:error, reason}
@callback exec(conn, command) :: {:ok, output} | {:error, reason}
@callback close(conn) :: :ok
end
```

Two implementations:
- `Fusion.SshBackend.Erlang` — new, uses `:ssh` module (default)
- `Fusion.SshBackend.System` — current behavior, shells out to `ssh`/`sshpass`

Configurable via `Fusion.Target`:
```elixir
%Fusion.Target{
host: "10.0.1.5",
username: "deploy",
auth: {:key, "~/.ssh/id_ed25519"},
ssh_backend: Fusion.SshBackend.Erlang # or Fusion.SshBackend.System
}
```

### Erlang SSH API Mapping

| Fusion Need | Erlang :ssh API |
|-------------|-----------------|
| Forward tunnel (`ssh -L`) | `:ssh.tcpip_tunnel_to_server/6` |
| Reverse tunnel (`ssh -R`) | `:ssh.tcpip_tunnel_from_server/6` |
| Remote command execution | `:ssh_connection.session_channel/2` + `:ssh_connection.exec/4` |
| Password auth | `:ssh.connect(host, port, user: ..., password: ...)` |
| Key auth | `:ssh.connect(host, port, key_cb: {Fusion.SshKeyProvider, ...})` |
| Disconnect detection | `Process.monitor(conn_pid)` |
| Cleanup | `:ssh.close(conn)` |

### Custom Key Provider

`Fusion.SshKeyProvider` — implements `ssh_client_key_api` behaviour to load a specific key file by path. Preserves the current `{:key, "/path/to/key"}` UX instead of scanning a directory.

### Module Changes

**New modules:**
- `Fusion.SshBackend` — behaviour definition
- `Fusion.SshBackend.Erlang` — new `:ssh` based implementation
- `Fusion.SshBackend.System` — extracted from current code
- `Fusion.SshKeyProvider` — custom `ssh_client_key_api` for specific key files

**Modified:**
- `Fusion.Target` — add `ssh_backend` field, default `Fusion.SshBackend.Erlang`
- `Fusion.NodeManager` — use backend behaviour instead of direct SSH calls
- `Fusion.Application` — ensure `:ssh` is started

**Preserved (moved into System backend):**
- `Fusion.Utilities.Ssh` — used by System backend
- `Fusion.Utilities.Exec` — used by System backend
- `Fusion.SshPortTunnel` — used by System backend
- `Fusion.Connector` — refactored to use backend

### Gotchas

- All strings to `:ssh` must be charlists (`~c"..."`)
- `silently_accept_hosts: true` required for non-interactive use
- `user_interaction: false` to prevent stdin blocking
- `:ssh` app must be started before use
- One `session_channel` per `exec` call
- Exec output arrives async via `{:ssh_cm, conn, {:data, ...}}` messages
- OTP 22+ required for high-level tunnel APIs (we're on OTP 28)

### Testing

- Existing unit tests should continue passing
- Docker integration tests validate actual SSH behavior
- Add unit tests for `SshBackend.Erlang` and `SshKeyProvider`
- Test both backends in integration tests
Loading