runtime-sdk 0.4.8__tar.gz → 0.4.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/PKG-INFO +1 -1
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/pyproject.toml +1 -1
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/scripts/runtime_sdk/cli.py +501 -33
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/scripts/runtime_sdk/client.py +19 -10
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/scripts/runtime_sdk.egg-info/PKG-INFO +1 -1
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/README.md +0 -0
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/scripts/runtime_sdk/__init__.py +0 -0
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/scripts/runtime_sdk/config.py +0 -0
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/scripts/runtime_sdk.egg-info/SOURCES.txt +0 -0
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/scripts/runtime_sdk.egg-info/dependency_links.txt +0 -0
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/scripts/runtime_sdk.egg-info/entry_points.txt +0 -0
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/scripts/runtime_sdk.egg-info/requires.txt +0 -0
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/scripts/runtime_sdk.egg-info/top_level.txt +0 -0
- {runtime_sdk-0.4.8 → runtime_sdk-0.4.10}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.10
|
|
4
4
|
Summary: Runtime Python SDK and CLI
|
|
5
5
|
Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
|
|
6
6
|
Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
|
|
@@ -5,12 +5,18 @@ import contextlib
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import select
|
|
8
|
+
import shlex
|
|
8
9
|
import shutil
|
|
10
|
+
import signal
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
9
13
|
import sys
|
|
10
14
|
import termios
|
|
11
15
|
import threading
|
|
16
|
+
import time
|
|
12
17
|
import tty
|
|
13
18
|
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
14
20
|
from typing import Any, Callable
|
|
15
21
|
from urllib.parse import urljoin, urlparse
|
|
16
22
|
|
|
@@ -177,6 +183,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
177
183
|
info_cmd = subparsers.add_parser("info", help="Get computer details")
|
|
178
184
|
info_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
179
185
|
|
|
186
|
+
url_cmd = subparsers.add_parser("url", help="Show a computer's public URL and live published port")
|
|
187
|
+
url_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
188
|
+
|
|
180
189
|
start_cmd = subparsers.add_parser("start", help="Wake a cold computer")
|
|
181
190
|
start_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
182
191
|
|
|
@@ -203,6 +212,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
203
212
|
publish_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
204
213
|
publish_cmd.add_argument("port", nargs="?", type=int, default=None, help="Local app port")
|
|
205
214
|
|
|
215
|
+
proxy_cmd = subparsers.add_parser("proxy", help="Manage background local port proxies")
|
|
216
|
+
proxy_subparsers = proxy_cmd.add_subparsers(dest="proxy_command", required=True)
|
|
217
|
+
proxy_start = proxy_subparsers.add_parser("start", help="Start one or more background local port proxies")
|
|
218
|
+
proxy_start.add_argument("id", help="Computer ID (hostname)")
|
|
219
|
+
proxy_start.add_argument("ports", nargs="+", help="Port mappings like 5432 or 15432:5432")
|
|
220
|
+
proxy_subparsers.add_parser("ls", help="List background local port proxies")
|
|
221
|
+
proxy_stop = proxy_subparsers.add_parser("stop", help="Stop one or more background local port proxies")
|
|
222
|
+
proxy_stop.add_argument("local_ports", nargs="*", type=int, help="Local ports to stop")
|
|
223
|
+
proxy_stop.add_argument("--all", action="store_true", help="Stop all running proxies")
|
|
224
|
+
|
|
225
|
+
proxy_agent = subparsers.add_parser("_proxy_agent", help=argparse.SUPPRESS)
|
|
226
|
+
proxy_agent.add_argument("--base-url", required=True)
|
|
227
|
+
proxy_agent.add_argument("--computer-id", required=True)
|
|
228
|
+
proxy_agent.add_argument("--display-name", required=True)
|
|
229
|
+
proxy_agent.add_argument("--local-port", type=int, required=True)
|
|
230
|
+
proxy_agent.add_argument("--remote-port", type=int, required=True)
|
|
231
|
+
|
|
206
232
|
return parser
|
|
207
233
|
|
|
208
234
|
|
|
@@ -247,6 +273,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
247
273
|
return handle_list(config)
|
|
248
274
|
if args.command == "info":
|
|
249
275
|
return handle_info(config, args.id)
|
|
276
|
+
if args.command == "url":
|
|
277
|
+
return handle_url(config, args.id)
|
|
250
278
|
if args.command == "start":
|
|
251
279
|
return handle_start(config, args.id)
|
|
252
280
|
if args.command == "enter":
|
|
@@ -259,6 +287,23 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
259
287
|
)
|
|
260
288
|
if args.command == "publish":
|
|
261
289
|
return handle_publish(config, args.id, args.port)
|
|
290
|
+
if args.command == "proxy":
|
|
291
|
+
if args.proxy_command == "start":
|
|
292
|
+
return handle_proxy_start(config, args.id, args.ports)
|
|
293
|
+
if args.proxy_command == "ls":
|
|
294
|
+
return handle_proxy_ls(config)
|
|
295
|
+
if args.proxy_command == "stop":
|
|
296
|
+
return handle_proxy_stop(config, args.local_ports, stop_all=args.all)
|
|
297
|
+
return report_error("unknown proxy command")
|
|
298
|
+
if args.command == "_proxy_agent":
|
|
299
|
+
return run_proxy_agent(
|
|
300
|
+
args.base_url,
|
|
301
|
+
os.environ.get("RUNTIME_PROXY_API_KEY", ""),
|
|
302
|
+
args.computer_id,
|
|
303
|
+
args.display_name,
|
|
304
|
+
args.local_port,
|
|
305
|
+
args.remote_port,
|
|
306
|
+
)
|
|
262
307
|
except RuntimeAPIError as exc:
|
|
263
308
|
return report_error(str(exc), status_code=exc.status_code)
|
|
264
309
|
except RuntimeConfigError as exc:
|
|
@@ -311,6 +356,201 @@ def _require_api_key(config: RuntimeConfig) -> int | None:
|
|
|
311
356
|
return report_error("missing api key; run login or verify first")
|
|
312
357
|
|
|
313
358
|
|
|
359
|
+
def _runtime_state_dir() -> Path:
|
|
360
|
+
root = os.environ.get("XDG_STATE_HOME")
|
|
361
|
+
if root:
|
|
362
|
+
return Path(root) / "runtime"
|
|
363
|
+
return Path.home() / ".local" / "state" / "runtime"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _proxy_dir() -> Path:
|
|
367
|
+
return _runtime_state_dir() / "proxies"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _proxy_state_path(local_port: int) -> Path:
|
|
371
|
+
return _proxy_dir() / f"proxy-{local_port}.json"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _proxy_log_path(local_port: int) -> Path:
|
|
375
|
+
return _proxy_dir() / f"proxy-{local_port}.log"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _pid_running(pid: int) -> bool:
|
|
379
|
+
if pid <= 0:
|
|
380
|
+
return False
|
|
381
|
+
try:
|
|
382
|
+
os.kill(pid, 0)
|
|
383
|
+
return True
|
|
384
|
+
except OSError:
|
|
385
|
+
return False
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _pid_command(pid: int) -> str:
|
|
389
|
+
if pid <= 0:
|
|
390
|
+
return ""
|
|
391
|
+
try:
|
|
392
|
+
return subprocess.check_output(
|
|
393
|
+
["ps", "-o", "command=", "-p", str(pid)],
|
|
394
|
+
text=True,
|
|
395
|
+
stderr=subprocess.DEVNULL,
|
|
396
|
+
).strip()
|
|
397
|
+
except (OSError, subprocess.SubprocessError):
|
|
398
|
+
return ""
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _proxy_pid_matches(pid: int, local_port: int) -> bool:
|
|
402
|
+
if not _pid_running(pid):
|
|
403
|
+
return False
|
|
404
|
+
command = _pid_command(pid)
|
|
405
|
+
if not command:
|
|
406
|
+
return False
|
|
407
|
+
with contextlib.suppress(ValueError):
|
|
408
|
+
argv = shlex.split(command)
|
|
409
|
+
if "_proxy_agent" not in argv:
|
|
410
|
+
return False
|
|
411
|
+
if "--local-port" not in argv:
|
|
412
|
+
return False
|
|
413
|
+
index = argv.index("--local-port")
|
|
414
|
+
if index + 1 >= len(argv) or argv[index + 1] != str(local_port):
|
|
415
|
+
return False
|
|
416
|
+
return "runtime_sdk.cli" in argv or any(part.endswith("runtime_sdk.cli") for part in argv)
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _load_proxy_records(*, clean_stale: bool = False) -> list[dict[str, Any]]:
|
|
421
|
+
records: list[dict[str, Any]] = []
|
|
422
|
+
directory = _proxy_dir()
|
|
423
|
+
if not directory.exists():
|
|
424
|
+
return records
|
|
425
|
+
for path in sorted(directory.glob("proxy-*.json")):
|
|
426
|
+
try:
|
|
427
|
+
record = json.loads(path.read_text())
|
|
428
|
+
except (OSError, ValueError):
|
|
429
|
+
continue
|
|
430
|
+
if not isinstance(record, dict):
|
|
431
|
+
continue
|
|
432
|
+
record["state_path"] = str(path)
|
|
433
|
+
pid = int(record.get("pid") or 0)
|
|
434
|
+
local_port = int(record.get("local_port") or 0)
|
|
435
|
+
record["status"] = "running" if _proxy_pid_matches(pid, local_port) else "stopped"
|
|
436
|
+
if clean_stale and record["status"] != "running":
|
|
437
|
+
with contextlib.suppress(OSError):
|
|
438
|
+
path.unlink()
|
|
439
|
+
continue
|
|
440
|
+
records.append(record)
|
|
441
|
+
return records
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _parse_proxy_port_spec(spec: str) -> tuple[int, int]:
|
|
445
|
+
text = str(spec or "").strip()
|
|
446
|
+
if not text:
|
|
447
|
+
raise RuntimeAPIError("port mapping is required")
|
|
448
|
+
if ":" in text:
|
|
449
|
+
local_text, remote_text = text.split(":", 1)
|
|
450
|
+
else:
|
|
451
|
+
local_text, remote_text = text, text
|
|
452
|
+
try:
|
|
453
|
+
local_port = int(local_text)
|
|
454
|
+
remote_port = int(remote_text)
|
|
455
|
+
except ValueError as exc:
|
|
456
|
+
raise RuntimeAPIError(f"invalid port mapping: {text}") from exc
|
|
457
|
+
for port in (local_port, remote_port):
|
|
458
|
+
if port <= 0 or port > 65535:
|
|
459
|
+
raise RuntimeAPIError("port must be between 1 and 65535")
|
|
460
|
+
return local_port, remote_port
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _resolve_ws_url(base_url: str, ws_url: Any) -> str:
|
|
464
|
+
text = str(ws_url or "").strip()
|
|
465
|
+
if not text:
|
|
466
|
+
return ""
|
|
467
|
+
if text.startswith("ws://") or text.startswith("wss://"):
|
|
468
|
+
return text
|
|
469
|
+
|
|
470
|
+
parsed = urlparse(base_url)
|
|
471
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
472
|
+
root = f"{scheme}://{parsed.netloc}"
|
|
473
|
+
if text.startswith("/"):
|
|
474
|
+
return root + text
|
|
475
|
+
return urljoin(root + "/", text)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _spawn_proxy_agent(base_url: str, api_key: str, computer_id: str, display_name: str, local_port: int, remote_port: int) -> int:
|
|
479
|
+
proxy_dir = _proxy_dir()
|
|
480
|
+
proxy_dir.mkdir(parents=True, exist_ok=True)
|
|
481
|
+
log_path = _proxy_log_path(local_port)
|
|
482
|
+
log_file = log_path.open("a", encoding="utf-8")
|
|
483
|
+
child_env = os.environ.copy()
|
|
484
|
+
child_env["RUNTIME_PROXY_API_KEY"] = api_key
|
|
485
|
+
try:
|
|
486
|
+
proc = subprocess.Popen(
|
|
487
|
+
[
|
|
488
|
+
sys.executable,
|
|
489
|
+
"-m",
|
|
490
|
+
"runtime_sdk.cli",
|
|
491
|
+
"_proxy_agent",
|
|
492
|
+
"--base-url",
|
|
493
|
+
base_url,
|
|
494
|
+
"--computer-id",
|
|
495
|
+
computer_id,
|
|
496
|
+
"--display-name",
|
|
497
|
+
display_name,
|
|
498
|
+
"--local-port",
|
|
499
|
+
str(local_port),
|
|
500
|
+
"--remote-port",
|
|
501
|
+
str(remote_port),
|
|
502
|
+
],
|
|
503
|
+
stdin=subprocess.DEVNULL,
|
|
504
|
+
stdout=log_file,
|
|
505
|
+
stderr=log_file,
|
|
506
|
+
start_new_session=True,
|
|
507
|
+
close_fds=True,
|
|
508
|
+
env=child_env,
|
|
509
|
+
)
|
|
510
|
+
finally:
|
|
511
|
+
log_file.close()
|
|
512
|
+
|
|
513
|
+
deadline = time.time() + 4.0
|
|
514
|
+
state_path = _proxy_state_path(local_port)
|
|
515
|
+
while time.time() < deadline:
|
|
516
|
+
if state_path.exists():
|
|
517
|
+
return proc.pid
|
|
518
|
+
code = proc.poll()
|
|
519
|
+
if code is not None:
|
|
520
|
+
break
|
|
521
|
+
time.sleep(0.1)
|
|
522
|
+
|
|
523
|
+
message = f"proxy failed to start on localhost:{local_port}"
|
|
524
|
+
with contextlib.suppress(OSError):
|
|
525
|
+
tail = log_path.read_text(encoding="utf-8").strip()
|
|
526
|
+
if tail:
|
|
527
|
+
message = tail.splitlines()[-1]
|
|
528
|
+
raise RuntimeAPIError(message)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _remove_proxy_state(local_port: int) -> None:
|
|
532
|
+
with contextlib.suppress(OSError):
|
|
533
|
+
_proxy_state_path(local_port).unlink()
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _stop_proxy_ports(local_ports: list[int], *, stop_all: bool = False) -> list[dict[str, Any]]:
|
|
537
|
+
records = _load_proxy_records(clean_stale=False)
|
|
538
|
+
stopped: list[dict[str, Any]] = []
|
|
539
|
+
wanted = set(local_ports)
|
|
540
|
+
for record in records:
|
|
541
|
+
port = int(record.get("local_port") or 0)
|
|
542
|
+
if not stop_all and port not in wanted:
|
|
543
|
+
continue
|
|
544
|
+
pid = int(record.get("pid") or 0)
|
|
545
|
+
if _proxy_pid_matches(pid, port):
|
|
546
|
+
with contextlib.suppress(OSError):
|
|
547
|
+
os.kill(pid, signal.SIGTERM)
|
|
548
|
+
_remove_proxy_state(port)
|
|
549
|
+
record["status"] = "stopped"
|
|
550
|
+
stopped.append(record)
|
|
551
|
+
return stopped
|
|
552
|
+
|
|
553
|
+
|
|
314
554
|
def _prompt_text(message: str, *, default: str | None = None) -> str | None:
|
|
315
555
|
"""Ask for a line of text. Returns None if the user left it blank.
|
|
316
556
|
|
|
@@ -732,6 +972,33 @@ def _render_run_result(result: dict[str, Any]) -> None:
|
|
|
732
972
|
console.print(f"[{color}]exit {exit_code}[/{color}]")
|
|
733
973
|
|
|
734
974
|
|
|
975
|
+
def _render_network_panel(payload: dict[str, Any], *, title: str = "url") -> None:
|
|
976
|
+
mods = _UI.rich()
|
|
977
|
+
Panel, Text = mods["Panel"], mods["Text"]
|
|
978
|
+
body = Text()
|
|
979
|
+
status = str(payload.get("status") or "unknown")
|
|
980
|
+
published_port = payload.get("published_port")
|
|
981
|
+
startup_port = payload.get("startup_port")
|
|
982
|
+
rows: list[tuple[str, str, str | None]] = [
|
|
983
|
+
("url", str(payload.get("public_url") or "—"), "cyan"),
|
|
984
|
+
("state", f"{_computer_status_dot(status)} {status}", _computer_status_style(status)),
|
|
985
|
+
]
|
|
986
|
+
if published_port:
|
|
987
|
+
rows.append(("port", str(published_port), "bold white"))
|
|
988
|
+
elif startup_port:
|
|
989
|
+
rows.append(("port", str(startup_port), "bold white"))
|
|
990
|
+
rows.append(("source", "durable startup", "dim"))
|
|
991
|
+
else:
|
|
992
|
+
rows.append(("port", "—", "dim"))
|
|
993
|
+
if published_port and startup_port and startup_port != published_port:
|
|
994
|
+
rows.append(("startup", str(startup_port), "dim"))
|
|
995
|
+
for label, value, style in rows:
|
|
996
|
+
body.append(f"{label:<8}", style="dim")
|
|
997
|
+
body.append(value, style=style or "white")
|
|
998
|
+
body.append("\n")
|
|
999
|
+
_UI.console().print(Panel(body, title=title, border_style="cyan", expand=False, padding=(0, 1)))
|
|
1000
|
+
|
|
1001
|
+
|
|
735
1002
|
def _render_whoami_panel(payload: dict[str, Any]) -> None:
|
|
736
1003
|
mods = _UI.rich()
|
|
737
1004
|
Panel, Text = mods["Panel"], mods["Text"]
|
|
@@ -939,7 +1206,7 @@ def handle_create(
|
|
|
939
1206
|
computer_id = result.get("id") or result.get("slug")
|
|
940
1207
|
if not computer_id:
|
|
941
1208
|
return code
|
|
942
|
-
return _enter_computer(config, str(computer_id)
|
|
1209
|
+
return _enter_computer(config, str(computer_id))
|
|
943
1210
|
|
|
944
1211
|
|
|
945
1212
|
def handle_list(config: RuntimeConfig) -> int:
|
|
@@ -973,6 +1240,26 @@ def handle_info(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
|
973
1240
|
return report_success(result, lambda p: _render_computer_panel(p, title="computer"))
|
|
974
1241
|
|
|
975
1242
|
|
|
1243
|
+
def handle_url(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
1244
|
+
if (err := _require_api_key(config)) is not None:
|
|
1245
|
+
return err
|
|
1246
|
+
|
|
1247
|
+
if computer_id is None:
|
|
1248
|
+
if not _interactive():
|
|
1249
|
+
return report_error("computer id is required")
|
|
1250
|
+
picked = _pick_computer(config, "Pick a computer")
|
|
1251
|
+
if picked is None:
|
|
1252
|
+
return 0
|
|
1253
|
+
computer_id = picked.get("id") or picked.get("slug")
|
|
1254
|
+
if not computer_id:
|
|
1255
|
+
return report_error("computer is missing an id")
|
|
1256
|
+
|
|
1257
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1258
|
+
computer_id = _resolve_computer_ref(client, computer_id)
|
|
1259
|
+
result = client.get_computer_network(computer_id)
|
|
1260
|
+
return report_success(result, lambda p: _render_network_panel(p, title="url"))
|
|
1261
|
+
|
|
1262
|
+
|
|
976
1263
|
def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
977
1264
|
if (err := _require_api_key(config)) is not None:
|
|
978
1265
|
return err
|
|
@@ -1139,40 +1426,233 @@ def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | N
|
|
|
1139
1426
|
|
|
1140
1427
|
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1141
1428
|
computer_id = _resolve_computer_ref(client, computer_id)
|
|
1142
|
-
_with_spinner(
|
|
1429
|
+
result = _with_spinner(
|
|
1143
1430
|
f"publishing port {port} on {computer_id}…",
|
|
1144
1431
|
lambda: client.publish_port(computer_id, port),
|
|
1145
1432
|
)
|
|
1146
1433
|
|
|
1147
|
-
def render(
|
|
1434
|
+
def render(payload: dict[str, Any]) -> None:
|
|
1148
1435
|
_UI.console().print(
|
|
1149
|
-
f"[green]✓[/green] public url for [bold]{computer_id}[/bold] pinned to [bold]{port}[/bold]"
|
|
1436
|
+
f"[green]✓[/green] public url for [bold]{computer_id}[/bold] pinned to [bold]{payload.get('published_port') or port}[/bold]"
|
|
1150
1437
|
)
|
|
1438
|
+
url = payload.get("public_url")
|
|
1439
|
+
if url:
|
|
1440
|
+
_UI.console().print(f"[bold green]→[/bold green] [cyan]{url}[/cyan]")
|
|
1151
1441
|
|
|
1152
|
-
return report_success(
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1442
|
+
return report_success(result, render)
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def handle_proxy_start(config: RuntimeConfig, computer_id: str | None, specs: list[str] | None) -> int:
|
|
1446
|
+
if (err := _require_api_key(config)) is not None:
|
|
1447
|
+
return err
|
|
1448
|
+
if not computer_id:
|
|
1449
|
+
return report_error("computer id is required")
|
|
1450
|
+
if not specs:
|
|
1451
|
+
return report_error("at least one port mapping is required")
|
|
1452
|
+
|
|
1453
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1454
|
+
resolved_id = _resolve_computer_ref(client, computer_id)
|
|
1455
|
+
existing_records = _load_proxy_records(clean_stale=True)
|
|
1456
|
+
existing_ports = {int(r.get("local_port") or 0) for r in existing_records}
|
|
1457
|
+
mappings: list[tuple[int, int]] = []
|
|
1458
|
+
seen_ports: set[int] = set()
|
|
1459
|
+
for spec in specs:
|
|
1460
|
+
local_port, remote_port = _parse_proxy_port_spec(spec)
|
|
1461
|
+
if local_port in existing_ports:
|
|
1462
|
+
return report_error(f"local port {local_port} is already in use by a runtime proxy")
|
|
1463
|
+
if local_port in seen_ports:
|
|
1464
|
+
return report_error(f"local port {local_port} was specified more than once")
|
|
1465
|
+
seen_ports.add(local_port)
|
|
1466
|
+
mappings.append((local_port, remote_port))
|
|
1467
|
+
|
|
1468
|
+
started: list[dict[str, Any]] = []
|
|
1469
|
+
started_ports: list[int] = []
|
|
1470
|
+
for local_port, remote_port in mappings:
|
|
1471
|
+
try:
|
|
1472
|
+
_spawn_proxy_agent(config.base_url, str(config.api_key or ""), resolved_id, computer_id, local_port, remote_port)
|
|
1473
|
+
except RuntimeAPIError as exc:
|
|
1474
|
+
if started_ports:
|
|
1475
|
+
_stop_proxy_ports(started_ports, stop_all=False)
|
|
1476
|
+
return report_error(str(exc), status_code=exc.status_code)
|
|
1477
|
+
started_ports.append(local_port)
|
|
1478
|
+
started.append({"computer_id": resolved_id, "computer": computer_id, "local_port": local_port, "remote_port": remote_port})
|
|
1479
|
+
|
|
1480
|
+
def render(payload: dict[str, Any]) -> None:
|
|
1481
|
+
_UI.console().print("[green]✓[/green] proxy started")
|
|
1482
|
+
for proxy in payload.get("proxies", []):
|
|
1483
|
+
_UI.console().print(
|
|
1484
|
+
f" [bold]localhost:{proxy['local_port']}[/bold] -> [cyan]{proxy['computer']}[/cyan]:{proxy['remote_port']}"
|
|
1485
|
+
)
|
|
1486
|
+
|
|
1487
|
+
return report_success({"proxies": started}, render)
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def handle_proxy_ls(config: RuntimeConfig) -> int:
|
|
1491
|
+
proxies = _load_proxy_records(clean_stale=True)
|
|
1492
|
+
|
|
1493
|
+
def render(payload: dict[str, Any]) -> None:
|
|
1494
|
+
mods = _UI.rich()
|
|
1495
|
+
Table = mods["Table"]
|
|
1496
|
+
table = Table(title="proxies")
|
|
1497
|
+
table.add_column("VM", style="white")
|
|
1498
|
+
table.add_column("Local", style="cyan")
|
|
1499
|
+
table.add_column("Remote", style="white")
|
|
1500
|
+
table.add_column("Status", style="green")
|
|
1501
|
+
for proxy in payload.get("proxies", []):
|
|
1502
|
+
table.add_row(
|
|
1503
|
+
str(proxy.get("display_name") or proxy.get("computer_id") or "—"),
|
|
1504
|
+
str(proxy.get("local_port") or "—"),
|
|
1505
|
+
str(proxy.get("remote_port") or "—"),
|
|
1506
|
+
str(proxy.get("status") or "unknown"),
|
|
1507
|
+
)
|
|
1508
|
+
_UI.console().print(table)
|
|
1509
|
+
|
|
1510
|
+
return report_success({"proxies": proxies}, render if _interactive() else None)
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def handle_proxy_stop(config: RuntimeConfig, local_ports: list[int] | None, *, stop_all: bool = False) -> int:
|
|
1514
|
+
if not stop_all and not local_ports:
|
|
1515
|
+
return report_error("pass one or more local ports, or use --all")
|
|
1516
|
+
|
|
1517
|
+
stopped = _stop_proxy_ports(local_ports or [], stop_all=stop_all)
|
|
1518
|
+
|
|
1519
|
+
def render(payload: dict[str, Any]) -> None:
|
|
1520
|
+
_UI.console().print("[green]✓[/green] proxy stopped")
|
|
1521
|
+
for proxy in payload.get("proxies", []):
|
|
1522
|
+
_UI.console().print(
|
|
1523
|
+
f" [bold]localhost:{proxy.get('local_port')}[/bold] -> [cyan]{proxy.get('display_name') or proxy.get('computer_id')}[/cyan]:{proxy.get('remote_port')}"
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
return report_success({"proxies": stopped}, render if _interactive() else None)
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def run_proxy_agent(base_url: str, api_key: str, computer_id: str, display_name: str, local_port: int, remote_port: int) -> int:
|
|
1530
|
+
if not api_key:
|
|
1531
|
+
print("proxy agent missing api key", file=sys.stderr)
|
|
1532
|
+
return 1
|
|
1533
|
+
|
|
1534
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
1535
|
+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
1536
|
+
try:
|
|
1537
|
+
listener.bind(("127.0.0.1", local_port))
|
|
1538
|
+
listener.listen()
|
|
1539
|
+
listener.settimeout(1.0)
|
|
1540
|
+
except OSError as exc:
|
|
1541
|
+
print(f"proxy listen failed on localhost:{local_port}: {exc}", file=sys.stderr)
|
|
1542
|
+
return 1
|
|
1543
|
+
|
|
1544
|
+
proxy_dir = _proxy_dir()
|
|
1545
|
+
proxy_dir.mkdir(parents=True, exist_ok=True)
|
|
1546
|
+
state = {
|
|
1547
|
+
"pid": os.getpid(),
|
|
1548
|
+
"computer_id": computer_id,
|
|
1549
|
+
"display_name": display_name,
|
|
1550
|
+
"local_port": local_port,
|
|
1551
|
+
"remote_port": remote_port,
|
|
1552
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
1553
|
+
}
|
|
1554
|
+
_proxy_state_path(local_port).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
|
|
1555
|
+
|
|
1556
|
+
stop_event = threading.Event()
|
|
1557
|
+
|
|
1558
|
+
def handle_signal(_signum: int, _frame: Any) -> None:
|
|
1559
|
+
stop_event.set()
|
|
1560
|
+
with contextlib.suppress(OSError):
|
|
1561
|
+
listener.close()
|
|
1160
1562
|
|
|
1563
|
+
signal.signal(signal.SIGTERM, handle_signal)
|
|
1564
|
+
signal.signal(signal.SIGINT, handle_signal)
|
|
1565
|
+
|
|
1566
|
+
try:
|
|
1567
|
+
while not stop_event.is_set():
|
|
1568
|
+
try:
|
|
1569
|
+
conn, _ = listener.accept()
|
|
1570
|
+
except socket.timeout:
|
|
1571
|
+
continue
|
|
1572
|
+
except OSError:
|
|
1573
|
+
if stop_event.is_set():
|
|
1574
|
+
break
|
|
1575
|
+
continue
|
|
1576
|
+
thread = threading.Thread(
|
|
1577
|
+
target=_handle_proxy_client,
|
|
1578
|
+
args=(base_url, api_key, computer_id, remote_port, conn),
|
|
1579
|
+
daemon=True,
|
|
1580
|
+
)
|
|
1581
|
+
thread.start()
|
|
1582
|
+
finally:
|
|
1583
|
+
with contextlib.suppress(OSError):
|
|
1584
|
+
listener.close()
|
|
1585
|
+
_remove_proxy_state(local_port)
|
|
1586
|
+
return 0
|
|
1161
1587
|
|
|
1162
|
-
|
|
1588
|
+
|
|
1589
|
+
def _handle_proxy_client(base_url: str, api_key: str, computer_id: str, remote_port: int, conn: socket.socket) -> None:
|
|
1590
|
+
client = RuntimeClient(base_url=base_url, api_key=api_key)
|
|
1591
|
+
try:
|
|
1592
|
+
ticket_payload = client.create_proxy_ticket(computer_id, remote_port)
|
|
1593
|
+
ws_url = _resolve_ws_url(base_url, ticket_payload.get("ws_url"))
|
|
1594
|
+
if not ws_url:
|
|
1595
|
+
raise RuntimeAPIError("proxy endpoint did not return a websocket url")
|
|
1596
|
+
with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1, max_size=None) as ws:
|
|
1597
|
+
stop = threading.Event()
|
|
1598
|
+
|
|
1599
|
+
def local_to_ws() -> None:
|
|
1600
|
+
try:
|
|
1601
|
+
while not stop.is_set():
|
|
1602
|
+
data = conn.recv(65536)
|
|
1603
|
+
if not data:
|
|
1604
|
+
break
|
|
1605
|
+
ws.send(data)
|
|
1606
|
+
except Exception:
|
|
1607
|
+
pass
|
|
1608
|
+
finally:
|
|
1609
|
+
stop.set()
|
|
1610
|
+
with contextlib.suppress(Exception):
|
|
1611
|
+
ws.close()
|
|
1612
|
+
|
|
1613
|
+
def ws_to_local() -> None:
|
|
1614
|
+
try:
|
|
1615
|
+
while not stop.is_set():
|
|
1616
|
+
data = ws.recv()
|
|
1617
|
+
if data is None:
|
|
1618
|
+
break
|
|
1619
|
+
if isinstance(data, str):
|
|
1620
|
+
chunk = data.encode()
|
|
1621
|
+
else:
|
|
1622
|
+
chunk = data
|
|
1623
|
+
if chunk:
|
|
1624
|
+
conn.sendall(chunk)
|
|
1625
|
+
except Exception:
|
|
1626
|
+
pass
|
|
1627
|
+
finally:
|
|
1628
|
+
stop.set()
|
|
1629
|
+
with contextlib.suppress(OSError):
|
|
1630
|
+
conn.shutdown(socket.SHUT_RDWR)
|
|
1631
|
+
|
|
1632
|
+
t1 = threading.Thread(target=local_to_ws, daemon=True)
|
|
1633
|
+
t2 = threading.Thread(target=ws_to_local, daemon=True)
|
|
1634
|
+
t1.start()
|
|
1635
|
+
t2.start()
|
|
1636
|
+
t1.join()
|
|
1637
|
+
t2.join()
|
|
1638
|
+
except Exception as exc:
|
|
1639
|
+
print(f"proxy connection failed for localhost:{conn.getsockname()[1]} -> {computer_id}:{remote_port}: {exc}", file=sys.stderr)
|
|
1640
|
+
finally:
|
|
1641
|
+
with contextlib.suppress(OSError):
|
|
1642
|
+
conn.close()
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
|
|
1163
1646
|
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1164
1647
|
ticket_payload = _with_spinner(
|
|
1165
1648
|
f"preparing console for {computer_id}…",
|
|
1166
1649
|
lambda: client.create_terminal_ticket(computer_id),
|
|
1167
1650
|
)
|
|
1168
|
-
ws_url =
|
|
1651
|
+
ws_url = _resolve_ws_url(config.base_url, ticket_payload.get("ws_url"))
|
|
1169
1652
|
if not ws_url:
|
|
1170
1653
|
return report_error("terminal endpoint did not return a websocket url")
|
|
1171
1654
|
|
|
1172
|
-
|
|
1173
|
-
_UI.console().print("[cyan]●[/cyan] Connecting to console...")
|
|
1174
|
-
else:
|
|
1175
|
-
_UI.console().print("[cyan]●[/cyan] Connecting to console...")
|
|
1655
|
+
_UI.console().print("[cyan]●[/cyan] Connecting to console...")
|
|
1176
1656
|
|
|
1177
1657
|
try:
|
|
1178
1658
|
return _run_terminal_session(ws_url)
|
|
@@ -1182,21 +1662,6 @@ def _enter_computer(config: RuntimeConfig, computer_id: str, *, announce_created
|
|
|
1182
1662
|
return report_error(f"terminal failed: {exc}")
|
|
1183
1663
|
|
|
1184
1664
|
|
|
1185
|
-
def _resolve_terminal_ws_url(base_url: str, ws_url: Any) -> str:
|
|
1186
|
-
text = str(ws_url or "").strip()
|
|
1187
|
-
if not text:
|
|
1188
|
-
return ""
|
|
1189
|
-
if text.startswith("ws://") or text.startswith("wss://"):
|
|
1190
|
-
return text
|
|
1191
|
-
|
|
1192
|
-
parsed = urlparse(base_url)
|
|
1193
|
-
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
1194
|
-
root = f"{scheme}://{parsed.netloc}"
|
|
1195
|
-
if text.startswith("/"):
|
|
1196
|
-
return root + text
|
|
1197
|
-
return urljoin(root + "/", text)
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
1665
|
def _run_terminal_session(ws_url: str) -> int:
|
|
1201
1666
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
1202
1667
|
return report_error("enter requires an interactive terminal")
|
|
@@ -1241,6 +1706,9 @@ def _run_terminal_session(ws_url: str) -> int:
|
|
|
1241
1706
|
stop.set()
|
|
1242
1707
|
return
|
|
1243
1708
|
except ConnectionClosed:
|
|
1709
|
+
if not stop.is_set() and not result.get("error") and int(result.get("code") or 0) == 0:
|
|
1710
|
+
result["code"] = 1
|
|
1711
|
+
result["error"] = "terminal connection closed unexpectedly"
|
|
1244
1712
|
stop.set()
|
|
1245
1713
|
except Exception as exc: # pragma: no cover - defensive for real TTY sessions
|
|
1246
1714
|
result["code"] = 1
|
|
@@ -79,6 +79,9 @@ class RuntimeClient:
|
|
|
79
79
|
def get_computer(self, computer_id: str) -> dict[str, Any]:
|
|
80
80
|
return self._request("GET", f"/api/computers/{computer_id}", auth_required=True)
|
|
81
81
|
|
|
82
|
+
def get_computer_network(self, computer_id: str) -> dict[str, Any]:
|
|
83
|
+
return self._request("GET", f"/api/computers/{computer_id}/network", auth_required=True)
|
|
84
|
+
|
|
82
85
|
def start_computer(self, computer_id: str) -> dict[str, Any]:
|
|
83
86
|
return self._request(
|
|
84
87
|
"POST",
|
|
@@ -98,6 +101,17 @@ class RuntimeClient:
|
|
|
98
101
|
timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
|
|
99
102
|
)
|
|
100
103
|
|
|
104
|
+
def create_proxy_ticket(self, computer_id: str, port: int) -> dict[str, Any]:
|
|
105
|
+
if port <= 0 or port > 65535:
|
|
106
|
+
raise RuntimeAPIError("port must be between 1 and 65535")
|
|
107
|
+
return self._request(
|
|
108
|
+
"POST",
|
|
109
|
+
f"/api/computers/{computer_id}/proxy-ticket",
|
|
110
|
+
json={"port": port},
|
|
111
|
+
auth_required=True,
|
|
112
|
+
timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
|
|
113
|
+
)
|
|
114
|
+
|
|
101
115
|
def run_command(
|
|
102
116
|
self,
|
|
103
117
|
computer_id: str,
|
|
@@ -122,17 +136,12 @@ class RuntimeClient:
|
|
|
122
136
|
def publish_port(self, computer_id: str, port: int) -> dict[str, Any]:
|
|
123
137
|
if port <= 0 or port > 65535:
|
|
124
138
|
raise RuntimeAPIError("port must be between 1 and 65535")
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
computer_id,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
gid=0,
|
|
139
|
+
return self._request(
|
|
140
|
+
"POST",
|
|
141
|
+
f"/api/computers/{computer_id}/publish",
|
|
142
|
+
json={"port": port},
|
|
143
|
+
auth_required=True,
|
|
131
144
|
)
|
|
132
|
-
if result.get("exit_code") != 0:
|
|
133
|
-
message = result.get("stderr") or result.get("stdout") or "failed to publish port"
|
|
134
|
-
raise RuntimeAPIError(str(message).strip())
|
|
135
|
-
return result
|
|
136
145
|
|
|
137
146
|
def _request(
|
|
138
147
|
self,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.10
|
|
4
4
|
Summary: Runtime Python SDK and CLI
|
|
5
5
|
Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
|
|
6
6
|
Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|