remote-shell / app.py
chankhavu's picture
UI: add Broadcast — run a command on the given session across all online clients
766eca1 verified
Raw
History Blame Contribute Delete
20.8 kB
"""
NII Relay — a tiny HF Space that acts as a message relay between you (in the
browser) and shell daemons running inside no-SSH containers on the cluster.
Flow
----
1. A daemon inside a container registers with a CLIENT_ID and polls this Space
every few seconds (via the `gradio_client` API endpoints below).
2. You pick a container + bash session in the web UI and submit a shell command.
3. The next poll hands the command to the daemon, which marks it ACKNOWLEDGED,
runs it in a persistent bash session, and posts stdout + exit code back.
4. The UI shows the output.
State is in-memory only. If the Space restarts, daemons simply re-register on
their next poll — but any not-yet-delivered commands are lost. That is fine for
interactive debugging.
Auth: the Space is PRIVATE, so reaching it at all requires an HF token with
access — that token is the auth boundary. There is no separate app-level secret.
"""
import os
import re
import time
import uuid
import shutil
import tempfile
import pathlib
import threading
from collections import defaultdict
import gradio as gr
CLIENT_STALE_AFTER = 30 # seconds w/o a poll => "offline"
MAX_OUTPUT_CHARS = 200_000 # cap stored output per command
MAX_HISTORY_PER_CLIENT = 200 # ring-buffer of commands
MAX_FILE_BYTES = 100 * 1024 * 1024 # 100 MB cap on transfers
FILE_TTL = 3600 # delete stored files older than 1h
FILES_DIR = pathlib.Path(tempfile.gettempdir()) / "relay_files"
FILES_DIR.mkdir(exist_ok=True)
_HEX32 = re.compile(r"\A[0-9a-f]{32}\Z")
def _valid_id(s) -> bool:
return isinstance(s, str) and bool(_HEX32.match(s))
LOCK = threading.Lock()
CLIENTS: dict[str, dict] = {} # cid -> {last_seen, meta}
COMMANDS: dict[str, dict] = {} # command_id -> record
BY_CLIENT: dict[str, list[str]] = defaultdict(list) # cid -> [command_id, ...]
PUT_FILES: dict[str, dict] = {} # file_id -> {path, filename} (server->client payloads)
def _now() -> float:
return time.time()
def _human(n: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
n /= 1024
def _touch_client(cid: str, meta: str = ""):
c = CLIENTS.setdefault(cid, {"first_seen": _now(), "meta": meta})
c["last_seen"] = _now()
if meta:
c["meta"] = meta
def _unlink(path: str):
try:
os.unlink(path)
except OSError:
pass
def _drop_command(cmd_id: str):
"""Remove a command record and any file it owns on disk. Call under LOCK."""
rec = COMMANDS.pop(cmd_id, None)
if not rec:
return
if rec.get("stored_path"):
_unlink(rec["stored_path"])
fid = rec.get("file_id")
if fid:
info = PUT_FILES.pop(fid, None)
if info:
_unlink(info["path"])
def _append_and_trim(cid: str, cmd_id: str):
"""Append to a client's history and ring-buffer it, deleting evicted files.
Call under LOCK. Used by every append site so growth is bounded uniformly."""
hist = BY_CLIENT[cid]
hist.append(cmd_id)
while len(hist) > MAX_HISTORY_PER_CLIENT:
_drop_command(hist.pop(0))
def _file_reaper():
"""Backstop against disk growth on the ephemeral Space: delete stored files
older than FILE_TTL and prune PUT_FILES whose file is gone."""
while True:
time.sleep(600)
cutoff = _now() - FILE_TTL
try:
for p in FILES_DIR.iterdir():
try:
if p.is_file() and p.stat().st_mtime < cutoff:
p.unlink()
except OSError:
pass
except OSError:
pass
with LOCK:
for fid in list(PUT_FILES):
if not os.path.isfile(PUT_FILES[fid]["path"]):
PUT_FILES.pop(fid, None)
# --------------------------------------------------------------------------- #
# Machine API (called by the daemon via gradio_client) #
# --------------------------------------------------------------------------- #
def api_register(client_id: str, meta: str):
client_id = (client_id or "").strip()
if not client_id:
raise gr.Error("empty client_id")
with LOCK:
_touch_client(client_id, meta or "")
return {"ok": True, "ts": _now()}
def api_poll(client_id: str):
"""Return all pending commands for this client and flip them to acknowledged."""
client_id = (client_id or "").strip()
out = []
with LOCK:
_touch_client(client_id)
for cmd_id in list(BY_CLIENT.get(client_id, [])):
rec = COMMANDS.get(cmd_id)
if rec is None:
continue
if rec["status"] == "pending":
rec["status"] = "acknowledged"
rec["acknowledged_at"] = _now()
out.append({
"id": rec["id"],
"type": rec.get("type", "exec"),
"session": rec.get("session", "main"),
"command": rec.get("command", ""),
"timeout": rec.get("timeout", 120),
# file-transfer fields (None for plain exec commands)
"file_id": rec.get("file_id"),
"dest": rec.get("dest"),
"remote_path": rec.get("remote_path"),
"filename": rec.get("filename"),
})
return {"commands": out, "ts": _now()}
def api_result(command_id: str, client_id: str,
exit_code: float, stdout: str, session: str):
if not _valid_id(command_id):
raise gr.Error("bad command id")
with LOCK:
rec = COMMANDS.get(command_id)
if rec is None:
# Space probably restarted; record it anyway so output isn't lost.
rec = COMMANDS[command_id] = {
"id": command_id, "client_id": client_id, "session": session,
"command": "(unknown — relay restarted)", "created": _now(),
"status": "pending",
}
_append_and_trim(client_id, command_id)
rec["status"] = "done"
rec["finished_at"] = _now()
rec["exit_code"] = int(exit_code)
rec["stdout"] = (stdout or "")[:MAX_OUTPUT_CHARS]
_touch_client(client_id)
return {"ok": True}
def api_fetch_file(file_id: str):
"""Daemon calls this to download a server->client (push) payload."""
if not _valid_id(file_id):
raise gr.Error("bad file id")
info = PUT_FILES.get(file_id)
if not info or not os.path.isfile(info["path"]):
raise gr.Error("unknown or expired file_id")
return info["path"]
def api_upload_file(command_id: str, client_id: str, fileobj, error: str):
"""Daemon calls this to post the result of a client->server (pull) request.
`fileobj` is the uploaded file path on success, or None when `error` is set."""
if not _valid_id(command_id):
raise gr.Error("bad command id")
# Do filesystem work BEFORE taking the lock (a 100 MB copy must not stall
# every concurrent poll/UI handler). Enforce the size cap server-side too —
# don't trust the client.
saved = None
fname = sz = None
too_big = False
if fileobj:
sz = os.path.getsize(fileobj)
if sz > MAX_FILE_BYTES:
too_big = True
else:
fname = os.path.basename(fileobj)
saved = FILES_DIR / f"{command_id}__{fname}"
shutil.copy(fileobj, saved)
with LOCK:
rec = COMMANDS.get(command_id)
if rec is None: # relay restarted — keep the file anyway
rec = COMMANDS[command_id] = {
"id": command_id, "client_id": client_id, "session": "file",
"type": "get", "command": "(file fetch — relay restarted)",
"created": _now(), "status": "pending",
}
_append_and_trim(client_id, command_id)
rec["status"] = "done"
rec["finished_at"] = _now()
_touch_client(client_id)
if too_big:
rec.update(exit_code=1,
stdout=f"file exceeds the {_human(MAX_FILE_BYTES)} limit")
elif saved is not None:
rec.update(stored_path=str(saved), filename=fname, size=sz,
exit_code=0, stdout=f"fetched {fname} ({_human(sz)})")
else:
rec.update(exit_code=1, stdout=(error or "fetch failed"))
return {"ok": True}
# --------------------------------------------------------------------------- #
# Human UI helpers #
# --------------------------------------------------------------------------- #
def _client_choices() -> list[str]:
with LOCK:
items = sorted(CLIENTS.items(), key=lambda kv: kv[1].get("last_seen", 0), reverse=True)
labels = []
for cid, c in items:
age = _now() - c.get("last_seen", 0)
dot = "🟢" if age < CLIENT_STALE_AFTER else "🔴"
labels.append(f"{dot} {cid}")
return labels
def _strip_dot(label: str) -> str:
return label.split(" ", 1)[1] if label and label[:1] in "🟢🔴" else (label or "")
def ui_send(client_label: str, session: str, command: str, timeout: int):
cid = _strip_dot(client_label)
if not cid:
return "⚠️ pick a container first", command
if not command.strip():
return "⚠️ empty command", command
session = (session or "main").strip()
cmd_id = uuid.uuid4().hex
with LOCK:
COMMANDS[cmd_id] = {
"id": cmd_id, "client_id": cid, "session": session,
"command": command, "timeout": int(timeout),
"status": "pending", "created": _now(),
}
_append_and_trim(cid, cmd_id)
return f"➡️ sent to `{cid}` [{session}]", ""
def ui_broadcast(session: str, command: str, timeout: int):
"""Queue the same command on the given session for every online container."""
if not command.strip():
return "⚠️ empty command", command
session = (session or "main").strip()
now = _now()
sent = []
with LOCK:
online = [cid for cid, c in CLIENTS.items()
if now - c.get("last_seen", 0) < CLIENT_STALE_AFTER]
for cid in online:
cmd_id = uuid.uuid4().hex
COMMANDS[cmd_id] = {
"id": cmd_id, "client_id": cid, "session": session,
"command": command, "timeout": int(timeout),
"status": "pending", "created": now,
}
_append_and_trim(cid, cmd_id)
sent.append(cid)
if not sent:
return "⚠️ no online containers to broadcast to", command
return (f"📡 broadcast to {len(sent)} container(s) [{session}]: "
+ ", ".join(f"`{c}`" for c in sent), "")
def ui_put_file(client_label: str, fileobj, dest: str):
"""Queue a push: send an uploaded file to the selected container."""
cid = _strip_dot(client_label)
if not cid:
return "⚠️ pick a container first", None
if not fileobj:
return "⚠️ choose a file to send", None
sz = os.path.getsize(fileobj)
if sz > MAX_FILE_BYTES:
return f"⚠️ file is {_human(sz)} — over the {_human(MAX_FILE_BYTES)} limit", None
fname = os.path.basename(fileobj)
file_id = uuid.uuid4().hex
saved = FILES_DIR / f"{file_id}__{fname}"
shutil.copy(fileobj, saved)
dest = (dest or "").strip() or fname
cmd_id = uuid.uuid4().hex
with LOCK:
PUT_FILES[file_id] = {"path": str(saved), "filename": fname}
COMMANDS[cmd_id] = {
"id": cmd_id, "client_id": cid, "session": "file", "type": "put",
"file_id": file_id, "dest": dest, "filename": fname, "size": sz,
"command": f"📤 send {fname} ({_human(sz)}) → {dest}",
"timeout": 300, "status": "pending", "created": _now(),
}
_append_and_trim(cid, cmd_id)
return f"➡️ sending `{fname}` to `{cid}`:`{dest}`", None
def ui_get_file(client_label: str, remote_path: str):
"""Queue a pull: ask the container for a file at `remote_path`."""
cid = _strip_dot(client_label)
if not cid:
return "⚠️ pick a container first"
remote_path = (remote_path or "").strip()
if not remote_path:
return "⚠️ enter a path on the container"
cmd_id = uuid.uuid4().hex
with LOCK:
COMMANDS[cmd_id] = {
"id": cmd_id, "client_id": cid, "session": "file", "type": "get",
"remote_path": remote_path, "command": f"📥 fetch {remote_path}",
"timeout": 300, "status": "pending", "created": _now(),
}
_append_and_trim(cid, cmd_id)
return f"➡️ requested `{remote_path}` from `{cid}`"
def _fetched_files(client_label: str) -> list[str]:
cid = _strip_dot(client_label)
paths = []
with LOCK:
for cmd_id in BY_CLIENT.get(cid, []):
r = COMMANDS.get(cmd_id)
if r and r.get("type") == "get" and r.get("stored_path") \
and os.path.isfile(r["stored_path"]):
paths.append(r["stored_path"])
return paths
_STATUS_ICON = {"pending": "⏳", "acknowledged": "📨", "done": "✅"}
def ui_history(client_label: str, n: int = 12) -> str:
cid = _strip_dot(client_label)
if not cid:
return "_Select a container to see its command history._"
with LOCK:
ids = list(BY_CLIENT.get(cid, []))[-int(n):][::-1]
recs = [dict(COMMANDS[i]) for i in ids if i in COMMANDS]
if not recs:
return f"_No commands for `{cid}` yet._"
blocks = []
for r in recs:
icon = _STATUS_ICON.get(r["status"], "•")
head = f"{icon} **[{r.get('session','main')}]** `{r['command']}`"
if r["status"] == "done":
ec = r.get("exit_code", "?")
out = r.get("stdout", "") or "(no output)"
head += f" → exit {ec}"
blocks.append(head + f"\n```text\n{out}\n```")
else:
blocks.append(head + f"\n_{r['status']}…_")
return "\n\n".join(blocks)
def _keep_selection(choices: list[str], client_label: str) -> str | None:
cur = _strip_dot(client_label)
for ch in choices:
if _strip_dot(ch) == cur:
return ch
return choices[0] if choices else None
def ui_refresh(client_label: str):
choices = _client_choices()
keep = _keep_selection(choices, client_label)
return gr.update(choices=choices, value=keep), ui_history(keep or "")
def ui_tick(client_label: str, last_choices: list[str], last_hist: str,
last_files: list[str]):
"""Timer handler that only emits updates when something actually changed,
so the dropdown, output, and downloads don't repaint (flicker) every tick."""
choices = _client_choices()
if choices == last_choices:
dd_out = gr.skip() # dropdown unchanged -> leave it alone
label_for_hist = client_label
else:
keep = _keep_selection(choices, client_label)
dd_out = gr.update(choices=choices, value=keep)
label_for_hist = keep or ""
hist = ui_history(label_for_hist)
hist_out = gr.skip() if hist == last_hist else hist
files = _fetched_files(label_for_hist)
files_out = gr.skip() if files == last_files else gr.update(value=files)
return dd_out, hist_out, files_out, choices, hist, files
# --------------------------------------------------------------------------- #
# Layout #
# --------------------------------------------------------------------------- #
with gr.Blocks(title="NII Relay", analytics_enabled=False) as demo:
gr.Markdown(
"# 🛰️ NII Relay\n"
"Send shell commands to no-SSH containers via a polling daemon. "
"Pick a container, name a bash session (reuse a name to keep its `cd`/env, "
"or type a new name to open a fresh session), and run."
)
with gr.Row():
client_dd = gr.Dropdown(label="Container", choices=_client_choices(),
interactive=True, allow_custom_value=True, scale=3)
refresh_btn = gr.Button("↻ Refresh", scale=1)
with gr.Row():
session_tb = gr.Textbox(label="bash session", value="main", scale=2)
timeout_nb = gr.Number(label="timeout (s)", value=120, precision=0, scale=1)
command_tb = gr.Textbox(label="command", lines=2,
placeholder="nvidia-smi | cd /mnt/data && ls -la | tail -n 50 train.log")
with gr.Row():
send_btn = gr.Button("Run ▶", variant="primary")
broadcast_btn = gr.Button("📡 Broadcast to all online", variant="secondary")
send_status = gr.Markdown("")
history_md = gr.Markdown("_Select a container to see its command history._")
with gr.Accordion("📁 File transfer (≤ 100 MB)", open=False):
with gr.Row():
with gr.Column():
gr.Markdown("**Send to container** (push)")
put_file = gr.File(label="file", type="filepath")
put_dest = gr.Textbox(label="destination path on container",
placeholder="/mnt/data/ or /mnt/data/foo.bin")
put_btn = gr.Button("Send ⬆", variant="primary")
put_status = gr.Markdown("")
with gr.Column():
gr.Markdown("**Fetch from container** (pull)")
get_path = gr.Textbox(label="path on container",
placeholder="/mnt/data/train.log")
get_btn = gr.Button("Fetch ⬇", variant="primary")
get_status = gr.Markdown("")
downloads = gr.Files(label="fetched files (click to download)",
interactive=False)
# remember what we last pushed, so the timer can skip no-op repaints
last_choices = gr.State([])
last_hist = gr.State("")
last_files = gr.State([])
timer = gr.Timer(3.0)
# wiring
send_btn.click(ui_send, [client_dd, session_tb, command_tb, timeout_nb],
[send_status, command_tb])
command_tb.submit(ui_send, [client_dd, session_tb, command_tb, timeout_nb],
[send_status, command_tb])
broadcast_btn.click(ui_broadcast, [session_tb, command_tb, timeout_nb],
[send_status, command_tb])
refresh_btn.click(ui_refresh, [client_dd], [client_dd, history_md])
client_dd.change(ui_history, [client_dd], history_md)
client_dd.change(_fetched_files, [client_dd], downloads)
put_btn.click(ui_put_file, [client_dd, put_file, put_dest], [put_status, put_file])
get_btn.click(ui_get_file, [client_dd, get_path], get_status)
timer.tick(ui_tick, [client_dd, last_choices, last_hist, last_files],
[client_dd, history_md, downloads, last_choices, last_hist, last_files])
# --- hidden machine API endpoints (called by the daemon) ---
# Auth is the private Space + HF token (gating who can reach these at all);
# there is no separate app-level secret.
with gr.Row(visible=False):
a_cid = gr.Textbox()
a_meta = gr.Textbox()
a_cmdid = gr.Textbox()
a_session = gr.Textbox()
a_exit = gr.Number()
a_stdout = gr.Textbox()
a_fileid = gr.Textbox()
a_err = gr.Textbox()
a_file_in = gr.File()
a_file_out = gr.File()
a_out = gr.JSON()
b_reg = gr.Button()
b_poll = gr.Button()
b_res = gr.Button()
b_fetch = gr.Button()
b_upload = gr.Button()
b_reg.click(api_register, [a_cid, a_meta], a_out, api_name="register")
b_poll.click(api_poll, [a_cid], a_out, api_name="poll")
b_res.click(api_result, [a_cmdid, a_cid, a_exit, a_stdout, a_session],
a_out, api_name="result")
b_fetch.click(api_fetch_file, [a_fileid], a_file_out, api_name="fetch_file")
b_upload.click(api_upload_file, [a_cmdid, a_cid, a_file_in, a_err],
a_out, api_name="upload_result")
threading.Thread(target=_file_reaper, daemon=True).start()
if __name__ == "__main__":
demo.queue(default_concurrency_limit=16).launch(
server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
theme=gr.themes.Soft(), max_file_size="150mb",
)