Skip to content

feat(remote): add rocm remote — serve a model on a remote GPU host over SSH#76

Open
volen-silo wants to merge 3 commits into
mainfrom
rocm-remote
Open

feat(remote): add rocm remote — serve a model on a remote GPU host over SSH#76
volen-silo wants to merge 3 commits into
mainfrom
rocm-remote

Conversation

@volen-silo

@volen-silo volen-silo commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

rocm remote serve <host> <model> brings up an OpenAI-compatible model server on a remote AMD GPU box and makes it callable from your machine with one command: connect over SSH, ensure the CLI + ROCm are present (pushing the CLI if missing), start a managed rocm serve on the remote, open a loopback tunnel, and print a ready-to-use base URL. Sessions are persistent and observable — rocm remote status / attach / stop manage them afterward.

Built in two commits (one per phase).

Commit 1 — rocm remote serve

  • New rocm remote command group and apps/rocm/src/remote/ module (transport, bootstrap, mod).
  • A Transport trait with an SshTransport that shells out to the system ssh/scp — reuses the user's keys / ~/.ssh/config, adds no new dependency, and stays synchronous. A Tailscale-native transport can slot in behind the same trait later.
  • Reuses the remote's own rocm serve / rocm services — no reimplementation of engine/runtime/device selection; the server runs where the GPU is.
  • Bootstrap probes rocm --version + ROCm and: auto-pushes the local binary if the CLI is missing (the whole runtime is one self-contained binary — rocm daemon is the supervisor, there is no separate rocmd to install), and detects-and-fails if ROCm is absent (never installs the multi-GB SDK).
  • Adds rocm services list --json — the machine-readable listing the remote orchestration reads back.
  • Loopback-only forward by default.

Commit 2 — persistent, observable sessions

  • serve is now async and always-persistent: it starts the managed server (which loads the model in the background), opens a detached tunnel that survives the command exiting, records the session locally, and returns immediately with a Pending status and the base URL.
  • rocm remote status [<id>] [--watch] reconciles two independent lifecycles into a table — remote-server health (polled over SSH via services list --json) and local tunnel liveness (PID check) — shown as separate columns. attach rebuilds a dropped tunnel without redeploying; stop tears down both. list is intentionally reserved for listing configured hosts (distinct from active sessions).
  • No local reconciler daemon — the Pending → Healthy progression comes from the remote's own managed-service lifecycle; status polls on demand.
  • The tunnel keeper is a standalone detached ssh process using ControlMaster=no, so killing its PID deterministically closes the forward.
  • New RemoteSessionRecord (one JSON file per session under remote-sessions/), mirroring ManagedServiceRecord.

Design decisions

  • System ssh/scp, not a Rust SSH crate — synchronous, no new dependency, and the tunnel is a child process that fits the existing detach + PID-tracking model.
  • Auto-push the local binary (rather than a public download) because the release repo is currently private; the pushed binary is verified on the remote before use, which cleanly catches an arch/libc mismatch.
  • Transport-agnostic registry/status/attach/stop, so a future Tailscale/mesh transport only swaps the forward mechanic (the Tunnel column becomes via mesh).

Test plan

  • cargo test -p rocm -p rocm-core — green, incl. ~19 new tests: ssh/scp argv construction (incl. ControlMaster=no teardown option), session-id + host resolver (incl. ambiguous-host), status classification + table rendering via an injected status stub (no network), bootstrap probe branches, and the natural-language-planner allowlist routing for remote.
  • cargo clippy --all-targets -- -D warnings clean; cargo fmt --check clean.
  • Validated end-to-end over an SSH loopback against a stubbed remote rocm: serve → status (Remote Healthy / Tunnel Up) → reachable through the tunnel → kill the tunnel PID → endpoint refused (confirms the ControlMaster=no teardown) → status shows degraded (Tunnel Down / Remote Healthy) → attach restores it → stop tears down both server and tunnel.

Not included / follow-ups

  • E2E on a real AMD GPU box (real model load, live Pending → Healthy) — deferred; only verifiable against live hardware.
  • Auto-reconnect for a dropped tunnel, rocm remote list for configured hosts, and a Tailscale transport for NAT traversal are future increments.

@volen-silo volen-silo changed the title Rocm remote feat(remote): add rocm remote — serve a model on a remote GPU host over SSH Jul 3, 2026
fn temp_paths(tag: &str) -> (std::path::PathBuf, AppPaths) {
let root =
std::env::temp_dir().join(format!("rocm-remote-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
// Unknown handle errors.
assert!(resolve_session(&paths, "nope").is_err());

let _ = std::fs::remove_dir_all(root);
assert!(err.contains("gpu-box-11435"));
assert!(err.contains("gpu-box-11436"));

let _ = std::fs::remove_dir_all(root);
.context("remote session path must have a parent directory")?;
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
fs::write(
/// skipped rather than failing the whole listing.
pub fn load_remote_sessions(paths: &AppPaths) -> Result<Vec<RemoteSessionRecord>> {
let dir = paths.remote_sessions_dir();
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut records = Vec::new();
for entry in fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))? {
@volen-silo

Copy link
Copy Markdown
Collaborator Author

CodeQL rust/path-injection findings — assessment (false positives, non-blocking)

The 6 CodeQL alerts on this PR are all the same rule — Uncontrolled data used in path expression — and all trace to inputs that aren't an untrusted boundary for a local CLI. CodeQL isn't a required check here, so these don't block merge, but recording the analysis for whoever triages the Security tab:

Alert Sink "user-provided value" is… Verdict
604–606 remove_dir_all in remote/mod.rs tests the process's own TMPDIR (std::env::temp_dir()) test-only cleanup of our own temp dir
608–609 read_dir / is_dir on remote_sessions_dir() the app's own ROCM_CLI_DATA_DIR config env var reading our own data dir
607 fs::write in RemoteSessionRecord::write session_id, derived from the host CLI arg session_id_for() sanitizes host to [A-Za-z0-9-] (no /, no .), so it can't escape the sessions dir

This is the same pattern the existing managed-service code uses (load_managed_services, ManagedServiceRecord::write), which is why the repo already has ~87 open alerts of this exact class. Nothing here widens the trust boundary: the "inputs" are the tool's own config env var, its own TMPDIR, and a sanitized CLI argument.

Recommendation: dismiss as "won't fix / false positive", consistent with the existing alerts of this class. Happy to add a defensive [A-Za-z0-9._-]-only sanitizer at the remote_session_path boundary if we'd prefer belt-and-suspenders — though it would only address alert 607 and diverge from how the rest of the codebase treats the data dir.

@volen-silo volen-silo marked this pull request as ready for review July 6, 2026 07:41
@volen-silo volen-silo force-pushed the rocm-remote branch 2 times, most recently from 7897377 to da6c506 Compare July 6, 2026 08:13
Bring up a managed model server on a remote GPU host with one command:
connect over SSH, ensure the CLI + ROCm are present (auto-push the CLI if
missing, detect-and-fail if ROCm is absent), start a managed `rocm serve`
on the remote, open a loopback port-forward, and print an OpenAI-compatible
base URL. The command holds the tunnel open until interrupted; the remote
server is managed and persists.

The new remote module reuses the remote's existing serve/services machinery
rather than reimplementing engine/runtime/device selection. Transport is a
trait (SshTransport shells out to system ssh/scp) so a Tailscale-native
transport can slot in later. Adds `rocm services list --json` as the
machine-readable listing the remote orchestration reads back.

Also registers `remote` in the natural-language planner's structured-command
allowlist so `rocm remote ...` routes to clap instead of the planner.

Phase 2 (session registry, detached tunnel keeper, remote list/status/stop
with reconciliation) is not included.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
Remote serving is now async and always-persistent. `serve` starts the
managed server on the remote (which loads the model in the background),
opens a detached SSH tunnel that survives the command, records the session
locally, and returns immediately with a Pending status and the OpenAI
base URL — no more foreground hold.

New verbs reconcile two independent lifecycles: `status [<id>] [--watch]`
shows a table of sessions with remote-server health (polled over SSH via
`services list --json`) and local tunnel liveness (PID check) as separate
columns; `attach` rebuilds a dropped tunnel without redeploying; `stop`
closes the tunnel and stops the remote server. `list` is reserved for
configured hosts.

The Pending->Healthy progression comes from the remote's own managed-service
lifecycle, so there is no local reconciler daemon — status polls on demand.
The tunnel keeper is a standalone detached ssh process using
ControlMaster=no so killing its PID deterministically closes the forward.
Bootstrap now reports progress when pushing the CLI to a remote that lacks it.

State lives in a new RemoteSessionRecord (one JSON file per session under
remote-sessions/), mirroring ManagedServiceRecord.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
@volen-silo

Copy link
Copy Markdown
Collaborator Author

How to test rocm remote locally

Build: cargo build -p rocm

Against a reachable GPU host (the real path):

# host must be SSH-reachable and have ROCm; the CLI is auto-pushed if missing
rocm remote serve gpu-box qwen2.5-7b-instruct     # returns immediately: Status: Pending + local URL
rocm remote status --watch                        # watch Pending -> Healthy as the model loads
OPENAI_BASE_URL=<printed-url> rocm chat --provider openai --prompt "hi"
rocm remote stop <session-id>                     # tears down tunnel + remote server

Worth verifying: the session survives closing the terminal; after kill-ing the tunnel pid, status shows Tunnel: Down (degraded) and rocm remote attach <id> rebuilds it; a host without ROCm bails with a clear "install ROCm first" message (no SDK auto-install).

No GPU box? Loopback smoke (no ROCm needed). The command surface only asks the remote for four things — rocm --version, rocm serve --managed, rocm services list --json, rocm services stop — so you can exercise the whole ssh/scp/tunnel/status/attach/stop lifecycle against localhost:

  1. Ensure passwordless ssh localhost works.
  2. Put a small fake rocm (plus a stub rocminfo so the ROCm probe passes) earlier on the remote PATH that: prints a version; on serve --managed writes a ManagedServiceRecord JSON and launches a throwaway python3 -m http.server on the port; emits that record for services list --json; kills it on services stop.
  3. Run the normal rocm remote serve localhost <model>statusattachstop against it.

This confirms the tunnel actually forwards, that killing the tunnel pid closes the forward, and that stop tears both down — all without a GPU. (I have a self-contained fake-rocm + driver script that automates exactly this with pass/fail checks; happy to share on request.)

Note: rocm services list --json was added as the machine-readable listing the remote status polling reads back.

@mikeroySoft

Copy link
Copy Markdown

We would likely use TCP for remote connectivity since it's already wired in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants