From 6893fa159f532cf32deb757d8d2d6bf9aefa4e2b Mon Sep 17 00:00:00 2001 From: Obvious Date: Sun, 11 Oct 2026 20:06:29 +0000 Subject: [PATCH 1/2] feat: multi-host compute profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [compute.hosts.] tables — one per GPU box, each with the same keys as [compute.ssh] (host, remote_dir, autostart, port, remote_port) — selected by FUSIONLAB_COMPUTE_HOST (env) or [compute] host in fusionlab.toml. The single [compute.ssh] table remains the degenerate case and still wins when no host is selected; a lone profile is unambiguous; several profiles without a selector or a selector naming an unknown host fail loudly (get_provider's contract), never by silently picking a box. FUSIONLAB_SSH_* env overrides still beat the selected profile. Zero-config default behavior is unchanged. Closes #43. Co-authored-by: Tapan Ganatma Nakkina --- .env.example | 1 + README.md | 3 +++ docs/COMPUTE.md | 32 ++++++++++++++++++++++++---- fusionlab.toml.example | 33 +++++++++++++++++++++-------- fusionlab/compute/__init__.py | 40 +++++++++++++++++++++++++++++++---- 5 files changed, 92 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index a5041b1..7ed86d8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index d029eb7..9029757 100644 --- a/README.md +++ b/README.md @@ -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.]` 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). diff --git a/docs/COMPUTE.md b/docs/COMPUTE.md index def5501..b7f032f 100644 --- a/docs/COMPUTE.md +++ b/docs/COMPUTE.md @@ -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.]` 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 diff --git a/fusionlab.toml.example b/fusionlab.toml.example index d3c6e94..508d493 100644 --- a/fusionlab.toml.example +++ b/fusionlab.toml.example @@ -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 diff --git a/fusionlab/compute/__init__.py b/fusionlab/compute/__init__.py index bea4b19..0d7aa09 100644 --- a/fusionlab/compute/__init__.py +++ b/fusionlab/compute/__init__.py @@ -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.] 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 @@ -34,8 +37,37 @@ } +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.] 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.] (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). @@ -43,8 +75,8 @@ def ssh_options() -> dict[str, Any]: 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() From 2cb2c0f904799a49673faef382f3b96aa56f4fac Mon Sep 17 00:00:00 2001 From: Obvious Date: Sun, 11 Oct 2026 20:06:29 +0000 Subject: [PATCH 2/2] test: profile resolution + posture Two profiles resolve to distinct option sets; env beats the selected profile; [compute] host selects and FUSIONLAB_COMPUTE_HOST beats it; a lone profile needs no selector; unknown hosts and ambiguous selections fail loudly (also through get_provider); the legacy table still resolves alone and wins without a selector. The example-config posture test now covers every [compute.hosts.*] profile too; the five pre-HARN-5 tests are unchanged and pass, pinning the zero-config default. Co-authored-by: Tapan Ganatma Nakkina --- tests/test_compute_config.py | 124 +++++++++++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 6 deletions(-) diff --git a/tests/test_compute_config.py b/tests/test_compute_config.py index f5d2099..8c6dc2f 100644 --- a/tests/test_compute_config.py +++ b/tests/test_compute_config.py @@ -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.] 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 @@ -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) @@ -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.] 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()