Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ FUSIONLAB_DEVICE=
# repo root; the env var wins. No key, password, or token belongs here: SSH auth comes from the
# user's own config, agent, and keys.
# FUSIONLAB_COMPUTE_PROVIDER=local
# FUSIONLAB_COMPUTE_HOST=gpu-lab
# FUSIONLAB_SSH_HOST=
# FUSIONLAB_SSH_REMOTE_DIR=~/FusionLab
# FUSIONLAB_SSH_AUTOSTART=true
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ remote_dir = "~/FusionLab" # the clone on that host
autostart = true # start the worker over SSH when none is answering
```

More than one GPU box? Define a `[compute.hosts.<name>]` profile per host (same keys) and switch
with `FUSIONLAB_COMPUTE_HOST` — see [docs/COMPUTE.md](docs/COMPUTE.md).

Full guide — prerequisites, what the chip and banner mean, and a first-run checklist against a real
host: [docs/COMPUTE.md](docs/COMPUTE.md).

Expand Down
32 changes: 28 additions & 4 deletions docs/COMPUTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,38 @@ On the machine with the browser (your laptop):
# port = 8420 # local end of the tunnel
```

More than one GPU box? Define one `[compute.hosts.<name>]` profile per host instead — each takes
the same keys as `[compute.ssh]` — and switch with `FUSIONLAB_COMPUTE_HOST` (give each profile
its own `port`, the local tunnel end, so two FusionLab instances on this machine don't collide):

```toml
[compute.hosts.gpu-lab]
host = "gpu-lab"
remote_dir = "~/FusionLab"
autostart = true
port = 8420

[compute.hosts.gpu-lab-2]
host = "gpu-lab-2"
remote_dir = "~/FusionLab"
autostart = true
port = 8421
```

The single `[compute.ssh]` table keeps working unchanged — with no host selected it wins over
any profiles. A lone profile needs no selector; several profiles without a selection, or a
selector naming an unknown host, are startup errors — never a quiet pick.

3. `make dev`, open the Replay tab, and watch the header chip. Within a few seconds it should
read **GPU: gpu-lab (…)** — the worker's own handshake answering through the tunnel.

Every value has an environment-variable form — `FUSIONLAB_COMPUTE_PROVIDER=ssh`,
`FUSIONLAB_SSH_HOST=gpu-lab`, `FUSIONLAB_SSH_REMOTE_DIR=…`, `FUSIONLAB_SSH_AUTOSTART=…`,
`FUSIONLAB_SSH_PORT=…` (full list in `.env.example`) — and the environment wins over the TOML
file. Nothing loads `.env` automatically — not the app, not `make dev` — so these take effect
when they are in the actual environment: export them, or set them in your service manager.
`FUSIONLAB_COMPUTE_HOST=gpu-lab`, `FUSIONLAB_SSH_HOST=gpu-lab`, `FUSIONLAB_SSH_REMOTE_DIR=…`,
`FUSIONLAB_SSH_AUTOSTART=…`, `FUSIONLAB_SSH_PORT=…` (full list in `.env.example`) — and the
environment wins over the TOML file. (The `FUSIONLAB_SSH_*` overrides apply on top of whichever
host profile is selected — leave them unset when you switch hosts.) Nothing loads `.env`
automatically — not the app, not `make dev` — so these take effect when they are in the actual
environment: export them, or set them in your service manager.

## What the UI tells you

Expand Down
33 changes: 24 additions & 9 deletions fusionlab.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,33 @@
# "User-provided compute (SSH)" and docs/COMPUTE.md.
# provider = "local"

[compute.ssh]
# The host to run compute on. Any alias your ~/.ssh/config already resolves works as-is.
# host = "ousaisrvr"
# Which [compute.hosts.*] profile to use when several are defined (FUSIONLAB_COMPUTE_HOST wins).
# With a single profile defined it is used without a selector.
# host = "gpu-lab"

# Where the FusionLab clone lives on that host; the worker autostarts from here.
# remote_dir = "~/FusionLab"
# One table per GPU box; switch with FUSIONLAB_COMPUTE_HOST (env) or the [compute] host key above.
# Each profile carries the same keys as the legacy [compute.ssh] table below.
[compute.hosts.gpu-lab]
host = "gpu-lab" # any ~/.ssh/config alias resolves as-is
remote_dir = "~/FusionLab" # where the FusionLab clone lives on that host
autostart = true # run `uv run fusionlab-worker` over SSH when no worker answers
# port = 8420 # local end of the tunnel on this machine — give each host its own
# # port when two FusionLab instances share this laptop
# remote_port = 8420 # port the worker binds on the remote host, loopback-only

# Run `uv run fusionlab-worker` over SSH when no worker answers (default true).
# A second box is one more block away — select it with FUSIONLAB_COMPUTE_HOST=gpu-lab-2.
# [compute.hosts.gpu-lab-2]
# host = "gpu-lab-2"
# remote_dir = "~/FusionLab"
# autostart = true
# port = 8421
# remote_port = 8420

# Local end of the tunnel on this machine (default 8420).
[compute.ssh]
# The degenerate single-host table, still supported unchanged; prefer a [compute.hosts.*] profile
# once you might own more than one box. A populated [compute.ssh] table wins when no host is selected.
# host = "ousaisrvr"
# remote_dir = "~/FusionLab"
# autostart = true
# port = 8420

# Port the worker binds on the remote host, loopback-only (default 8420).
# remote_port = 8420
40 changes: 36 additions & 4 deletions fusionlab/compute/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
in-process behavior, so a bare checkout with no config runs exactly as before. SshProvider (PR 2) is
registered behind a lazy import:
the default install never pulls asyncssh; selecting ssh requires `uv sync --extra remote` and a
[compute.ssh] host (or FUSIONLAB_SSH_HOST) — keys and passwords never appear in config, by design.
[compute.hosts.<name>] profile — or the legacy single [compute.ssh] table — (or FUSIONLAB_SSH_HOST).
Host profiles are selected by FUSIONLAB_COMPUTE_HOST (env) or [compute] host in fusionlab.toml; with
no selector the legacy table wins, a lone profile is unambiguous, and several profiles fail loudly
until one is named. Keys and passwords never appear in config, by design.
"""

from __future__ import annotations
Expand Down Expand Up @@ -34,17 +37,46 @@
}


def _ssh_table(cfg: dict[str, Any]) -> dict[str, Any]:
"""The base ssh table for the selected host — raw [compute.ssh]-shaped keys, no env merging yet.

Selection: FUSIONLAB_COMPUTE_HOST (env) wins over [compute] host in the toml; either names a
[compute.hosts.<name>] profile, and an unknown name fails loudly here (get_provider's contract),
never by silently falling back to another host. With no selector the legacy [compute.ssh] table
still works unchanged — the degenerate single-host case — then a lone profile is unambiguous,
and several profiles are an error until one is named.
"""
hosts = cfg.get("hosts") or {}
selected = os.environ.get("FUSIONLAB_COMPUTE_HOST", "").strip() or str(cfg.get("host", "")).strip()
if selected:
if selected not in hosts:
known = f"known hosts: {sorted(hosts)}" if hosts else "no [compute.hosts.*] profiles are defined"
raise ValueError(f"unknown compute host {selected!r} (FUSIONLAB_COMPUTE_HOST or [compute] host); "
f"{known} — see fusionlab.toml.example")
return dict(hosts[selected])
legacy = cfg.get("ssh") or {}
if legacy:
return dict(legacy)
if len(hosts) == 1:
return dict(next(iter(hosts.values())))
if len(hosts) > 1:
raise ValueError(f"{len(hosts)} [compute.hosts.*] profiles are defined ({sorted(hosts)}) but no host "
f"is selected — set FUSIONLAB_COMPUTE_HOST or [compute] host in fusionlab.toml")
return {}


def ssh_options() -> dict[str, Any]:
"""The [compute.ssh] table from fusionlab.toml, merged with FUSIONLAB_SSH_* env overrides (env wins).
"""The selected host's ssh options — [compute.hosts.<name>] (or the legacy [compute.ssh] table)
from fusionlab.toml, merged with FUSIONLAB_SSH_* env overrides (env wins).

Returns kwargs ready for SshProvider(**...). Never contains credentials — the connection uses the
user's own SSH agent, keys, and ~/.ssh/config (design §8).
"""
opts: dict[str, Any] = {}
if _TOML_PATH.exists():
with _TOML_PATH.open("rb") as f:
opts.update(tomllib.load(f).get("compute", {}).get("ssh", {}))
if "port" in opts: # the design's toml key names the local end of the tunnel
opts.update(_ssh_table(tomllib.load(f).get("compute", {})))
if "port" in opts: # every toml spelling names the local end of the tunnel
opts["local_port"] = opts.pop("port")
for key, var in _SSH_ENV_VARS.items():
raw = os.environ.get(var, "").strip()
Expand Down
124 changes: 118 additions & 6 deletions tests/test_compute_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""The [compute] config surface (PR 3): fusionlab.toml at the repo root, FUSIONLAB_* env overrides on
top, and no credential field anywhere in the path (design §7-8)."""
top, and no credential field anywhere in the path (design §7-8). HARN-5 adds per-host profiles:
[compute.hosts.<name>] tables selected by FUSIONLAB_COMPUTE_HOST (env) or [compute] host, with the
single [compute.ssh] table kept as the degenerate case and the zero-config default unchanged."""

import tomllib
from pathlib import Path
Expand All @@ -11,11 +13,17 @@
REPO_ROOT = Path(compute.__file__).resolve().parent.parent.parent
SSH_VARS = ("FUSIONLAB_SSH_HOST", "FUSIONLAB_SSH_REMOTE_DIR", "FUSIONLAB_SSH_AUTOSTART",
"FUSIONLAB_SSH_PORT", "FUSIONLAB_SSH_REMOTE_PORT", "FUSIONLAB_COMPUTE_PROVIDER")
HOST_VARS = SSH_VARS + ("FUSIONLAB_COMPUTE_HOST",)
SSH_KEYS = {"host", "remote_dir", "autostart", "port", "remote_port"}
TWO_HOSTS_TOML = ('[compute.hosts.lab-a]\nhost = "box-a"\nremote_dir = "~/FusionLab"\n'
'autostart = true\nport = 8421\n'
'[compute.hosts.lab-b]\nhost = "box-b"\nremote_dir = "~/FusionLab"\n'
'autostart = false\nport = 8422\n')


@pytest.fixture()
def no_env(monkeypatch):
for var in SSH_VARS:
for var in HOST_VARS:
monkeypatch.delenv(var, raising=False)


Expand Down Expand Up @@ -52,8 +60,112 @@ def test_env_overrides_beat_the_toml(monkeypatch, tmp_path):


def test_example_config_parses_and_holds_no_credentials():
"""The shipped example is valid TOML for exactly this surface, with no credential-shaped key."""
"""The shipped example is valid TOML for exactly this surface, with no credential-shaped key —
including every [compute.hosts.*] profile (HARN-5)."""
data = tomllib.loads((REPO_ROOT / "fusionlab.toml.example").read_text())
ssh = data["compute"]["ssh"]
assert set(ssh) <= {"host", "remote_dir", "autostart", "port", "remote_port"}
assert not any(word in key for key in ssh for word in ("key", "password", "secret", "token"))
cfg = data["compute"]
tables = [cfg["ssh"], *cfg.get("hosts", {}).values()]
for table in tables:
assert set(table) <= SSH_KEYS
assert not any(word in key for key in table for word in ("key", "password", "secret", "token"))


# -- HARN-5: [compute.hosts.<name>] profiles ---------------------------------------------------


def test_two_profiles_resolve_to_distinct_option_sets(no_env, monkeypatch, tmp_path):
"""The acceptance pair: the same config file yields each box's own options, per selection."""
toml = tmp_path / "fusionlab.toml"
toml.write_text(TWO_HOSTS_TOML)
monkeypatch.setattr(compute, "_TOML_PATH", toml)
monkeypatch.setenv("FUSIONLAB_COMPUTE_HOST", "lab-a")
a = compute.ssh_options()
assert a == {"host": "box-a", "remote_dir": "~/FusionLab", "autostart": True, "local_port": 8421}
monkeypatch.setenv("FUSIONLAB_COMPUTE_HOST", "lab-b")
b = compute.ssh_options()
assert b == {"host": "box-b", "remote_dir": "~/FusionLab", "autostart": False, "local_port": 8422}
assert a != b


def test_env_overrides_beat_the_selected_profile(no_env, monkeypatch, tmp_path):
"""FUSIONLAB_SSH_* still wins, now over the profile's matching keys too."""
toml = tmp_path / "fusionlab.toml"
toml.write_text(TWO_HOSTS_TOML)
monkeypatch.setattr(compute, "_TOML_PATH", toml)
monkeypatch.setenv("FUSIONLAB_COMPUTE_HOST", "lab-a")
monkeypatch.setenv("FUSIONLAB_SSH_HOST", "env-host")
monkeypatch.setenv("FUSIONLAB_SSH_PORT", "1234")
assert compute.ssh_options() == {"host": "env-host", "remote_dir": "~/FusionLab",
"autostart": True, "local_port": 1234}


def test_toml_host_key_selects_a_profile_and_env_beats_it(no_env, monkeypatch, tmp_path):
"""[compute] host names a profile in the file; FUSIONLAB_COMPUTE_HOST overrides it."""
toml = tmp_path / "fusionlab.toml"
toml.write_text('[compute]\nhost = "lab-b"\n' + TWO_HOSTS_TOML)
monkeypatch.setattr(compute, "_TOML_PATH", toml)
assert compute.ssh_options()["host"] == "box-b"
monkeypatch.setenv("FUSIONLAB_COMPUTE_HOST", "lab-a")
assert compute.ssh_options()["host"] == "box-a"


def test_single_profile_needs_no_selector(no_env, monkeypatch, tmp_path):
"""One GPU box, no selection anywhere: the lone profile is unambiguous."""
toml = tmp_path / "fusionlab.toml"
toml.write_text('[compute.hosts.lab-a]\nhost = "box-a"\nport = 8421\n')
monkeypatch.setattr(compute, "_TOML_PATH", toml)
assert compute.ssh_options() == {"host": "box-a", "local_port": 8421}


def test_unknown_host_fails_loudly(no_env, monkeypatch, tmp_path):
"""A selector naming no profile is a loud ValueError that lists the known hosts — per
get_provider's unknown-provider contract, never a silent fallback to another host."""
toml = tmp_path / "fusionlab.toml"
toml.write_text(TWO_HOSTS_TOML)
monkeypatch.setattr(compute, "_TOML_PATH", toml)
monkeypatch.setenv("FUSIONLAB_COMPUTE_HOST", "lab-c")
with pytest.raises(ValueError, match=r"unknown compute host 'lab-c'.*lab-a.*lab-b"):
compute.ssh_options()


def test_unknown_host_with_no_profiles_fails_loudly(no_env, monkeypatch, tmp_path):
"""Selecting a host when the file defines none says so, instead of an empty option set."""
toml = tmp_path / "fusionlab.toml"
toml.write_text('[compute]\nprovider = "ssh"\n')
monkeypatch.setattr(compute, "_TOML_PATH", toml)
monkeypatch.setenv("FUSIONLAB_COMPUTE_HOST", "lab-c")
with pytest.raises(ValueError, match="unknown compute host 'lab-c'.*no \\[compute.hosts.\\*\\] profiles"):
compute.ssh_options()


def test_several_profiles_without_a_selection_fail_loudly(no_env, monkeypatch, tmp_path):
"""Two boxes and no selector is ambiguous — an error, not a quiet pick."""
toml = tmp_path / "fusionlab.toml"
toml.write_text(TWO_HOSTS_TOML)
monkeypatch.setattr(compute, "_TOML_PATH", toml)
with pytest.raises(ValueError, match="no host is selected.*FUSIONLAB_COMPUTE_HOST"):
compute.ssh_options()


def test_legacy_ssh_table_still_resolves_alone_and_wins_without_a_selector(no_env, monkeypatch, tmp_path):
"""The degenerate case is byte-identical to pre-HARN-5 behavior: alone it resolves; with
profiles defined it still wins when nothing selects a host (existing configs keep working)."""
toml = tmp_path / "fusionlab.toml"
toml.write_text('[compute.ssh]\nhost = "legacy"\nport = 9000\n')
monkeypatch.setattr(compute, "_TOML_PATH", toml)
assert compute.ssh_options() == {"host": "legacy", "local_port": 9000}
toml.write_text('[compute.ssh]\nhost = "legacy"\nport = 9000\n' + TWO_HOSTS_TOML)
assert compute.ssh_options() == {"host": "legacy", "local_port": 9000}


def test_unknown_host_fails_at_get_provider(no_env, monkeypatch, tmp_path):
"""The loud contract holds end to end: get_provider() raises for an unknown host (asyncssh is
only imported at connect time, so the ValueError surfaces even without the remote extra)."""
toml = tmp_path / "fusionlab.toml"
toml.write_text(TWO_HOSTS_TOML)
monkeypatch.setattr(compute, "_TOML_PATH", toml)
monkeypatch.setattr(compute, "_provider", None) # get_provider caches one per process
monkeypatch.setenv("FUSIONLAB_COMPUTE_PROVIDER", "ssh")
monkeypatch.setenv("FUSIONLAB_COMPUTE_HOST", "lab-c")
with pytest.raises(ValueError, match="unknown compute host 'lab-c'"):
compute.get_provider()
Loading