runtime-sdk 0.4.9__tar.gz → 0.4.11__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.9 → runtime_sdk-0.4.11}/PKG-INFO +30 -12
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/README.md +29 -11
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/pyproject.toml +1 -1
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/scripts/runtime_sdk/cli.py +680 -34
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/scripts/runtime_sdk/client.py +44 -10
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/scripts/runtime_sdk.egg-info/PKG-INFO +30 -12
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/scripts/runtime_sdk/__init__.py +0 -0
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/scripts/runtime_sdk/config.py +0 -0
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/scripts/runtime_sdk.egg-info/SOURCES.txt +0 -0
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/scripts/runtime_sdk.egg-info/dependency_links.txt +0 -0
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/scripts/runtime_sdk.egg-info/entry_points.txt +0 -0
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/scripts/runtime_sdk.egg-info/requires.txt +0 -0
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/scripts/runtime_sdk.egg-info/top_level.txt +0 -0
- {runtime_sdk-0.4.9 → runtime_sdk-0.4.11}/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.11
|
|
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
|
|
@@ -54,16 +54,27 @@ runtime logout
|
|
|
54
54
|
runtime login --api-key rt_live_...
|
|
55
55
|
|
|
56
56
|
# Computers
|
|
57
|
-
runtime create #
|
|
57
|
+
runtime create # creates a computer with the starter app already published
|
|
58
58
|
runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
59
59
|
runtime enter <name-or-id> # accepts slug/name like test, or the computer id
|
|
60
60
|
runtime list
|
|
61
61
|
runtime info <id>
|
|
62
62
|
runtime start <id>
|
|
63
|
-
runtime run <id> "echo hello"
|
|
63
|
+
runtime run <id> "echo hello" # one-shot foreground command only
|
|
64
64
|
runtime run <id> "apt install -y nodejs" --uid 0
|
|
65
|
-
runtime
|
|
65
|
+
runtime startup show <id>
|
|
66
|
+
runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
67
|
+
runtime startup clear <id> # low-level durable service config
|
|
68
|
+
runtime service show <id> # user-facing alias for the durable published app
|
|
69
|
+
runtime service clear <id>
|
|
70
|
+
runtime publish <id> 3000 # promote the running app on port 3000 to the public durable app
|
|
66
71
|
runtime delete <id>
|
|
72
|
+
|
|
73
|
+
# Inside a running computer, the helper installed by Runtime can manage the
|
|
74
|
+
# durable app without leaving the sandbox:
|
|
75
|
+
# runtime-env publish 3000
|
|
76
|
+
# runtime-env service show
|
|
77
|
+
# runtime-env service clear
|
|
67
78
|
```
|
|
68
79
|
|
|
69
80
|
## Python
|
|
@@ -73,12 +84,12 @@ from runtime_sdk import RuntimeClient
|
|
|
73
84
|
|
|
74
85
|
client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_...")
|
|
75
86
|
|
|
76
|
-
# Create a computer
|
|
87
|
+
# Create a computer. New computers start with the starter app already published.
|
|
77
88
|
computer = client.create_computer()
|
|
78
89
|
print(computer["public_url"]) # https://goldbird.runruntime.dev
|
|
79
90
|
|
|
80
|
-
# Or create one with
|
|
81
|
-
# That
|
|
91
|
+
# Or create one with an explicit durable app command.
|
|
92
|
+
# That saved service is replayed after cold restore / start.
|
|
82
93
|
app = client.create_computer(
|
|
83
94
|
slug="myapp",
|
|
84
95
|
command="python3 app.py",
|
|
@@ -93,7 +104,8 @@ print(result["stdout"])
|
|
|
93
104
|
# Wake a cold computer explicitly
|
|
94
105
|
client.start_computer(app["id"])
|
|
95
106
|
|
|
96
|
-
#
|
|
107
|
+
# Promote the running app on a local port to the durable public app.
|
|
108
|
+
# Runtime inspects the listening process and saves its command + cwd when possible.
|
|
97
109
|
client.publish_port(computer["id"], 3000)
|
|
98
110
|
|
|
99
111
|
# List, info, delete
|
|
@@ -120,10 +132,16 @@ make smoke
|
|
|
120
132
|
Use `make deploy` instead of `make sync` when migrations, env
|
|
121
133
|
files, Caddy, or systemd units changed.
|
|
122
134
|
|
|
123
|
-
Cold restore and
|
|
124
|
-
computers
|
|
125
|
-
|
|
126
|
-
|
|
135
|
+
Cold restore and explicit `runtime start` replay the saved published app command.
|
|
136
|
+
New computers seed that durable app from the starter workspace. Later,
|
|
137
|
+
`runtime publish <id> <port>` can promote a running listener into the saved
|
|
138
|
+
public app definition by inspecting the live process. Inside a running computer,
|
|
139
|
+
`runtime-env publish <port>` does the same thing using a computer-scoped token
|
|
140
|
+
installed by Runtime. `runtime service show|clear` and `runtime-env service
|
|
141
|
+
show|clear` expose that same durable app state directly. The low-level
|
|
142
|
+
`runtime startup ...` commands still map to the same durable state. A one-off
|
|
143
|
+
`runtime run` stays one-shot: the filesystem is restored after going cold, but
|
|
144
|
+
that ad-hoc process is not.
|
|
127
145
|
|
|
128
146
|
Run the SDK unit tests through the backend project environment:
|
|
129
147
|
|
|
@@ -35,16 +35,27 @@ runtime logout
|
|
|
35
35
|
runtime login --api-key rt_live_...
|
|
36
36
|
|
|
37
37
|
# Computers
|
|
38
|
-
runtime create #
|
|
38
|
+
runtime create # creates a computer with the starter app already published
|
|
39
39
|
runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
40
40
|
runtime enter <name-or-id> # accepts slug/name like test, or the computer id
|
|
41
41
|
runtime list
|
|
42
42
|
runtime info <id>
|
|
43
43
|
runtime start <id>
|
|
44
|
-
runtime run <id> "echo hello"
|
|
44
|
+
runtime run <id> "echo hello" # one-shot foreground command only
|
|
45
45
|
runtime run <id> "apt install -y nodejs" --uid 0
|
|
46
|
-
runtime
|
|
46
|
+
runtime startup show <id>
|
|
47
|
+
runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
48
|
+
runtime startup clear <id> # low-level durable service config
|
|
49
|
+
runtime service show <id> # user-facing alias for the durable published app
|
|
50
|
+
runtime service clear <id>
|
|
51
|
+
runtime publish <id> 3000 # promote the running app on port 3000 to the public durable app
|
|
47
52
|
runtime delete <id>
|
|
53
|
+
|
|
54
|
+
# Inside a running computer, the helper installed by Runtime can manage the
|
|
55
|
+
# durable app without leaving the sandbox:
|
|
56
|
+
# runtime-env publish 3000
|
|
57
|
+
# runtime-env service show
|
|
58
|
+
# runtime-env service clear
|
|
48
59
|
```
|
|
49
60
|
|
|
50
61
|
## Python
|
|
@@ -54,12 +65,12 @@ from runtime_sdk import RuntimeClient
|
|
|
54
65
|
|
|
55
66
|
client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_...")
|
|
56
67
|
|
|
57
|
-
# Create a computer
|
|
68
|
+
# Create a computer. New computers start with the starter app already published.
|
|
58
69
|
computer = client.create_computer()
|
|
59
70
|
print(computer["public_url"]) # https://goldbird.runruntime.dev
|
|
60
71
|
|
|
61
|
-
# Or create one with
|
|
62
|
-
# That
|
|
72
|
+
# Or create one with an explicit durable app command.
|
|
73
|
+
# That saved service is replayed after cold restore / start.
|
|
63
74
|
app = client.create_computer(
|
|
64
75
|
slug="myapp",
|
|
65
76
|
command="python3 app.py",
|
|
@@ -74,7 +85,8 @@ print(result["stdout"])
|
|
|
74
85
|
# Wake a cold computer explicitly
|
|
75
86
|
client.start_computer(app["id"])
|
|
76
87
|
|
|
77
|
-
#
|
|
88
|
+
# Promote the running app on a local port to the durable public app.
|
|
89
|
+
# Runtime inspects the listening process and saves its command + cwd when possible.
|
|
78
90
|
client.publish_port(computer["id"], 3000)
|
|
79
91
|
|
|
80
92
|
# List, info, delete
|
|
@@ -101,10 +113,16 @@ make smoke
|
|
|
101
113
|
Use `make deploy` instead of `make sync` when migrations, env
|
|
102
114
|
files, Caddy, or systemd units changed.
|
|
103
115
|
|
|
104
|
-
Cold restore and
|
|
105
|
-
computers
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
Cold restore and explicit `runtime start` replay the saved published app command.
|
|
117
|
+
New computers seed that durable app from the starter workspace. Later,
|
|
118
|
+
`runtime publish <id> <port>` can promote a running listener into the saved
|
|
119
|
+
public app definition by inspecting the live process. Inside a running computer,
|
|
120
|
+
`runtime-env publish <port>` does the same thing using a computer-scoped token
|
|
121
|
+
installed by Runtime. `runtime service show|clear` and `runtime-env service
|
|
122
|
+
show|clear` expose that same durable app state directly. The low-level
|
|
123
|
+
`runtime startup ...` commands still map to the same durable state. A one-off
|
|
124
|
+
`runtime run` stays one-shot: the filesystem is restored after going cold, but
|
|
125
|
+
that ad-hoc process is not.
|
|
108
126
|
|
|
109
127
|
Run the SDK unit tests through the backend project environment:
|
|
110
128
|
|
|
@@ -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
|
|
|
@@ -159,16 +165,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
159
165
|
subparsers.add_parser("whoami")
|
|
160
166
|
subparsers.add_parser("logout")
|
|
161
167
|
|
|
162
|
-
create_cmd = subparsers.add_parser("create", help="Create a new computer")
|
|
168
|
+
create_cmd = subparsers.add_parser("create", help="Create a new computer with the starter app published by default")
|
|
163
169
|
create_cmd.add_argument(
|
|
164
170
|
"name",
|
|
165
171
|
nargs="?",
|
|
166
172
|
default=None,
|
|
167
173
|
help="Subdomain name (e.g. redsox → redsox.runruntime.dev). Random if skipped.",
|
|
168
174
|
)
|
|
169
|
-
create_cmd.add_argument("--command", dest="startup_command", help="
|
|
170
|
-
create_cmd.add_argument("--cwd", help="Working directory for the startup command")
|
|
171
|
-
create_cmd.add_argument("--port", type=int, help="App port to publish
|
|
175
|
+
create_cmd.add_argument("--command", dest="startup_command", help="Durable startup command to save on create")
|
|
176
|
+
create_cmd.add_argument("--cwd", help="Working directory for the durable startup command")
|
|
177
|
+
create_cmd.add_argument("--port", type=int, help="App port to publish for durable startup")
|
|
172
178
|
subparsers.add_parser("list", aliases=["ls"], help="List computers")
|
|
173
179
|
|
|
174
180
|
enter_cmd = subparsers.add_parser("enter", help="Enter a computer's console")
|
|
@@ -177,13 +183,16 @@ 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
|
|
|
183
192
|
delete_cmd = subparsers.add_parser("delete", aliases=["rm"], help="Delete a computer")
|
|
184
193
|
delete_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
185
194
|
|
|
186
|
-
run_cmd = subparsers.add_parser("run", help="Run a command in a computer")
|
|
195
|
+
run_cmd = subparsers.add_parser("run", help="Run a one-shot command in a computer")
|
|
187
196
|
run_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
188
197
|
run_cmd.add_argument(
|
|
189
198
|
"run_command",
|
|
@@ -198,11 +207,47 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
198
207
|
run_cmd.add_argument("--shell", help="Shell to use")
|
|
199
208
|
|
|
200
209
|
publish_cmd = subparsers.add_parser(
|
|
201
|
-
"publish", help="
|
|
210
|
+
"publish", help="Promote the running app on a local port into the durable public app"
|
|
202
211
|
)
|
|
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
|
+
startup_cmd = subparsers.add_parser("startup", help="Manage durable startup config")
|
|
216
|
+
startup_subparsers = startup_cmd.add_subparsers(dest="startup_action", required=True)
|
|
217
|
+
startup_show = startup_subparsers.add_parser("show", help="Show durable startup config")
|
|
218
|
+
startup_show.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
219
|
+
startup_set = startup_subparsers.add_parser("set", help="Set durable startup config")
|
|
220
|
+
startup_set.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
221
|
+
startup_set.add_argument("--command", dest="startup_value_command", required=True, help="Durable startup command")
|
|
222
|
+
startup_set.add_argument("--cwd", help="Working directory for the durable startup command")
|
|
223
|
+
startup_set.add_argument("--port", required=True, type=int, help="App port to publish for durable startup")
|
|
224
|
+
startup_clear = startup_subparsers.add_parser("clear", help="Clear durable startup config")
|
|
225
|
+
startup_clear.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
226
|
+
|
|
227
|
+
service_cmd = subparsers.add_parser("service", help="Manage the durable published app service")
|
|
228
|
+
service_subparsers = service_cmd.add_subparsers(dest="service_action", required=True)
|
|
229
|
+
service_show = service_subparsers.add_parser("show", help="Show the durable published app service")
|
|
230
|
+
service_show.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
231
|
+
service_clear = service_subparsers.add_parser("clear", help="Clear the durable published app service")
|
|
232
|
+
service_clear.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
|
|
233
|
+
|
|
234
|
+
proxy_cmd = subparsers.add_parser("proxy", help="Manage background local port proxies")
|
|
235
|
+
proxy_subparsers = proxy_cmd.add_subparsers(dest="proxy_command", required=True)
|
|
236
|
+
proxy_start = proxy_subparsers.add_parser("start", help="Start one or more background local port proxies")
|
|
237
|
+
proxy_start.add_argument("id", help="Computer ID (hostname)")
|
|
238
|
+
proxy_start.add_argument("ports", nargs="+", help="Port mappings like 5432 or 15432:5432")
|
|
239
|
+
proxy_subparsers.add_parser("ls", help="List background local port proxies")
|
|
240
|
+
proxy_stop = proxy_subparsers.add_parser("stop", help="Stop one or more background local port proxies")
|
|
241
|
+
proxy_stop.add_argument("local_ports", nargs="*", type=int, help="Local ports to stop")
|
|
242
|
+
proxy_stop.add_argument("--all", action="store_true", help="Stop all running proxies")
|
|
243
|
+
|
|
244
|
+
proxy_agent = subparsers.add_parser("_proxy_agent", help=argparse.SUPPRESS)
|
|
245
|
+
proxy_agent.add_argument("--base-url", required=True)
|
|
246
|
+
proxy_agent.add_argument("--computer-id", required=True)
|
|
247
|
+
proxy_agent.add_argument("--display-name", required=True)
|
|
248
|
+
proxy_agent.add_argument("--local-port", type=int, required=True)
|
|
249
|
+
proxy_agent.add_argument("--remote-port", type=int, required=True)
|
|
250
|
+
|
|
206
251
|
return parser
|
|
207
252
|
|
|
208
253
|
|
|
@@ -247,6 +292,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
247
292
|
return handle_list(config)
|
|
248
293
|
if args.command == "info":
|
|
249
294
|
return handle_info(config, args.id)
|
|
295
|
+
if args.command == "url":
|
|
296
|
+
return handle_url(config, args.id)
|
|
250
297
|
if args.command == "start":
|
|
251
298
|
return handle_start(config, args.id)
|
|
252
299
|
if args.command == "enter":
|
|
@@ -259,6 +306,37 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
259
306
|
)
|
|
260
307
|
if args.command == "publish":
|
|
261
308
|
return handle_publish(config, args.id, args.port)
|
|
309
|
+
if args.command == "startup":
|
|
310
|
+
if args.startup_action == "show":
|
|
311
|
+
return handle_startup_show(config, args.id)
|
|
312
|
+
if args.startup_action == "set":
|
|
313
|
+
return handle_startup_set(config, args.id, args.startup_value_command, args.cwd, args.port)
|
|
314
|
+
if args.startup_action == "clear":
|
|
315
|
+
return handle_startup_clear(config, args.id)
|
|
316
|
+
return report_error("unknown startup command")
|
|
317
|
+
if args.command == "service":
|
|
318
|
+
if args.service_action == "show":
|
|
319
|
+
return handle_service_show(config, args.id)
|
|
320
|
+
if args.service_action == "clear":
|
|
321
|
+
return handle_service_clear(config, args.id)
|
|
322
|
+
return report_error("unknown service command")
|
|
323
|
+
if args.command == "proxy":
|
|
324
|
+
if args.proxy_command == "start":
|
|
325
|
+
return handle_proxy_start(config, args.id, args.ports)
|
|
326
|
+
if args.proxy_command == "ls":
|
|
327
|
+
return handle_proxy_ls(config)
|
|
328
|
+
if args.proxy_command == "stop":
|
|
329
|
+
return handle_proxy_stop(config, args.local_ports, stop_all=args.all)
|
|
330
|
+
return report_error("unknown proxy command")
|
|
331
|
+
if args.command == "_proxy_agent":
|
|
332
|
+
return run_proxy_agent(
|
|
333
|
+
args.base_url,
|
|
334
|
+
os.environ.get("RUNTIME_PROXY_API_KEY", ""),
|
|
335
|
+
args.computer_id,
|
|
336
|
+
args.display_name,
|
|
337
|
+
args.local_port,
|
|
338
|
+
args.remote_port,
|
|
339
|
+
)
|
|
262
340
|
except RuntimeAPIError as exc:
|
|
263
341
|
return report_error(str(exc), status_code=exc.status_code)
|
|
264
342
|
except RuntimeConfigError as exc:
|
|
@@ -311,6 +389,201 @@ def _require_api_key(config: RuntimeConfig) -> int | None:
|
|
|
311
389
|
return report_error("missing api key; run login or verify first")
|
|
312
390
|
|
|
313
391
|
|
|
392
|
+
def _runtime_state_dir() -> Path:
|
|
393
|
+
root = os.environ.get("XDG_STATE_HOME")
|
|
394
|
+
if root:
|
|
395
|
+
return Path(root) / "runtime"
|
|
396
|
+
return Path.home() / ".local" / "state" / "runtime"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _proxy_dir() -> Path:
|
|
400
|
+
return _runtime_state_dir() / "proxies"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _proxy_state_path(local_port: int) -> Path:
|
|
404
|
+
return _proxy_dir() / f"proxy-{local_port}.json"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _proxy_log_path(local_port: int) -> Path:
|
|
408
|
+
return _proxy_dir() / f"proxy-{local_port}.log"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _pid_running(pid: int) -> bool:
|
|
412
|
+
if pid <= 0:
|
|
413
|
+
return False
|
|
414
|
+
try:
|
|
415
|
+
os.kill(pid, 0)
|
|
416
|
+
return True
|
|
417
|
+
except OSError:
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _pid_command(pid: int) -> str:
|
|
422
|
+
if pid <= 0:
|
|
423
|
+
return ""
|
|
424
|
+
try:
|
|
425
|
+
return subprocess.check_output(
|
|
426
|
+
["ps", "-o", "command=", "-p", str(pid)],
|
|
427
|
+
text=True,
|
|
428
|
+
stderr=subprocess.DEVNULL,
|
|
429
|
+
).strip()
|
|
430
|
+
except (OSError, subprocess.SubprocessError):
|
|
431
|
+
return ""
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _proxy_pid_matches(pid: int, local_port: int) -> bool:
|
|
435
|
+
if not _pid_running(pid):
|
|
436
|
+
return False
|
|
437
|
+
command = _pid_command(pid)
|
|
438
|
+
if not command:
|
|
439
|
+
return False
|
|
440
|
+
with contextlib.suppress(ValueError):
|
|
441
|
+
argv = shlex.split(command)
|
|
442
|
+
if "_proxy_agent" not in argv:
|
|
443
|
+
return False
|
|
444
|
+
if "--local-port" not in argv:
|
|
445
|
+
return False
|
|
446
|
+
index = argv.index("--local-port")
|
|
447
|
+
if index + 1 >= len(argv) or argv[index + 1] != str(local_port):
|
|
448
|
+
return False
|
|
449
|
+
return "runtime_sdk.cli" in argv or any(part.endswith("runtime_sdk.cli") for part in argv)
|
|
450
|
+
return False
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _load_proxy_records(*, clean_stale: bool = False) -> list[dict[str, Any]]:
|
|
454
|
+
records: list[dict[str, Any]] = []
|
|
455
|
+
directory = _proxy_dir()
|
|
456
|
+
if not directory.exists():
|
|
457
|
+
return records
|
|
458
|
+
for path in sorted(directory.glob("proxy-*.json")):
|
|
459
|
+
try:
|
|
460
|
+
record = json.loads(path.read_text())
|
|
461
|
+
except (OSError, ValueError):
|
|
462
|
+
continue
|
|
463
|
+
if not isinstance(record, dict):
|
|
464
|
+
continue
|
|
465
|
+
record["state_path"] = str(path)
|
|
466
|
+
pid = int(record.get("pid") or 0)
|
|
467
|
+
local_port = int(record.get("local_port") or 0)
|
|
468
|
+
record["status"] = "running" if _proxy_pid_matches(pid, local_port) else "stopped"
|
|
469
|
+
if clean_stale and record["status"] != "running":
|
|
470
|
+
with contextlib.suppress(OSError):
|
|
471
|
+
path.unlink()
|
|
472
|
+
continue
|
|
473
|
+
records.append(record)
|
|
474
|
+
return records
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _parse_proxy_port_spec(spec: str) -> tuple[int, int]:
|
|
478
|
+
text = str(spec or "").strip()
|
|
479
|
+
if not text:
|
|
480
|
+
raise RuntimeAPIError("port mapping is required")
|
|
481
|
+
if ":" in text:
|
|
482
|
+
local_text, remote_text = text.split(":", 1)
|
|
483
|
+
else:
|
|
484
|
+
local_text, remote_text = text, text
|
|
485
|
+
try:
|
|
486
|
+
local_port = int(local_text)
|
|
487
|
+
remote_port = int(remote_text)
|
|
488
|
+
except ValueError as exc:
|
|
489
|
+
raise RuntimeAPIError(f"invalid port mapping: {text}") from exc
|
|
490
|
+
for port in (local_port, remote_port):
|
|
491
|
+
if port <= 0 or port > 65535:
|
|
492
|
+
raise RuntimeAPIError("port must be between 1 and 65535")
|
|
493
|
+
return local_port, remote_port
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _resolve_ws_url(base_url: str, ws_url: Any) -> str:
|
|
497
|
+
text = str(ws_url or "").strip()
|
|
498
|
+
if not text:
|
|
499
|
+
return ""
|
|
500
|
+
if text.startswith("ws://") or text.startswith("wss://"):
|
|
501
|
+
return text
|
|
502
|
+
|
|
503
|
+
parsed = urlparse(base_url)
|
|
504
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
505
|
+
root = f"{scheme}://{parsed.netloc}"
|
|
506
|
+
if text.startswith("/"):
|
|
507
|
+
return root + text
|
|
508
|
+
return urljoin(root + "/", text)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _spawn_proxy_agent(base_url: str, api_key: str, computer_id: str, display_name: str, local_port: int, remote_port: int) -> int:
|
|
512
|
+
proxy_dir = _proxy_dir()
|
|
513
|
+
proxy_dir.mkdir(parents=True, exist_ok=True)
|
|
514
|
+
log_path = _proxy_log_path(local_port)
|
|
515
|
+
log_file = log_path.open("a", encoding="utf-8")
|
|
516
|
+
child_env = os.environ.copy()
|
|
517
|
+
child_env["RUNTIME_PROXY_API_KEY"] = api_key
|
|
518
|
+
try:
|
|
519
|
+
proc = subprocess.Popen(
|
|
520
|
+
[
|
|
521
|
+
sys.executable,
|
|
522
|
+
"-m",
|
|
523
|
+
"runtime_sdk.cli",
|
|
524
|
+
"_proxy_agent",
|
|
525
|
+
"--base-url",
|
|
526
|
+
base_url,
|
|
527
|
+
"--computer-id",
|
|
528
|
+
computer_id,
|
|
529
|
+
"--display-name",
|
|
530
|
+
display_name,
|
|
531
|
+
"--local-port",
|
|
532
|
+
str(local_port),
|
|
533
|
+
"--remote-port",
|
|
534
|
+
str(remote_port),
|
|
535
|
+
],
|
|
536
|
+
stdin=subprocess.DEVNULL,
|
|
537
|
+
stdout=log_file,
|
|
538
|
+
stderr=log_file,
|
|
539
|
+
start_new_session=True,
|
|
540
|
+
close_fds=True,
|
|
541
|
+
env=child_env,
|
|
542
|
+
)
|
|
543
|
+
finally:
|
|
544
|
+
log_file.close()
|
|
545
|
+
|
|
546
|
+
deadline = time.time() + 4.0
|
|
547
|
+
state_path = _proxy_state_path(local_port)
|
|
548
|
+
while time.time() < deadline:
|
|
549
|
+
if state_path.exists():
|
|
550
|
+
return proc.pid
|
|
551
|
+
code = proc.poll()
|
|
552
|
+
if code is not None:
|
|
553
|
+
break
|
|
554
|
+
time.sleep(0.1)
|
|
555
|
+
|
|
556
|
+
message = f"proxy failed to start on localhost:{local_port}"
|
|
557
|
+
with contextlib.suppress(OSError):
|
|
558
|
+
tail = log_path.read_text(encoding="utf-8").strip()
|
|
559
|
+
if tail:
|
|
560
|
+
message = tail.splitlines()[-1]
|
|
561
|
+
raise RuntimeAPIError(message)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _remove_proxy_state(local_port: int) -> None:
|
|
565
|
+
with contextlib.suppress(OSError):
|
|
566
|
+
_proxy_state_path(local_port).unlink()
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _stop_proxy_ports(local_ports: list[int], *, stop_all: bool = False) -> list[dict[str, Any]]:
|
|
570
|
+
records = _load_proxy_records(clean_stale=False)
|
|
571
|
+
stopped: list[dict[str, Any]] = []
|
|
572
|
+
wanted = set(local_ports)
|
|
573
|
+
for record in records:
|
|
574
|
+
port = int(record.get("local_port") or 0)
|
|
575
|
+
if not stop_all and port not in wanted:
|
|
576
|
+
continue
|
|
577
|
+
pid = int(record.get("pid") or 0)
|
|
578
|
+
if _proxy_pid_matches(pid, port):
|
|
579
|
+
with contextlib.suppress(OSError):
|
|
580
|
+
os.kill(pid, signal.SIGTERM)
|
|
581
|
+
_remove_proxy_state(port)
|
|
582
|
+
record["status"] = "stopped"
|
|
583
|
+
stopped.append(record)
|
|
584
|
+
return stopped
|
|
585
|
+
|
|
586
|
+
|
|
314
587
|
def _prompt_text(message: str, *, default: str | None = None) -> str | None:
|
|
315
588
|
"""Ask for a line of text. Returns None if the user left it blank.
|
|
316
589
|
|
|
@@ -424,7 +697,7 @@ def _resolve_computer_ref(client: RuntimeClient, ref: str) -> str:
|
|
|
424
697
|
return resolved or ref
|
|
425
698
|
if len(matches) > 1:
|
|
426
699
|
raise RuntimeAPIError(f"multiple computers matched {ref!r}; use the exact computer id")
|
|
427
|
-
|
|
700
|
+
raise RuntimeAPIError(f"computer {ref!r} not found")
|
|
428
701
|
|
|
429
702
|
|
|
430
703
|
|
|
@@ -732,6 +1005,54 @@ def _render_run_result(result: dict[str, Any]) -> None:
|
|
|
732
1005
|
console.print(f"[{color}]exit {exit_code}[/{color}]")
|
|
733
1006
|
|
|
734
1007
|
|
|
1008
|
+
def _render_network_panel(payload: dict[str, Any], *, title: str = "url") -> None:
|
|
1009
|
+
mods = _UI.rich()
|
|
1010
|
+
Panel, Text = mods["Panel"], mods["Text"]
|
|
1011
|
+
body = Text()
|
|
1012
|
+
status = str(payload.get("status") or "unknown")
|
|
1013
|
+
published_port = payload.get("published_port")
|
|
1014
|
+
startup_port = payload.get("startup_port")
|
|
1015
|
+
rows: list[tuple[str, str, str | None]] = [
|
|
1016
|
+
("url", str(payload.get("public_url") or "—"), "cyan"),
|
|
1017
|
+
("state", f"{_computer_status_dot(status)} {status}", _computer_status_style(status)),
|
|
1018
|
+
]
|
|
1019
|
+
if published_port:
|
|
1020
|
+
rows.append(("port", str(published_port), "bold white"))
|
|
1021
|
+
elif startup_port:
|
|
1022
|
+
rows.append(("port", str(startup_port), "bold white"))
|
|
1023
|
+
rows.append(("source", "durable startup", "dim"))
|
|
1024
|
+
else:
|
|
1025
|
+
rows.append(("port", "—", "dim"))
|
|
1026
|
+
if published_port and startup_port and startup_port != published_port:
|
|
1027
|
+
rows.append(("startup", str(startup_port), "dim"))
|
|
1028
|
+
for label, value, style in rows:
|
|
1029
|
+
body.append(f"{label:<8}", style="dim")
|
|
1030
|
+
body.append(value, style=style or "white")
|
|
1031
|
+
body.append("\n")
|
|
1032
|
+
_UI.console().print(Panel(body, title=title, border_style="cyan", expand=False, padding=(0, 1)))
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _render_startup_panel(payload: dict[str, Any], *, title: str = "startup") -> None:
|
|
1036
|
+
mods = _UI.rich()
|
|
1037
|
+
Panel, Text = mods["Panel"], mods["Text"]
|
|
1038
|
+
body = Text()
|
|
1039
|
+
configured = bool(payload.get("configured"))
|
|
1040
|
+
rows: list[tuple[str, str, str | None]] = [("configured", "yes" if configured else "no", "bold white" if configured else "dim")]
|
|
1041
|
+
if configured:
|
|
1042
|
+
rows.extend(
|
|
1043
|
+
[
|
|
1044
|
+
("command", str(payload.get("command") or "—"), "white"),
|
|
1045
|
+
("cwd", str(payload.get("cwd") or "/home/ubuntu"), "dim"),
|
|
1046
|
+
("port", str(payload.get("port") or "—"), "bold white"),
|
|
1047
|
+
]
|
|
1048
|
+
)
|
|
1049
|
+
for label, value, style in rows:
|
|
1050
|
+
body.append(f"{label:<10}", style="dim")
|
|
1051
|
+
body.append(value, style=style or "white")
|
|
1052
|
+
body.append("\n")
|
|
1053
|
+
_UI.console().print(Panel(body, title=title, border_style="cyan", expand=False, padding=(0, 1)))
|
|
1054
|
+
|
|
1055
|
+
|
|
735
1056
|
def _render_whoami_panel(payload: dict[str, Any]) -> None:
|
|
736
1057
|
mods = _UI.rich()
|
|
737
1058
|
Panel, Text = mods["Panel"], mods["Text"]
|
|
@@ -973,6 +1294,131 @@ def handle_info(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
|
973
1294
|
return report_success(result, lambda p: _render_computer_panel(p, title="computer"))
|
|
974
1295
|
|
|
975
1296
|
|
|
1297
|
+
def handle_url(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
1298
|
+
if (err := _require_api_key(config)) is not None:
|
|
1299
|
+
return err
|
|
1300
|
+
|
|
1301
|
+
if computer_id is None:
|
|
1302
|
+
if not _interactive():
|
|
1303
|
+
return report_error("computer id is required")
|
|
1304
|
+
picked = _pick_computer(config, "Pick a computer")
|
|
1305
|
+
if picked is None:
|
|
1306
|
+
return 0
|
|
1307
|
+
computer_id = picked.get("id") or picked.get("slug")
|
|
1308
|
+
if not computer_id:
|
|
1309
|
+
return report_error("computer is missing an id")
|
|
1310
|
+
|
|
1311
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1312
|
+
computer_id = _resolve_computer_ref(client, computer_id)
|
|
1313
|
+
result = client.get_computer_network(computer_id)
|
|
1314
|
+
return report_success(result, lambda p: _render_network_panel(p, title="url"))
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def handle_startup_show(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
1318
|
+
if (err := _require_api_key(config)) is not None:
|
|
1319
|
+
return err
|
|
1320
|
+
|
|
1321
|
+
if computer_id is None:
|
|
1322
|
+
if not _interactive():
|
|
1323
|
+
return report_error("computer id is required")
|
|
1324
|
+
picked = _pick_computer(config, "Pick a computer")
|
|
1325
|
+
if picked is None:
|
|
1326
|
+
return 0
|
|
1327
|
+
computer_id = picked.get("id") or picked.get("slug")
|
|
1328
|
+
if not computer_id:
|
|
1329
|
+
return report_error("computer is missing an id")
|
|
1330
|
+
|
|
1331
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1332
|
+
computer_id = _resolve_computer_ref(client, computer_id)
|
|
1333
|
+
result = client.get_startup_config(computer_id)
|
|
1334
|
+
return report_success(result, lambda p: _render_startup_panel(p, title="startup"))
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def handle_startup_set(
|
|
1338
|
+
config: RuntimeConfig,
|
|
1339
|
+
computer_id: str | None,
|
|
1340
|
+
command: str | None,
|
|
1341
|
+
cwd: str | None,
|
|
1342
|
+
port: int | None,
|
|
1343
|
+
) -> int:
|
|
1344
|
+
if (err := _require_api_key(config)) is not None:
|
|
1345
|
+
return err
|
|
1346
|
+
if not computer_id:
|
|
1347
|
+
return report_error("computer id is required")
|
|
1348
|
+
if not command:
|
|
1349
|
+
return report_error("startup command is required")
|
|
1350
|
+
if port is None:
|
|
1351
|
+
return report_error("port is required")
|
|
1352
|
+
if port <= 0 or port > 65535:
|
|
1353
|
+
return report_error("port must be between 1 and 65535")
|
|
1354
|
+
|
|
1355
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1356
|
+
computer_id = _resolve_computer_ref(client, computer_id)
|
|
1357
|
+
result = _with_spinner(
|
|
1358
|
+
f"saving startup config for {computer_id}…",
|
|
1359
|
+
lambda: client.set_startup_config(computer_id, command=command, cwd=cwd, port=port),
|
|
1360
|
+
)
|
|
1361
|
+
return report_success(result, lambda p: _render_startup_panel(p, title="startup saved"))
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def handle_startup_clear(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
1365
|
+
if (err := _require_api_key(config)) is not None:
|
|
1366
|
+
return err
|
|
1367
|
+
if not computer_id:
|
|
1368
|
+
return report_error("computer id is required")
|
|
1369
|
+
|
|
1370
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1371
|
+
computer_id = _resolve_computer_ref(client, computer_id)
|
|
1372
|
+
result = _with_spinner(
|
|
1373
|
+
f"clearing startup config for {computer_id}…",
|
|
1374
|
+
lambda: client.clear_startup_config(computer_id),
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
def render(_: dict[str, Any]) -> None:
|
|
1378
|
+
_UI.console().print(f"[green]✓[/green] cleared durable startup config for [bold]{computer_id}[/bold]")
|
|
1379
|
+
|
|
1380
|
+
return report_success(result or {"configured": False}, render)
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def handle_service_show(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
1384
|
+
if (err := _require_api_key(config)) is not None:
|
|
1385
|
+
return err
|
|
1386
|
+
|
|
1387
|
+
if computer_id is None:
|
|
1388
|
+
if not _interactive():
|
|
1389
|
+
return report_error("computer id is required")
|
|
1390
|
+
picked = _pick_computer(config, "Pick a computer")
|
|
1391
|
+
if picked is None:
|
|
1392
|
+
return 0
|
|
1393
|
+
computer_id = picked.get("id") or picked.get("slug")
|
|
1394
|
+
if not computer_id:
|
|
1395
|
+
return report_error("computer is missing an id")
|
|
1396
|
+
|
|
1397
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1398
|
+
computer_id = _resolve_computer_ref(client, computer_id)
|
|
1399
|
+
result = client.get_service_config(computer_id)
|
|
1400
|
+
return report_success(result, lambda p: _render_startup_panel(p, title="service"))
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def handle_service_clear(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
1404
|
+
if (err := _require_api_key(config)) is not None:
|
|
1405
|
+
return err
|
|
1406
|
+
if not computer_id:
|
|
1407
|
+
return report_error("computer id is required")
|
|
1408
|
+
|
|
1409
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1410
|
+
computer_id = _resolve_computer_ref(client, computer_id)
|
|
1411
|
+
result = _with_spinner(
|
|
1412
|
+
f"clearing service for {computer_id}…",
|
|
1413
|
+
lambda: client.clear_service_config(computer_id),
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
def render(_: dict[str, Any]) -> None:
|
|
1417
|
+
_UI.console().print(f"[green]✓[/green] cleared durable service for [bold]{computer_id}[/bold]")
|
|
1418
|
+
|
|
1419
|
+
return report_success(result or {"configured": False}, render)
|
|
1420
|
+
|
|
1421
|
+
|
|
976
1422
|
def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
977
1423
|
if (err := _require_api_key(config)) is not None:
|
|
978
1424
|
return err
|
|
@@ -1139,24 +1585,239 @@ def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | N
|
|
|
1139
1585
|
|
|
1140
1586
|
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1141
1587
|
computer_id = _resolve_computer_ref(client, computer_id)
|
|
1142
|
-
_with_spinner(
|
|
1588
|
+
result = _with_spinner(
|
|
1143
1589
|
f"publishing port {port} on {computer_id}…",
|
|
1144
1590
|
lambda: client.publish_port(computer_id, port),
|
|
1145
1591
|
)
|
|
1146
1592
|
|
|
1147
|
-
def render(
|
|
1593
|
+
def render(payload: dict[str, Any]) -> None:
|
|
1148
1594
|
_UI.console().print(
|
|
1149
|
-
f"[green]✓[/green] public url for [bold]{computer_id}[/bold]
|
|
1595
|
+
f"[green]✓[/green] public url for [bold]{computer_id}[/bold] now points at durable app port [bold]{payload.get('published_port') or port}[/bold]"
|
|
1150
1596
|
)
|
|
1597
|
+
url = payload.get("public_url")
|
|
1598
|
+
if url:
|
|
1599
|
+
_UI.console().print(f"[bold green]→[/bold green] [cyan]{url}[/cyan]")
|
|
1151
1600
|
|
|
1152
|
-
return report_success(
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1601
|
+
return report_success(result, render)
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def handle_proxy_start(config: RuntimeConfig, computer_id: str | None, specs: list[str] | None) -> int:
|
|
1605
|
+
if (err := _require_api_key(config)) is not None:
|
|
1606
|
+
return err
|
|
1607
|
+
if not computer_id:
|
|
1608
|
+
return report_error("computer id is required")
|
|
1609
|
+
if not specs:
|
|
1610
|
+
return report_error("at least one port mapping is required")
|
|
1611
|
+
|
|
1612
|
+
client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
|
|
1613
|
+
resolved_id = _resolve_computer_ref(client, computer_id)
|
|
1614
|
+
existing_records = _load_proxy_records(clean_stale=True)
|
|
1615
|
+
existing_ports = {int(r.get("local_port") or 0) for r in existing_records}
|
|
1616
|
+
mappings: list[tuple[int, int]] = []
|
|
1617
|
+
seen_ports: set[int] = set()
|
|
1618
|
+
for spec in specs:
|
|
1619
|
+
local_port, remote_port = _parse_proxy_port_spec(spec)
|
|
1620
|
+
if local_port in existing_ports:
|
|
1621
|
+
return report_error(f"local port {local_port} is already in use by a runtime proxy")
|
|
1622
|
+
if local_port in seen_ports:
|
|
1623
|
+
return report_error(f"local port {local_port} was specified more than once")
|
|
1624
|
+
seen_ports.add(local_port)
|
|
1625
|
+
mappings.append((local_port, remote_port))
|
|
1626
|
+
|
|
1627
|
+
started: list[dict[str, Any]] = []
|
|
1628
|
+
started_ports: list[int] = []
|
|
1629
|
+
for local_port, remote_port in mappings:
|
|
1630
|
+
try:
|
|
1631
|
+
_spawn_proxy_agent(config.base_url, str(config.api_key or ""), resolved_id, computer_id, local_port, remote_port)
|
|
1632
|
+
except RuntimeAPIError as exc:
|
|
1633
|
+
if started_ports:
|
|
1634
|
+
_stop_proxy_ports(started_ports, stop_all=False)
|
|
1635
|
+
return report_error(str(exc), status_code=exc.status_code)
|
|
1636
|
+
started_ports.append(local_port)
|
|
1637
|
+
started.append({"computer_id": resolved_id, "computer": computer_id, "local_port": local_port, "remote_port": remote_port})
|
|
1638
|
+
|
|
1639
|
+
def render(payload: dict[str, Any]) -> None:
|
|
1640
|
+
_UI.console().print("[green]✓[/green] proxy started")
|
|
1641
|
+
for proxy in payload.get("proxies", []):
|
|
1642
|
+
_UI.console().print(
|
|
1643
|
+
f" [bold]localhost:{proxy['local_port']}[/bold] -> [cyan]{proxy['computer']}[/cyan]:{proxy['remote_port']}"
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
return report_success({"proxies": started}, render)
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def handle_proxy_ls(config: RuntimeConfig) -> int:
|
|
1650
|
+
proxies = _load_proxy_records(clean_stale=True)
|
|
1651
|
+
|
|
1652
|
+
def render(payload: dict[str, Any]) -> None:
|
|
1653
|
+
mods = _UI.rich()
|
|
1654
|
+
Table = mods["Table"]
|
|
1655
|
+
table = Table(title="proxies")
|
|
1656
|
+
table.add_column("VM", style="white")
|
|
1657
|
+
table.add_column("Local", style="cyan")
|
|
1658
|
+
table.add_column("Remote", style="white")
|
|
1659
|
+
table.add_column("Status", style="green")
|
|
1660
|
+
for proxy in payload.get("proxies", []):
|
|
1661
|
+
table.add_row(
|
|
1662
|
+
str(proxy.get("display_name") or proxy.get("computer_id") or "—"),
|
|
1663
|
+
str(proxy.get("local_port") or "—"),
|
|
1664
|
+
str(proxy.get("remote_port") or "—"),
|
|
1665
|
+
str(proxy.get("status") or "unknown"),
|
|
1666
|
+
)
|
|
1667
|
+
_UI.console().print(table)
|
|
1668
|
+
|
|
1669
|
+
return report_success({"proxies": proxies}, render if _interactive() else None)
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
def handle_proxy_stop(config: RuntimeConfig, local_ports: list[int] | None, *, stop_all: bool = False) -> int:
|
|
1673
|
+
if not stop_all and not local_ports:
|
|
1674
|
+
return report_error("pass one or more local ports, or use --all")
|
|
1675
|
+
|
|
1676
|
+
stopped = _stop_proxy_ports(local_ports or [], stop_all=stop_all)
|
|
1677
|
+
|
|
1678
|
+
def render(payload: dict[str, Any]) -> None:
|
|
1679
|
+
_UI.console().print("[green]✓[/green] proxy stopped")
|
|
1680
|
+
for proxy in payload.get("proxies", []):
|
|
1681
|
+
_UI.console().print(
|
|
1682
|
+
f" [bold]localhost:{proxy.get('local_port')}[/bold] -> [cyan]{proxy.get('display_name') or proxy.get('computer_id')}[/cyan]:{proxy.get('remote_port')}"
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
return report_success({"proxies": stopped}, render if _interactive() else None)
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
def _proxy_preflight(base_url: str, api_key: str, computer_id: str, remote_port: int) -> None:
|
|
1689
|
+
client = RuntimeClient(base_url=base_url, api_key=api_key)
|
|
1690
|
+
ticket_payload = client.create_proxy_ticket(computer_id, remote_port)
|
|
1691
|
+
ws_url = _resolve_ws_url(base_url, ticket_payload.get("ws_url"))
|
|
1692
|
+
if not ws_url:
|
|
1693
|
+
raise RuntimeAPIError("proxy endpoint did not return a websocket url")
|
|
1694
|
+
with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1, max_size=None):
|
|
1695
|
+
return
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
def run_proxy_agent(base_url: str, api_key: str, computer_id: str, display_name: str, local_port: int, remote_port: int) -> int:
|
|
1700
|
+
if not api_key:
|
|
1701
|
+
print("proxy agent missing api key", file=sys.stderr)
|
|
1702
|
+
return 1
|
|
1703
|
+
|
|
1704
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
1705
|
+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
1706
|
+
try:
|
|
1707
|
+
listener.bind(("127.0.0.1", local_port))
|
|
1708
|
+
listener.listen()
|
|
1709
|
+
listener.settimeout(1.0)
|
|
1710
|
+
except OSError as exc:
|
|
1711
|
+
print(f"proxy listen failed on localhost:{local_port}: {exc}", file=sys.stderr)
|
|
1712
|
+
return 1
|
|
1713
|
+
|
|
1714
|
+
try:
|
|
1715
|
+
_proxy_preflight(base_url, api_key, computer_id, remote_port)
|
|
1716
|
+
except Exception as exc:
|
|
1717
|
+
with contextlib.suppress(OSError):
|
|
1718
|
+
listener.close()
|
|
1719
|
+
print(f"proxy preflight failed for localhost:{local_port} -> {computer_id}:{remote_port}: {exc}", file=sys.stderr)
|
|
1720
|
+
return 1
|
|
1721
|
+
|
|
1722
|
+
proxy_dir = _proxy_dir()
|
|
1723
|
+
proxy_dir.mkdir(parents=True, exist_ok=True)
|
|
1724
|
+
state = {
|
|
1725
|
+
"pid": os.getpid(),
|
|
1726
|
+
"computer_id": computer_id,
|
|
1727
|
+
"display_name": display_name,
|
|
1728
|
+
"local_port": local_port,
|
|
1729
|
+
"remote_port": remote_port,
|
|
1730
|
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
|
1731
|
+
}
|
|
1732
|
+
_proxy_state_path(local_port).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
|
|
1733
|
+
|
|
1734
|
+
stop_event = threading.Event()
|
|
1735
|
+
|
|
1736
|
+
def handle_signal(_signum: int, _frame: Any) -> None:
|
|
1737
|
+
stop_event.set()
|
|
1738
|
+
with contextlib.suppress(OSError):
|
|
1739
|
+
listener.close()
|
|
1740
|
+
|
|
1741
|
+
signal.signal(signal.SIGTERM, handle_signal)
|
|
1742
|
+
signal.signal(signal.SIGINT, handle_signal)
|
|
1743
|
+
|
|
1744
|
+
try:
|
|
1745
|
+
while not stop_event.is_set():
|
|
1746
|
+
try:
|
|
1747
|
+
conn, _ = listener.accept()
|
|
1748
|
+
except socket.timeout:
|
|
1749
|
+
continue
|
|
1750
|
+
except OSError:
|
|
1751
|
+
if stop_event.is_set():
|
|
1752
|
+
break
|
|
1753
|
+
continue
|
|
1754
|
+
thread = threading.Thread(
|
|
1755
|
+
target=_handle_proxy_client,
|
|
1756
|
+
args=(base_url, api_key, computer_id, remote_port, conn),
|
|
1757
|
+
daemon=True,
|
|
1758
|
+
)
|
|
1759
|
+
thread.start()
|
|
1760
|
+
finally:
|
|
1761
|
+
with contextlib.suppress(OSError):
|
|
1762
|
+
listener.close()
|
|
1763
|
+
_remove_proxy_state(local_port)
|
|
1764
|
+
return 0
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
def _handle_proxy_client(base_url: str, api_key: str, computer_id: str, remote_port: int, conn: socket.socket) -> None:
|
|
1768
|
+
client = RuntimeClient(base_url=base_url, api_key=api_key)
|
|
1769
|
+
try:
|
|
1770
|
+
ticket_payload = client.create_proxy_ticket(computer_id, remote_port)
|
|
1771
|
+
ws_url = _resolve_ws_url(base_url, ticket_payload.get("ws_url"))
|
|
1772
|
+
if not ws_url:
|
|
1773
|
+
raise RuntimeAPIError("proxy endpoint did not return a websocket url")
|
|
1774
|
+
with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1, max_size=None) as ws:
|
|
1775
|
+
stop = threading.Event()
|
|
1776
|
+
|
|
1777
|
+
def local_to_ws() -> None:
|
|
1778
|
+
try:
|
|
1779
|
+
while not stop.is_set():
|
|
1780
|
+
data = conn.recv(65536)
|
|
1781
|
+
if not data:
|
|
1782
|
+
break
|
|
1783
|
+
ws.send(data)
|
|
1784
|
+
except Exception:
|
|
1785
|
+
pass
|
|
1786
|
+
finally:
|
|
1787
|
+
stop.set()
|
|
1788
|
+
with contextlib.suppress(Exception):
|
|
1789
|
+
ws.close()
|
|
1790
|
+
|
|
1791
|
+
def ws_to_local() -> None:
|
|
1792
|
+
try:
|
|
1793
|
+
while not stop.is_set():
|
|
1794
|
+
data = ws.recv()
|
|
1795
|
+
if data is None:
|
|
1796
|
+
break
|
|
1797
|
+
if isinstance(data, str):
|
|
1798
|
+
chunk = data.encode()
|
|
1799
|
+
else:
|
|
1800
|
+
chunk = data
|
|
1801
|
+
if chunk:
|
|
1802
|
+
conn.sendall(chunk)
|
|
1803
|
+
except Exception:
|
|
1804
|
+
pass
|
|
1805
|
+
finally:
|
|
1806
|
+
stop.set()
|
|
1807
|
+
with contextlib.suppress(OSError):
|
|
1808
|
+
conn.shutdown(socket.SHUT_RDWR)
|
|
1809
|
+
|
|
1810
|
+
t1 = threading.Thread(target=local_to_ws, daemon=True)
|
|
1811
|
+
t2 = threading.Thread(target=ws_to_local, daemon=True)
|
|
1812
|
+
t1.start()
|
|
1813
|
+
t2.start()
|
|
1814
|
+
t1.join()
|
|
1815
|
+
t2.join()
|
|
1816
|
+
except Exception as exc:
|
|
1817
|
+
print(f"proxy connection failed for localhost:{conn.getsockname()[1]} -> {computer_id}:{remote_port}: {exc}", file=sys.stderr)
|
|
1818
|
+
finally:
|
|
1819
|
+
with contextlib.suppress(OSError):
|
|
1820
|
+
conn.close()
|
|
1160
1821
|
|
|
1161
1822
|
|
|
1162
1823
|
def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
|
|
@@ -1165,7 +1826,7 @@ def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
|
|
|
1165
1826
|
f"preparing console for {computer_id}…",
|
|
1166
1827
|
lambda: client.create_terminal_ticket(computer_id),
|
|
1167
1828
|
)
|
|
1168
|
-
ws_url =
|
|
1829
|
+
ws_url = _resolve_ws_url(config.base_url, ticket_payload.get("ws_url"))
|
|
1169
1830
|
if not ws_url:
|
|
1170
1831
|
return report_error("terminal endpoint did not return a websocket url")
|
|
1171
1832
|
|
|
@@ -1179,21 +1840,6 @@ def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
|
|
|
1179
1840
|
return report_error(f"terminal failed: {exc}")
|
|
1180
1841
|
|
|
1181
1842
|
|
|
1182
|
-
def _resolve_terminal_ws_url(base_url: str, ws_url: Any) -> str:
|
|
1183
|
-
text = str(ws_url or "").strip()
|
|
1184
|
-
if not text:
|
|
1185
|
-
return ""
|
|
1186
|
-
if text.startswith("ws://") or text.startswith("wss://"):
|
|
1187
|
-
return text
|
|
1188
|
-
|
|
1189
|
-
parsed = urlparse(base_url)
|
|
1190
|
-
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
1191
|
-
root = f"{scheme}://{parsed.netloc}"
|
|
1192
|
-
if text.startswith("/"):
|
|
1193
|
-
return root + text
|
|
1194
|
-
return urljoin(root + "/", text)
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
1843
|
def _run_terminal_session(ws_url: str) -> int:
|
|
1198
1844
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
1199
1845
|
return report_error("enter requires an interactive terminal")
|
|
@@ -79,6 +79,34 @@ 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
|
+
|
|
85
|
+
def get_startup_config(self, computer_id: str) -> dict[str, Any]:
|
|
86
|
+
return self._request("GET", f"/api/computers/{computer_id}/startup", auth_required=True)
|
|
87
|
+
|
|
88
|
+
def get_service_config(self, computer_id: str) -> dict[str, Any]:
|
|
89
|
+
return self.get_startup_config(computer_id)
|
|
90
|
+
|
|
91
|
+
def set_startup_config(
|
|
92
|
+
self,
|
|
93
|
+
computer_id: str,
|
|
94
|
+
*,
|
|
95
|
+
command: str,
|
|
96
|
+
cwd: str | None = None,
|
|
97
|
+
port: int,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
body: dict[str, Any] = {"command": command, "port": port}
|
|
100
|
+
if cwd is not None:
|
|
101
|
+
body["cwd"] = cwd
|
|
102
|
+
return self._request("PUT", f"/api/computers/{computer_id}/startup", json=body, auth_required=True)
|
|
103
|
+
|
|
104
|
+
def clear_startup_config(self, computer_id: str) -> dict[str, Any]:
|
|
105
|
+
return self._request("DELETE", f"/api/computers/{computer_id}/startup", auth_required=True)
|
|
106
|
+
|
|
107
|
+
def clear_service_config(self, computer_id: str) -> dict[str, Any]:
|
|
108
|
+
return self.clear_startup_config(computer_id)
|
|
109
|
+
|
|
82
110
|
def start_computer(self, computer_id: str) -> dict[str, Any]:
|
|
83
111
|
return self._request(
|
|
84
112
|
"POST",
|
|
@@ -98,6 +126,17 @@ class RuntimeClient:
|
|
|
98
126
|
timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
|
|
99
127
|
)
|
|
100
128
|
|
|
129
|
+
def create_proxy_ticket(self, computer_id: str, port: int) -> dict[str, Any]:
|
|
130
|
+
if port <= 0 or port > 65535:
|
|
131
|
+
raise RuntimeAPIError("port must be between 1 and 65535")
|
|
132
|
+
return self._request(
|
|
133
|
+
"POST",
|
|
134
|
+
f"/api/computers/{computer_id}/proxy-ticket",
|
|
135
|
+
json={"port": port},
|
|
136
|
+
auth_required=True,
|
|
137
|
+
timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
|
|
138
|
+
)
|
|
139
|
+
|
|
101
140
|
def run_command(
|
|
102
141
|
self,
|
|
103
142
|
computer_id: str,
|
|
@@ -122,17 +161,12 @@ class RuntimeClient:
|
|
|
122
161
|
def publish_port(self, computer_id: str, port: int) -> dict[str, Any]:
|
|
123
162
|
if port <= 0 or port > 65535:
|
|
124
163
|
raise RuntimeAPIError("port must be between 1 and 65535")
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
computer_id,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
gid=0,
|
|
164
|
+
return self._request(
|
|
165
|
+
"POST",
|
|
166
|
+
f"/api/computers/{computer_id}/publish",
|
|
167
|
+
json={"port": port},
|
|
168
|
+
auth_required=True,
|
|
131
169
|
)
|
|
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
170
|
|
|
137
171
|
def _request(
|
|
138
172
|
self,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.11
|
|
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
|
|
@@ -54,16 +54,27 @@ runtime logout
|
|
|
54
54
|
runtime login --api-key rt_live_...
|
|
55
55
|
|
|
56
56
|
# Computers
|
|
57
|
-
runtime create #
|
|
57
|
+
runtime create # creates a computer with the starter app already published
|
|
58
58
|
runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
59
59
|
runtime enter <name-or-id> # accepts slug/name like test, or the computer id
|
|
60
60
|
runtime list
|
|
61
61
|
runtime info <id>
|
|
62
62
|
runtime start <id>
|
|
63
|
-
runtime run <id> "echo hello"
|
|
63
|
+
runtime run <id> "echo hello" # one-shot foreground command only
|
|
64
64
|
runtime run <id> "apt install -y nodejs" --uid 0
|
|
65
|
-
runtime
|
|
65
|
+
runtime startup show <id>
|
|
66
|
+
runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
|
|
67
|
+
runtime startup clear <id> # low-level durable service config
|
|
68
|
+
runtime service show <id> # user-facing alias for the durable published app
|
|
69
|
+
runtime service clear <id>
|
|
70
|
+
runtime publish <id> 3000 # promote the running app on port 3000 to the public durable app
|
|
66
71
|
runtime delete <id>
|
|
72
|
+
|
|
73
|
+
# Inside a running computer, the helper installed by Runtime can manage the
|
|
74
|
+
# durable app without leaving the sandbox:
|
|
75
|
+
# runtime-env publish 3000
|
|
76
|
+
# runtime-env service show
|
|
77
|
+
# runtime-env service clear
|
|
67
78
|
```
|
|
68
79
|
|
|
69
80
|
## Python
|
|
@@ -73,12 +84,12 @@ from runtime_sdk import RuntimeClient
|
|
|
73
84
|
|
|
74
85
|
client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_...")
|
|
75
86
|
|
|
76
|
-
# Create a computer
|
|
87
|
+
# Create a computer. New computers start with the starter app already published.
|
|
77
88
|
computer = client.create_computer()
|
|
78
89
|
print(computer["public_url"]) # https://goldbird.runruntime.dev
|
|
79
90
|
|
|
80
|
-
# Or create one with
|
|
81
|
-
# That
|
|
91
|
+
# Or create one with an explicit durable app command.
|
|
92
|
+
# That saved service is replayed after cold restore / start.
|
|
82
93
|
app = client.create_computer(
|
|
83
94
|
slug="myapp",
|
|
84
95
|
command="python3 app.py",
|
|
@@ -93,7 +104,8 @@ print(result["stdout"])
|
|
|
93
104
|
# Wake a cold computer explicitly
|
|
94
105
|
client.start_computer(app["id"])
|
|
95
106
|
|
|
96
|
-
#
|
|
107
|
+
# Promote the running app on a local port to the durable public app.
|
|
108
|
+
# Runtime inspects the listening process and saves its command + cwd when possible.
|
|
97
109
|
client.publish_port(computer["id"], 3000)
|
|
98
110
|
|
|
99
111
|
# List, info, delete
|
|
@@ -120,10 +132,16 @@ make smoke
|
|
|
120
132
|
Use `make deploy` instead of `make sync` when migrations, env
|
|
121
133
|
files, Caddy, or systemd units changed.
|
|
122
134
|
|
|
123
|
-
Cold restore and
|
|
124
|
-
computers
|
|
125
|
-
|
|
126
|
-
|
|
135
|
+
Cold restore and explicit `runtime start` replay the saved published app command.
|
|
136
|
+
New computers seed that durable app from the starter workspace. Later,
|
|
137
|
+
`runtime publish <id> <port>` can promote a running listener into the saved
|
|
138
|
+
public app definition by inspecting the live process. Inside a running computer,
|
|
139
|
+
`runtime-env publish <port>` does the same thing using a computer-scoped token
|
|
140
|
+
installed by Runtime. `runtime service show|clear` and `runtime-env service
|
|
141
|
+
show|clear` expose that same durable app state directly. The low-level
|
|
142
|
+
`runtime startup ...` commands still map to the same durable state. A one-off
|
|
143
|
+
`runtime run` stays one-shot: the filesystem is restored after going cold, but
|
|
144
|
+
that ad-hoc process is not.
|
|
127
145
|
|
|
128
146
|
Run the SDK unit tests through the backend project environment:
|
|
129
147
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|