alyxruntime 0.1.0__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.
- alyxruntime-0.1.0/PKG-INFO +133 -0
- alyxruntime-0.1.0/README.md +117 -0
- alyxruntime-0.1.0/alyxruntime/__init__.py +6 -0
- alyxruntime-0.1.0/alyxruntime/__main__.py +4 -0
- alyxruntime-0.1.0/alyxruntime/cli.py +255 -0
- alyxruntime-0.1.0/alyxruntime/client.py +294 -0
- alyxruntime-0.1.0/alyxruntime/config.py +34 -0
- alyxruntime-0.1.0/alyxruntime/hardware.py +58 -0
- alyxruntime-0.1.0/alyxruntime/server.py +344 -0
- alyxruntime-0.1.0/alyxruntime/tunnel.py +152 -0
- alyxruntime-0.1.0/alyxruntime.egg-info/PKG-INFO +133 -0
- alyxruntime-0.1.0/alyxruntime.egg-info/SOURCES.txt +16 -0
- alyxruntime-0.1.0/alyxruntime.egg-info/dependency_links.txt +1 -0
- alyxruntime-0.1.0/alyxruntime.egg-info/entry_points.txt +2 -0
- alyxruntime-0.1.0/alyxruntime.egg-info/requires.txt +11 -0
- alyxruntime-0.1.0/alyxruntime.egg-info/top_level.txt +1 -0
- alyxruntime-0.1.0/pyproject.toml +23 -0
- alyxruntime-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alyxruntime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Use any remote machine's CPU/GPU as a Jupyter runtime over a Cloudflare tunnel, Colab-style, from the command line or Python.
|
|
5
|
+
Requires-Python: >=3.8
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: requests>=2.28
|
|
8
|
+
Requires-Dist: websocket-client>=1.4
|
|
9
|
+
Provides-Extra: host
|
|
10
|
+
Requires-Dist: jupyter_server>=2.0; extra == "host"
|
|
11
|
+
Requires-Dist: ipykernel>=6.0; extra == "host"
|
|
12
|
+
Provides-Extra: lab
|
|
13
|
+
Requires-Dist: jupyter_server>=2.0; extra == "lab"
|
|
14
|
+
Requires-Dist: ipykernel>=6.0; extra == "lab"
|
|
15
|
+
Requires-Dist: jupyterlab>=4.0; extra == "lab"
|
|
16
|
+
|
|
17
|
+
# alyxruntime
|
|
18
|
+
|
|
19
|
+
Use any other computer's CPU/GPU as your Jupyter runtime - like Colab's "connect to local runtime",
|
|
20
|
+
but for *any* machine you control, from the command line or from Python.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
your laptop Cloudflare edge GPU box (host) - no open ports, outbound only
|
|
24
|
+
┌────────────┐ https ┌───────────────┐ tunnel ┌─────────────────────────────────────────┐
|
|
25
|
+
│ alyxruntime│ ────► │ *.trycloudflare│ ◄──────│ cloudflared ─► IP-allowlist gate │
|
|
26
|
+
│ connect │ +token│ .com │ │ └─► Jupyter (loopback) │
|
|
27
|
+
└────────────┘ └───────────────┘ │ └─► kernel (GPU) │
|
|
28
|
+
└─────────────────────────────────────────┘
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The host talks *out* to Cloudflare through `cloudflared`, so there is **no port forwarding, no public IP,
|
|
32
|
+
and HTTPS is included** - it works from behind home routers, NAT and most firewalls.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install "alyxruntime[host]" # on the machine that SHARES its GPU/CPU
|
|
38
|
+
pip install alyxruntime # on the machine that CONNECTS (lighter)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
On the **host** (the machine with the GPU):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
alyxruntime stream --allowed_ip 203.0.113.7 # your client's public IP
|
|
47
|
+
alyxruntime stream --allowed_ip 203.0.113.7 10.0.0.0/24 # several IPs / CIDR ranges
|
|
48
|
+
alyxruntime stream --allowed_ip all # any IP - the token is the only gate
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
It starts a Cloudflare quick tunnel (if `cloudflared` is missing it offers to download the official
|
|
52
|
+
binary, or pass `--install-cloudflared`) and prints a ready-to-paste command with a random access token:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
alyxruntime connect 'https://random-words.trycloudflare.com/?token=...'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Not sure what your public IP is? Run `alyxruntime connect` from the client anyway: the host replies
|
|
59
|
+
`access denied for IP x.x.x.x. Ask the host to add it with --allowed_ip x.x.x.x`.
|
|
60
|
+
|
|
61
|
+
On the **client**:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
alyxruntime connect https://my-host:8765/?token=XXXX # verifies, shows remote CPU/RAM/GPU, saves session
|
|
65
|
+
alyxruntime run train.py # run a script (also .ipynb, `-c "code"`, or stdin with `-`)
|
|
66
|
+
alyxruntime shell # interactive prompt; variables persist between commands
|
|
67
|
+
alyxruntime info # remote CPU / RAM / GPU
|
|
68
|
+
alyxruntime upload data.csv # copy files to the host's working dir
|
|
69
|
+
alyxruntime download model.pt
|
|
70
|
+
alyxruntime restart # fresh kernel
|
|
71
|
+
alyxruntime disconnect --kill-kernel
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Plots (`plt.show()`) are saved locally to `./alyx_outputs/`.
|
|
75
|
+
VS Code / JupyterLab also work: *Select Kernel -> Existing Jupyter Server* -> paste the same URL.
|
|
76
|
+
|
|
77
|
+
### From Python
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from alyxruntime import Runtime
|
|
81
|
+
|
|
82
|
+
rt = Runtime.connect("https://my-host:8765/?token=XXXX")
|
|
83
|
+
rt.run("import torch; print(torch.cuda.is_available())")
|
|
84
|
+
res = rt.run("2 + 2", echo=False); print(res.results) # ['4']
|
|
85
|
+
rt.upload("data.csv"); rt.download("out.pt")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Host side from Python: `from alyxruntime import serve; serve(allowed_ip=["203.0.113.7"])`.
|
|
89
|
+
|
|
90
|
+
### Stable URL (named tunnel)
|
|
91
|
+
|
|
92
|
+
Quick tunnels get a new random URL every run. For a fixed hostname, create a tunnel in the Cloudflare
|
|
93
|
+
dashboard (Zero Trust -> Networks -> Tunnels), point a public hostname at `http://localhost:9000`, then:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
alyxruntime stream --tunnel-token <TOKEN> --public-url https://gpu.example.com --port 9000 \
|
|
97
|
+
--allowed_ip 203.0.113.7
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## `stream` options
|
|
101
|
+
|
|
102
|
+
| flag | meaning |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `--allowed_ip IP [IP...]` | public IPs / CIDR ranges (v4 or v6) allowed to connect, as seen by Cloudflare, or the word `all` (same as `--allow-any`). **Required** in tunnel mode. |
|
|
105
|
+
| `--allow-any` | accept any IP (the token is then your only protection) |
|
|
106
|
+
| `--no-tunnel` | skip Cloudflare; listen directly on `--host:--port` (you handle NAT; loopback is always allowed in this mode) |
|
|
107
|
+
| `--install-cloudflared` | download `cloudflared` automatically if it's missing |
|
|
108
|
+
| `--tunnel-token` / `--public-url` / `--port` | use a named tunnel with a stable hostname (see above) |
|
|
109
|
+
| `--token` | fixed access token instead of a random one |
|
|
110
|
+
| `--root-dir` | directory the client can see (default: current dir) |
|
|
111
|
+
| `--tls-cert` / `--tls-key` | HTTPS for `--no-tunnel` mode (not needed with a tunnel) |
|
|
112
|
+
| `--public-host` | hostname/IP to print in the connect URL with `--no-tunnel` |
|
|
113
|
+
|
|
114
|
+
## Security - please read
|
|
115
|
+
|
|
116
|
+
A runtime is **remote code execution by design**: whoever connects runs arbitrary code as the host user.
|
|
117
|
+
|
|
118
|
+
* **Two independent gates:** the IP allowlist *and* a random token.
|
|
119
|
+
* **How the allowlist works with a tunnel:** `cloudflared` reaches the host from `127.0.0.1`, so the
|
|
120
|
+
socket address is useless for filtering. The gate instead reads the `CF-Connecting-IP` header, which
|
|
121
|
+
Cloudflare's edge sets itself, and checks **every request** against `--allowed_ip`. Requests with no
|
|
122
|
+
such header (anything not coming through the tunnel) are refused, loopback gets no special pass, and
|
|
123
|
+
each request is forced to `Connection: close` so one vetted connection can't be reused by someone else.
|
|
124
|
+
* The quick-tunnel URL is public: anyone who learns it can *reach* the gate (and be refused). Treat the
|
|
125
|
+
token like a password, and don't paste the connect line in public places.
|
|
126
|
+
* Traffic passes through Cloudflare's network (TLS to Cloudflare, then Cloudflare -> your host over the
|
|
127
|
+
tunnel); Cloudflare can technically see it. If that's unacceptable, use `--no-tunnel` with your own VPN.
|
|
128
|
+
* IP allowlisting trusts that Cloudflare reports the true client IP. It won't protect against someone
|
|
129
|
+
sharing your NAT/VPN exit IP, and mobile/CGNAT or rotating-IP connections will need a CIDR range.
|
|
130
|
+
* Local users on the host can reach the gate on its loopback port and are trusted like the host user.
|
|
131
|
+
* Don't run `stream` as root unless you mean it. The client's saved token lives in `~/.alyxruntime/session.json` (0600).
|
|
132
|
+
* Cloudflare's free quick tunnels are meant for development: no uptime guarantee, and you should read
|
|
133
|
+
Cloudflare's terms before using them for anything heavy or long-lived (use a named tunnel for that).
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# alyxruntime
|
|
2
|
+
|
|
3
|
+
Use any other computer's CPU/GPU as your Jupyter runtime - like Colab's "connect to local runtime",
|
|
4
|
+
but for *any* machine you control, from the command line or from Python.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
your laptop Cloudflare edge GPU box (host) - no open ports, outbound only
|
|
8
|
+
┌────────────┐ https ┌───────────────┐ tunnel ┌─────────────────────────────────────────┐
|
|
9
|
+
│ alyxruntime│ ────► │ *.trycloudflare│ ◄──────│ cloudflared ─► IP-allowlist gate │
|
|
10
|
+
│ connect │ +token│ .com │ │ └─► Jupyter (loopback) │
|
|
11
|
+
└────────────┘ └───────────────┘ │ └─► kernel (GPU) │
|
|
12
|
+
└─────────────────────────────────────────┘
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The host talks *out* to Cloudflare through `cloudflared`, so there is **no port forwarding, no public IP,
|
|
16
|
+
and HTTPS is included** - it works from behind home routers, NAT and most firewalls.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install "alyxruntime[host]" # on the machine that SHARES its GPU/CPU
|
|
22
|
+
pip install alyxruntime # on the machine that CONNECTS (lighter)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
On the **host** (the machine with the GPU):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
alyxruntime stream --allowed_ip 203.0.113.7 # your client's public IP
|
|
31
|
+
alyxruntime stream --allowed_ip 203.0.113.7 10.0.0.0/24 # several IPs / CIDR ranges
|
|
32
|
+
alyxruntime stream --allowed_ip all # any IP - the token is the only gate
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
It starts a Cloudflare quick tunnel (if `cloudflared` is missing it offers to download the official
|
|
36
|
+
binary, or pass `--install-cloudflared`) and prints a ready-to-paste command with a random access token:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
alyxruntime connect 'https://random-words.trycloudflare.com/?token=...'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Not sure what your public IP is? Run `alyxruntime connect` from the client anyway: the host replies
|
|
43
|
+
`access denied for IP x.x.x.x. Ask the host to add it with --allowed_ip x.x.x.x`.
|
|
44
|
+
|
|
45
|
+
On the **client**:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
alyxruntime connect https://my-host:8765/?token=XXXX # verifies, shows remote CPU/RAM/GPU, saves session
|
|
49
|
+
alyxruntime run train.py # run a script (also .ipynb, `-c "code"`, or stdin with `-`)
|
|
50
|
+
alyxruntime shell # interactive prompt; variables persist between commands
|
|
51
|
+
alyxruntime info # remote CPU / RAM / GPU
|
|
52
|
+
alyxruntime upload data.csv # copy files to the host's working dir
|
|
53
|
+
alyxruntime download model.pt
|
|
54
|
+
alyxruntime restart # fresh kernel
|
|
55
|
+
alyxruntime disconnect --kill-kernel
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Plots (`plt.show()`) are saved locally to `./alyx_outputs/`.
|
|
59
|
+
VS Code / JupyterLab also work: *Select Kernel -> Existing Jupyter Server* -> paste the same URL.
|
|
60
|
+
|
|
61
|
+
### From Python
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from alyxruntime import Runtime
|
|
65
|
+
|
|
66
|
+
rt = Runtime.connect("https://my-host:8765/?token=XXXX")
|
|
67
|
+
rt.run("import torch; print(torch.cuda.is_available())")
|
|
68
|
+
res = rt.run("2 + 2", echo=False); print(res.results) # ['4']
|
|
69
|
+
rt.upload("data.csv"); rt.download("out.pt")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Host side from Python: `from alyxruntime import serve; serve(allowed_ip=["203.0.113.7"])`.
|
|
73
|
+
|
|
74
|
+
### Stable URL (named tunnel)
|
|
75
|
+
|
|
76
|
+
Quick tunnels get a new random URL every run. For a fixed hostname, create a tunnel in the Cloudflare
|
|
77
|
+
dashboard (Zero Trust -> Networks -> Tunnels), point a public hostname at `http://localhost:9000`, then:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
alyxruntime stream --tunnel-token <TOKEN> --public-url https://gpu.example.com --port 9000 \
|
|
81
|
+
--allowed_ip 203.0.113.7
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## `stream` options
|
|
85
|
+
|
|
86
|
+
| flag | meaning |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `--allowed_ip IP [IP...]` | public IPs / CIDR ranges (v4 or v6) allowed to connect, as seen by Cloudflare, or the word `all` (same as `--allow-any`). **Required** in tunnel mode. |
|
|
89
|
+
| `--allow-any` | accept any IP (the token is then your only protection) |
|
|
90
|
+
| `--no-tunnel` | skip Cloudflare; listen directly on `--host:--port` (you handle NAT; loopback is always allowed in this mode) |
|
|
91
|
+
| `--install-cloudflared` | download `cloudflared` automatically if it's missing |
|
|
92
|
+
| `--tunnel-token` / `--public-url` / `--port` | use a named tunnel with a stable hostname (see above) |
|
|
93
|
+
| `--token` | fixed access token instead of a random one |
|
|
94
|
+
| `--root-dir` | directory the client can see (default: current dir) |
|
|
95
|
+
| `--tls-cert` / `--tls-key` | HTTPS for `--no-tunnel` mode (not needed with a tunnel) |
|
|
96
|
+
| `--public-host` | hostname/IP to print in the connect URL with `--no-tunnel` |
|
|
97
|
+
|
|
98
|
+
## Security - please read
|
|
99
|
+
|
|
100
|
+
A runtime is **remote code execution by design**: whoever connects runs arbitrary code as the host user.
|
|
101
|
+
|
|
102
|
+
* **Two independent gates:** the IP allowlist *and* a random token.
|
|
103
|
+
* **How the allowlist works with a tunnel:** `cloudflared` reaches the host from `127.0.0.1`, so the
|
|
104
|
+
socket address is useless for filtering. The gate instead reads the `CF-Connecting-IP` header, which
|
|
105
|
+
Cloudflare's edge sets itself, and checks **every request** against `--allowed_ip`. Requests with no
|
|
106
|
+
such header (anything not coming through the tunnel) are refused, loopback gets no special pass, and
|
|
107
|
+
each request is forced to `Connection: close` so one vetted connection can't be reused by someone else.
|
|
108
|
+
* The quick-tunnel URL is public: anyone who learns it can *reach* the gate (and be refused). Treat the
|
|
109
|
+
token like a password, and don't paste the connect line in public places.
|
|
110
|
+
* Traffic passes through Cloudflare's network (TLS to Cloudflare, then Cloudflare -> your host over the
|
|
111
|
+
tunnel); Cloudflare can technically see it. If that's unacceptable, use `--no-tunnel` with your own VPN.
|
|
112
|
+
* IP allowlisting trusts that Cloudflare reports the true client IP. It won't protect against someone
|
|
113
|
+
sharing your NAT/VPN exit IP, and mobile/CGNAT or rotating-IP connections will need a CIDR range.
|
|
114
|
+
* Local users on the host can reach the gate on its loopback port and are trusted like the host user.
|
|
115
|
+
* Don't run `stream` as root unless you mean it. The client's saved token lives in `~/.alyxruntime/session.json` (0600).
|
|
116
|
+
* Cloudflare's free quick tunnels are meant for development: no uptime guarantee, and you should read
|
|
117
|
+
Cloudflare's terms before using them for anything heavy or long-lived (use a named tunnel for that).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""alyxruntime - use another computer's CPU/GPU as your Jupyter runtime."""
|
|
2
|
+
from .client import AlyxError, AuthError, ExecResult, Runtime
|
|
3
|
+
from .server import serve
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["Runtime", "ExecResult", "AlyxError", "AuthError", "serve", "__version__"]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import getpass
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__, hardware
|
|
7
|
+
from .client import AlyxError, AuthError, Runtime
|
|
8
|
+
from .config import clear_state, load_state, save_state
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def die(msg, code=1):
|
|
12
|
+
print("alyxruntime: " + msg, file=sys.stderr)
|
|
13
|
+
sys.exit(code)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _current() -> Runtime:
|
|
17
|
+
st = load_state()
|
|
18
|
+
if not st:
|
|
19
|
+
die("not connected. Run: alyxruntime connect <url>")
|
|
20
|
+
return Runtime.from_state(st)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _persist(rt: Runtime):
|
|
24
|
+
save_state(rt.to_state())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------- commands
|
|
28
|
+
def cmd_connect(a):
|
|
29
|
+
rt = Runtime(a.url, token=a.token, insecure=a.insecure, kernel_name=a.kernel)
|
|
30
|
+
try:
|
|
31
|
+
wait = lambda: print("Waiting for the tunnel to come up ...", file=sys.stderr)
|
|
32
|
+
try:
|
|
33
|
+
rt.wait_until_reachable(on_wait=wait)
|
|
34
|
+
except AuthError:
|
|
35
|
+
if rt.token or not sys.stdin.isatty():
|
|
36
|
+
raise
|
|
37
|
+
rt.token = getpass.getpass("Access token: ")
|
|
38
|
+
rt.http.headers["Authorization"] = "token " + rt.token
|
|
39
|
+
rt.ping()
|
|
40
|
+
rt.ensure_kernel()
|
|
41
|
+
info = rt.info()
|
|
42
|
+
except AlyxError as e:
|
|
43
|
+
die(str(e))
|
|
44
|
+
_persist(rt) # save first, so a closed stdout can't lose the session
|
|
45
|
+
print("Connected to %s\n%s\n kernel: %s (%s)" % (rt.base, hardware.format_summary(info),
|
|
46
|
+
rt.kernel_id[:8], rt.kernel_name))
|
|
47
|
+
print("\nNext: alyxruntime run train.py | alyxruntime shell | alyxruntime info")
|
|
48
|
+
print("VS Code / JupyterLab: choose 'Existing Jupyter Server' and paste the same URL.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _cells(path):
|
|
52
|
+
if path.endswith(".ipynb"):
|
|
53
|
+
nb = json.load(open(path))
|
|
54
|
+
for c in nb.get("cells", []):
|
|
55
|
+
if c.get("cell_type") == "code":
|
|
56
|
+
yield "".join(c["source"])
|
|
57
|
+
else:
|
|
58
|
+
yield open(path).read()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_run(a):
|
|
62
|
+
rt = _current()
|
|
63
|
+
if a.code is not None:
|
|
64
|
+
sources = [a.code]
|
|
65
|
+
elif a.file == "-" or (a.file is None and not sys.stdin.isatty()):
|
|
66
|
+
sources = [sys.stdin.read()]
|
|
67
|
+
elif a.file:
|
|
68
|
+
sources = list(_cells(a.file))
|
|
69
|
+
else:
|
|
70
|
+
die("give a file, -c CODE, or pipe code on stdin")
|
|
71
|
+
try:
|
|
72
|
+
for src in sources:
|
|
73
|
+
res = rt.run(src, timeout=a.timeout)
|
|
74
|
+
for p in res.save_images():
|
|
75
|
+
print("[saved plot -> %s]" % p, file=sys.stderr)
|
|
76
|
+
if not res.ok:
|
|
77
|
+
_persist(rt)
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
except KeyboardInterrupt:
|
|
80
|
+
die("interrupted (sent interrupt to remote kernel)", 130)
|
|
81
|
+
except AlyxError as e:
|
|
82
|
+
die(str(e))
|
|
83
|
+
_persist(rt)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_shell(a):
|
|
87
|
+
import codeop
|
|
88
|
+
rt = _current()
|
|
89
|
+
print("Remote shell on %s (Ctrl+D to leave)" % rt.base)
|
|
90
|
+
buf = []
|
|
91
|
+
while True:
|
|
92
|
+
try:
|
|
93
|
+
line = input("... " if buf else ">>> ")
|
|
94
|
+
except EOFError:
|
|
95
|
+
print()
|
|
96
|
+
break
|
|
97
|
+
except KeyboardInterrupt:
|
|
98
|
+
print("\nKeyboardInterrupt")
|
|
99
|
+
buf = []
|
|
100
|
+
continue
|
|
101
|
+
buf.append(line)
|
|
102
|
+
src = "\n".join(buf)
|
|
103
|
+
try:
|
|
104
|
+
if line.strip() and codeop.compile_command(src) is None:
|
|
105
|
+
continue # statement incomplete, keep reading
|
|
106
|
+
except (SyntaxError, ValueError, OverflowError):
|
|
107
|
+
pass # %magics, !shell etc: let the remote IPython deal with it
|
|
108
|
+
buf = []
|
|
109
|
+
if not src.strip():
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
for p in rt.run(src).save_images():
|
|
113
|
+
print("[saved plot -> %s]" % p)
|
|
114
|
+
except KeyboardInterrupt:
|
|
115
|
+
print("\nKeyboardInterrupt")
|
|
116
|
+
except AlyxError as e:
|
|
117
|
+
print("error: %s" % e, file=sys.stderr)
|
|
118
|
+
_persist(rt)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_info(a):
|
|
122
|
+
rt = _current()
|
|
123
|
+
try:
|
|
124
|
+
print(hardware.format_summary(rt.info()))
|
|
125
|
+
except AlyxError as e:
|
|
126
|
+
die(str(e))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_status(a):
|
|
130
|
+
st = load_state()
|
|
131
|
+
if not st:
|
|
132
|
+
print("Not connected.")
|
|
133
|
+
return
|
|
134
|
+
rt = Runtime.from_state(st)
|
|
135
|
+
try:
|
|
136
|
+
rt.ping()
|
|
137
|
+
print("Connected to %s\n kernel: %s" % (rt.base, (rt.kernel_id or "none")[:8]))
|
|
138
|
+
except AlyxError as e:
|
|
139
|
+
print("Saved session for %s but it is not reachable: %s" % (rt.base, e))
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_restart(a):
|
|
144
|
+
rt = _current()
|
|
145
|
+
try:
|
|
146
|
+
rt.restart()
|
|
147
|
+
except AlyxError as e:
|
|
148
|
+
die(str(e))
|
|
149
|
+
print("Kernel restarted (variables cleared).")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_upload(a):
|
|
153
|
+
rt = _current()
|
|
154
|
+
try:
|
|
155
|
+
print("uploaded -> " + rt.upload(a.local, a.remote))
|
|
156
|
+
except (AlyxError, OSError) as e:
|
|
157
|
+
die(str(e))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_download(a):
|
|
161
|
+
rt = _current()
|
|
162
|
+
try:
|
|
163
|
+
print("saved -> %s" % rt.download(a.remote, a.local))
|
|
164
|
+
except (AlyxError, OSError) as e:
|
|
165
|
+
die(str(e))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_disconnect(a):
|
|
169
|
+
st = load_state()
|
|
170
|
+
if st and a.kill_kernel:
|
|
171
|
+
rt = Runtime.from_state(st)
|
|
172
|
+
rt.shutdown()
|
|
173
|
+
clear_state()
|
|
174
|
+
print("Disconnected.")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_stream(a):
|
|
178
|
+
from .server import serve
|
|
179
|
+
try:
|
|
180
|
+
serve(allowed_ip=a.allowed_ip, host=a.host, port=a.port, token=a.token, root_dir=a.root_dir,
|
|
181
|
+
tls_cert=a.tls_cert, tls_key=a.tls_key, allow_any=a.allow_any, public_host=a.public_host,
|
|
182
|
+
tunnel=a.tunnel, tunnel_token=a.tunnel_token, public_url=a.public_url,
|
|
183
|
+
auto_install=a.install_cloudflared)
|
|
184
|
+
except ValueError as e:
|
|
185
|
+
die(str(e))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------ parser
|
|
189
|
+
def build_parser():
|
|
190
|
+
p = argparse.ArgumentParser(prog="alyxruntime",
|
|
191
|
+
description="Use another computer's CPU/GPU as your Jupyter runtime.")
|
|
192
|
+
p.add_argument("--version", action="version", version="alyxruntime " + __version__)
|
|
193
|
+
sub = p.add_subparsers(dest="cmd", metavar="<command>")
|
|
194
|
+
sub.required = True
|
|
195
|
+
|
|
196
|
+
c = sub.add_parser("connect", help="connect to a runtime shared with `stream`")
|
|
197
|
+
c.add_argument("url", help="e.g. https://host:8765/?token=XXXX")
|
|
198
|
+
c.add_argument("--token", help="access token (or put it in the URL / $ALYX_TOKEN)")
|
|
199
|
+
c.add_argument("--insecure", action="store_true", help="skip TLS verification (self-signed certs)")
|
|
200
|
+
c.add_argument("--kernel", default="python3", help="kernelspec name (default python3)")
|
|
201
|
+
c.set_defaults(fn=cmd_connect)
|
|
202
|
+
|
|
203
|
+
s = sub.add_parser("stream", help="share THIS machine's CPU/GPU as a runtime")
|
|
204
|
+
s.add_argument("--allowed_ip", "--allowed-ip", nargs="+", action="extend", metavar="IP",
|
|
205
|
+
help="client IP(s) or CIDR range(s) allowed to connect, e.g. 203.0.113.7 10.0.0.0/24; "
|
|
206
|
+
"or the word 'all' to allow any IP (token is then the only protection)")
|
|
207
|
+
s.add_argument("--allow-any", action="store_true", help="accept any IP (token is the only protection)")
|
|
208
|
+
s.add_argument("--no-tunnel", dest="tunnel", action="store_false",
|
|
209
|
+
help="don't use Cloudflare; listen directly on --host:--port (you handle NAT/TLS)")
|
|
210
|
+
s.add_argument("--tunnel-token", help="run a NAMED Cloudflare tunnel (stable hostname) with this token; "
|
|
211
|
+
"needs --public-url and --port")
|
|
212
|
+
s.add_argument("--public-url", help="the https URL of your named tunnel, e.g. https://gpu.example.com")
|
|
213
|
+
s.add_argument("--install-cloudflared", action="store_true",
|
|
214
|
+
help="download cloudflared automatically if it's missing (no prompt)")
|
|
215
|
+
s.add_argument("--port", type=int, help="local port (default: random with tunnel, 8765 with --no-tunnel)")
|
|
216
|
+
s.add_argument("--host", default="0.0.0.0", help="interface to bind with --no-tunnel (default all)")
|
|
217
|
+
s.add_argument("--token", help="use a fixed token instead of a random one")
|
|
218
|
+
s.add_argument("--root-dir", help="working directory exposed to the client (default: cwd)")
|
|
219
|
+
s.add_argument("--tls-cert", help="PEM certificate to serve HTTPS")
|
|
220
|
+
s.add_argument("--tls-key", help="PEM private key for --tls-cert")
|
|
221
|
+
s.add_argument("--public-host", help="hostname/IP to print in the connect URL (with --no-tunnel)")
|
|
222
|
+
s.set_defaults(fn=cmd_stream)
|
|
223
|
+
|
|
224
|
+
r = sub.add_parser("run", help="run a .py/.ipynb file or code on the remote runtime")
|
|
225
|
+
r.add_argument("file", nargs="?", help="script, notebook, or - for stdin")
|
|
226
|
+
r.add_argument("-c", "--code", help="code string to run")
|
|
227
|
+
r.add_argument("--timeout", type=float, help="seconds before interrupting")
|
|
228
|
+
r.set_defaults(fn=cmd_run)
|
|
229
|
+
|
|
230
|
+
for name, fn, h in (("shell", cmd_shell, "interactive Python prompt on the remote runtime"),
|
|
231
|
+
("info", cmd_info, "show remote CPU/RAM/GPU"),
|
|
232
|
+
("status", cmd_status, "show current connection"),
|
|
233
|
+
("restart", cmd_restart, "restart the remote kernel")):
|
|
234
|
+
sub.add_parser(name, help=h).set_defaults(fn=fn)
|
|
235
|
+
|
|
236
|
+
u = sub.add_parser("upload", help="copy a local file to the host")
|
|
237
|
+
u.add_argument("local"); u.add_argument("remote", nargs="?")
|
|
238
|
+
u.set_defaults(fn=cmd_upload)
|
|
239
|
+
d = sub.add_parser("download", help="copy a file from the host")
|
|
240
|
+
d.add_argument("remote"); d.add_argument("local", nargs="?")
|
|
241
|
+
d.set_defaults(fn=cmd_download)
|
|
242
|
+
|
|
243
|
+
x = sub.add_parser("disconnect", help="forget the saved connection")
|
|
244
|
+
x.add_argument("--kill-kernel", action="store_true", help="also shut down the remote kernel")
|
|
245
|
+
x.set_defaults(fn=cmd_disconnect)
|
|
246
|
+
return p
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main(argv=None):
|
|
250
|
+
a = build_parser().parse_args(argv)
|
|
251
|
+
a.fn(a)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
if __name__ == "__main__":
|
|
255
|
+
main()
|