runbay 0.1.0__py3-none-any.whl
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.
- runbay/__init__.py +46 -0
- runbay/api.py +384 -0
- runbay/cli/__init__.py +5 -0
- runbay/cli/__main__.py +6 -0
- runbay/cli/commands/__init__.py +1 -0
- runbay/cli/commands/account.py +94 -0
- runbay/cli/commands/billing.py +66 -0
- runbay/cli/commands/build_utils/__init__.py +16 -0
- runbay/cli/commands/build_utils/handler_generator.py +29 -0
- runbay/cli/commands/build_utils/manifest.py +32 -0
- runbay/cli/commands/build_utils/resource_config_generator.py +40 -0
- runbay/cli/commands/build_utils/scanner.py +56 -0
- runbay/cli/commands/dicts.py +184 -0
- runbay/cli/commands/endpoint.py +80 -0
- runbay/cli/commands/environment.py +98 -0
- runbay/cli/commands/jobs.py +68 -0
- runbay/cli/commands/project.py +294 -0
- runbay/cli/commands/queues.py +175 -0
- runbay/cli/commands/run.py +182 -0
- runbay/cli/commands/sandboxes.py +287 -0
- runbay/cli/commands/secrets.py +101 -0
- runbay/cli/commands/volumes.py +217 -0
- runbay/cli/main.py +64 -0
- runbay/cli/utils/__init__.py +1 -0
- runbay/cli/utils/client.py +89 -0
- runbay/cli/utils/output.py +41 -0
- runbay/cli/utils/rich_help.py +136 -0
- runbay/cli/utils/volume_io.py +67 -0
- runbay/cli/utils/ws_terminal.py +440 -0
- runbay/client.py +119 -0
- runbay/config.py +54 -0
- runbay/decorator.py +534 -0
- runbay/dict.py +144 -0
- runbay/entrypoint.py +87 -0
- runbay/environment.py +111 -0
- runbay/exceptions.py +89 -0
- runbay/packaging.py +128 -0
- runbay/project.py +123 -0
- runbay/queue.py +103 -0
- runbay/retries.py +77 -0
- runbay/sandbox.py +431 -0
- runbay/schedule.py +35 -0
- runbay/secret.py +69 -0
- runbay/serverless.py +61 -0
- runbay/vendor.py +98 -0
- runbay/volume.py +81 -0
- runbay-0.1.0.dist-info/METADATA +8 -0
- runbay-0.1.0.dist-info/RECORD +51 -0
- runbay-0.1.0.dist-info/WHEEL +5 -0
- runbay-0.1.0.dist-info/entry_points.txt +3 -0
- runbay-0.1.0.dist-info/top_level.txt +1 -0
runbay/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""RunBay SDK — deploy and call serverless GPU endpoints."""
|
|
2
|
+
|
|
3
|
+
from .client import Endpoint, Job
|
|
4
|
+
from .decorator import (
|
|
5
|
+
CpuGroup,
|
|
6
|
+
CpuInstanceType,
|
|
7
|
+
EndpointFunction,
|
|
8
|
+
GpuGroup,
|
|
9
|
+
asgi_app,
|
|
10
|
+
endpoint,
|
|
11
|
+
fastapi_endpoint,
|
|
12
|
+
web_server,
|
|
13
|
+
)
|
|
14
|
+
from .dict import Dict
|
|
15
|
+
from .entrypoint import LocalEntrypoint, is_local, local_entrypoint
|
|
16
|
+
from .environment import Environment
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
ApiError,
|
|
19
|
+
AuthError,
|
|
20
|
+
Error,
|
|
21
|
+
InsufficientBalanceError,
|
|
22
|
+
JobError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
RemoteError,
|
|
25
|
+
SandboxError,
|
|
26
|
+
TimeoutError,
|
|
27
|
+
)
|
|
28
|
+
from .queue import Queue
|
|
29
|
+
from .retries import Retries
|
|
30
|
+
from .sandbox import Sandbox, SandboxSnapshot, Tunnel
|
|
31
|
+
from .schedule import Cron, Period
|
|
32
|
+
from .secret import Secret
|
|
33
|
+
from .volume import Volume
|
|
34
|
+
from . import serverless
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
__all__ = [
|
|
38
|
+
"endpoint", "asgi_app", "web_server", "fastapi_endpoint", "local_entrypoint", "LocalEntrypoint", "is_local",
|
|
39
|
+
"GpuGroup", "CpuGroup", "CpuInstanceType", "Cron", "Period", "Retries",
|
|
40
|
+
"EndpointFunction", "Endpoint", "Job", "Sandbox", "SandboxSnapshot", "Tunnel",
|
|
41
|
+
"Secret", "Volume", "Dict", "Queue", "Environment",
|
|
42
|
+
# exceptions
|
|
43
|
+
"Error", "ApiError", "AuthError", "NotFoundError", "InsufficientBalanceError",
|
|
44
|
+
"SandboxError", "JobError", "RemoteError", "TimeoutError",
|
|
45
|
+
"serverless",
|
|
46
|
+
]
|
runbay/api.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Management API client (auth, endpoints CRUD, artifact upload)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .config import get_api_url, get_auth_token
|
|
9
|
+
# Re-exported so `from .api import ApiError` (and callers) keep working.
|
|
10
|
+
from .exceptions import ApiError, api_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ManagementClient:
|
|
14
|
+
def __init__(self, api_url: Optional[str] = None, token: Optional[str] = None):
|
|
15
|
+
self.api_url = (api_url or get_api_url()).rstrip("/")
|
|
16
|
+
self.token = token or get_auth_token()
|
|
17
|
+
self.session = requests.Session()
|
|
18
|
+
# explicit UA: proxies (e.g. Cloudflare) may block default python UAs
|
|
19
|
+
self.session.headers["User-Agent"] = "runbay-sdk/0.1.0"
|
|
20
|
+
|
|
21
|
+
def _headers(self, auth: bool = True) -> Dict[str, str]:
|
|
22
|
+
# requests sets Content-Type itself when json= is passed; sending it
|
|
23
|
+
# on body-less POSTs makes Fastify reject the empty body with a 400
|
|
24
|
+
headers: Dict[str, str] = {}
|
|
25
|
+
if auth:
|
|
26
|
+
if not self.token:
|
|
27
|
+
raise ApiError(401, "Not logged in — run 'rb login' or set RB_API_KEY")
|
|
28
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
29
|
+
return headers
|
|
30
|
+
|
|
31
|
+
def _request(self, method: str, path: str, auth: bool = True, **kwargs) -> Any:
|
|
32
|
+
res = self.session.request(
|
|
33
|
+
method,
|
|
34
|
+
f"{self.api_url}{path}",
|
|
35
|
+
headers=self._headers(auth),
|
|
36
|
+
timeout=kwargs.pop("timeout", 60),
|
|
37
|
+
**kwargs,
|
|
38
|
+
)
|
|
39
|
+
if res.status_code >= 400:
|
|
40
|
+
try:
|
|
41
|
+
message = res.json().get("error", res.text)
|
|
42
|
+
except ValueError:
|
|
43
|
+
message = res.text
|
|
44
|
+
raise api_error(res.status_code, message)
|
|
45
|
+
return res.json() if res.text else {}
|
|
46
|
+
|
|
47
|
+
# ---- auth ----
|
|
48
|
+
|
|
49
|
+
def register(self, email: str, name: str, password: str) -> Dict[str, Any]:
|
|
50
|
+
return self._request(
|
|
51
|
+
"POST", "/user/register", auth=False,
|
|
52
|
+
json={"email": email, "name": name, "password": password},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def login(self, email: str, password: str) -> Dict[str, Any]:
|
|
56
|
+
return self._request(
|
|
57
|
+
"POST", "/user/login", auth=False, json={"email": email, "password": password}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def create_api_key(self, name: str = "default") -> Dict[str, Any]:
|
|
61
|
+
return self._request("POST", "/user/apikey", json={"name": name})
|
|
62
|
+
|
|
63
|
+
def me(self) -> Dict[str, Any]:
|
|
64
|
+
return self._request("GET", "/user/me")
|
|
65
|
+
|
|
66
|
+
def balance(self) -> Dict[str, Any]:
|
|
67
|
+
return self._request("GET", "/user/balance")
|
|
68
|
+
|
|
69
|
+
def usage(self, limit: int = 50) -> Dict[str, Any]:
|
|
70
|
+
return self._request("GET", "/user/usage", params={"limit": str(limit)})
|
|
71
|
+
|
|
72
|
+
def gpus(self) -> list:
|
|
73
|
+
"""Merged compute catalog (GPU + CPU entries, each with a `kind` field).
|
|
74
|
+
|
|
75
|
+
Backend returns `{gpus: [...], cpus: [...]}`; we merge into one list so
|
|
76
|
+
callers reading `kind` stay backward compatible.
|
|
77
|
+
"""
|
|
78
|
+
data = self._request("GET", "/api/gpus")
|
|
79
|
+
return [*data.get("gpus", []), *data.get("cpus", [])]
|
|
80
|
+
|
|
81
|
+
# ---- sandboxes ----
|
|
82
|
+
|
|
83
|
+
def create_sandbox(self, spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
84
|
+
return self._request("POST", "/api/sandboxes", json=spec)["sandbox"]
|
|
85
|
+
|
|
86
|
+
def list_sandboxes(self, include_terminated: bool = False) -> list:
|
|
87
|
+
params = {"all": "true"} if include_terminated else {}
|
|
88
|
+
return self._request("GET", "/api/sandboxes", params=params)["sandboxes"]
|
|
89
|
+
|
|
90
|
+
def get_sandbox(self, sandbox_id: str) -> Dict[str, Any]:
|
|
91
|
+
return self._request("GET", f"/api/sandboxes/{sandbox_id}")["sandbox"]
|
|
92
|
+
|
|
93
|
+
def terminate_sandbox(self, sandbox_id: str) -> Dict[str, Any]:
|
|
94
|
+
return self._request("DELETE", f"/api/sandboxes/{sandbox_id}")
|
|
95
|
+
|
|
96
|
+
# ---- sandbox snapshots ----
|
|
97
|
+
|
|
98
|
+
def create_sandbox_snapshot(self, sandbox_id: str) -> Dict[str, Any]:
|
|
99
|
+
return self._request("POST", f"/api/sandboxes/{sandbox_id}/snapshot")["snapshot"]
|
|
100
|
+
|
|
101
|
+
def list_sandbox_snapshots(self, sandbox_id: Optional[str] = None) -> list:
|
|
102
|
+
params = {"sandboxId": sandbox_id} if sandbox_id else {}
|
|
103
|
+
return self._request("GET", "/api/sandbox-snapshots", params=params)["snapshots"]
|
|
104
|
+
|
|
105
|
+
def get_sandbox_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
|
|
106
|
+
return self._request("GET", f"/api/sandbox-snapshots/{snapshot_id}")["snapshot"]
|
|
107
|
+
|
|
108
|
+
def delete_sandbox_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
|
|
109
|
+
return self._request("DELETE", f"/api/sandbox-snapshots/{snapshot_id}")
|
|
110
|
+
|
|
111
|
+
# ---- endpoints ----
|
|
112
|
+
|
|
113
|
+
def create_endpoint(self, spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
114
|
+
return self._request("POST", "/api/endpoints", json=spec)["endpoint"]
|
|
115
|
+
|
|
116
|
+
def list_endpoints(self, environment: Optional[str] = None) -> list:
|
|
117
|
+
params = {"environment": environment} if environment else {}
|
|
118
|
+
return self._request("GET", "/api/endpoints", params=params)["endpoints"]
|
|
119
|
+
|
|
120
|
+
# ---- environments ----
|
|
121
|
+
|
|
122
|
+
def list_environments(self) -> list:
|
|
123
|
+
return self._request("GET", "/api/environments")["environments"]
|
|
124
|
+
|
|
125
|
+
def create_environment(self, name: str, web_suffix: str = "") -> Dict[str, Any]:
|
|
126
|
+
body: Dict[str, Any] = {"name": name}
|
|
127
|
+
if web_suffix:
|
|
128
|
+
body["webSuffix"] = web_suffix
|
|
129
|
+
return self._request("POST", "/api/environments", json=body)["environment"]
|
|
130
|
+
|
|
131
|
+
def update_environment(
|
|
132
|
+
self,
|
|
133
|
+
name: str,
|
|
134
|
+
set_name: Optional[str] = None,
|
|
135
|
+
set_web_suffix: Optional[str] = None,
|
|
136
|
+
) -> Dict[str, Any]:
|
|
137
|
+
body: Dict[str, Any] = {}
|
|
138
|
+
if set_name is not None:
|
|
139
|
+
body["setName"] = set_name
|
|
140
|
+
if set_web_suffix is not None:
|
|
141
|
+
body["setWebSuffix"] = set_web_suffix
|
|
142
|
+
if not body:
|
|
143
|
+
raise ValueError("update_environment requires set_name or set_web_suffix")
|
|
144
|
+
return self._request("PATCH", f"/api/environments/{name}", json=body)["environment"]
|
|
145
|
+
|
|
146
|
+
def delete_environment(self, name: str) -> Dict[str, Any]:
|
|
147
|
+
return self._request("DELETE", f"/api/environments/{name}")
|
|
148
|
+
|
|
149
|
+
def storage(self) -> Dict[str, Any]:
|
|
150
|
+
return self._request("GET", "/user/storage")["storage"]
|
|
151
|
+
|
|
152
|
+
def get_endpoint(self, endpoint_id: str) -> Dict[str, Any]:
|
|
153
|
+
return self._request("GET", f"/api/endpoints/{endpoint_id}")["endpoint"]
|
|
154
|
+
|
|
155
|
+
def update_endpoint(self, endpoint_id: str, spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
156
|
+
return self._request("PATCH", f"/api/endpoints/{endpoint_id}", json=spec)["endpoint"]
|
|
157
|
+
|
|
158
|
+
def delete_endpoint(self, endpoint_id: str) -> Dict[str, Any]:
|
|
159
|
+
return self._request("DELETE", f"/api/endpoints/{endpoint_id}")
|
|
160
|
+
|
|
161
|
+
def list_jobs(self, endpoint_id: str, status: Optional[str] = None, limit: int = 50) -> list:
|
|
162
|
+
params = {"limit": str(limit)}
|
|
163
|
+
if status:
|
|
164
|
+
params["status"] = status
|
|
165
|
+
return self._request("GET", f"/api/endpoints/{endpoint_id}/jobs", params=params)["jobs"]
|
|
166
|
+
|
|
167
|
+
def list_workers(self, endpoint_id: str) -> list:
|
|
168
|
+
return self._request("GET", f"/api/endpoints/{endpoint_id}/workers")["workers"]
|
|
169
|
+
|
|
170
|
+
# ---- volumes ----
|
|
171
|
+
|
|
172
|
+
def create_volume(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
173
|
+
body: Dict[str, Any] = {"name": name}
|
|
174
|
+
if environment is not None:
|
|
175
|
+
body["environment"] = environment
|
|
176
|
+
return self._request("POST", "/api/volumes", json=body)["volume"]
|
|
177
|
+
|
|
178
|
+
def list_volumes(self, environment: Optional[str] = None) -> list:
|
|
179
|
+
params = {"environment": environment} if environment else {}
|
|
180
|
+
return self._request("GET", "/api/volumes", params=params)["volumes"]
|
|
181
|
+
|
|
182
|
+
def get_volume(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
183
|
+
params = {"environment": environment} if environment else {}
|
|
184
|
+
return self._request("GET", f"/api/volumes/{name}", params=params)["volume"]
|
|
185
|
+
|
|
186
|
+
def delete_volume(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
187
|
+
params = {"environment": environment} if environment else {}
|
|
188
|
+
return self._request("DELETE", f"/api/volumes/{name}", params=params)
|
|
189
|
+
|
|
190
|
+
def rename_volume(self, old_name: str, new_name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
191
|
+
params = {"environment": environment} if environment else {}
|
|
192
|
+
return self._request("PATCH", f"/api/volumes/{old_name}", params=params, json={"name": new_name})["volume"]
|
|
193
|
+
|
|
194
|
+
def list_volume_files(self, name: str, prefix: str = "", environment: Optional[str] = None) -> list:
|
|
195
|
+
params: Dict[str, str] = {}
|
|
196
|
+
if prefix:
|
|
197
|
+
params["prefix"] = prefix
|
|
198
|
+
if environment is not None:
|
|
199
|
+
params["environment"] = environment
|
|
200
|
+
return self._request("GET", f"/api/volumes/{name}/files", params=params)["files"]
|
|
201
|
+
|
|
202
|
+
def delete_volume_path(self, name: str, path: str, recursive: bool = False, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
203
|
+
params = {"environment": environment} if environment else {}
|
|
204
|
+
return self._request("DELETE", f"/api/volumes/{name}/files", params=params, json={"path": path, "recursive": recursive})
|
|
205
|
+
|
|
206
|
+
def get_volume_upload_url(self, name: str, path: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
207
|
+
params = {"environment": environment} if environment else {}
|
|
208
|
+
return self._request("POST", f"/api/volumes/{name}/upload-url", params=params, json={"path": path})
|
|
209
|
+
|
|
210
|
+
def get_volume_download_url(self, name: str, path: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
211
|
+
params = {"environment": environment} if environment else {}
|
|
212
|
+
return self._request("POST", f"/api/volumes/{name}/download-url", params=params, json={"path": path})
|
|
213
|
+
|
|
214
|
+
# ---- dicts ----
|
|
215
|
+
|
|
216
|
+
def create_dict(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
217
|
+
body: Dict[str, Any] = {"name": name}
|
|
218
|
+
if environment is not None:
|
|
219
|
+
body["environment"] = environment
|
|
220
|
+
return self._request("POST", "/api/dicts", json=body)["dict"]
|
|
221
|
+
|
|
222
|
+
def list_dicts(self, environment: Optional[str] = None) -> list:
|
|
223
|
+
params = {"environment": environment} if environment else {}
|
|
224
|
+
return self._request("GET", "/api/dicts", params=params)["dicts"]
|
|
225
|
+
|
|
226
|
+
def get_dict(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
227
|
+
params = {"environment": environment} if environment else {}
|
|
228
|
+
return self._request("GET", f"/api/dicts/{name}", params=params)["dict"]
|
|
229
|
+
|
|
230
|
+
def rename_dict(self, old_name: str, new_name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
231
|
+
params = {"environment": environment} if environment else {}
|
|
232
|
+
return self._request("PATCH", f"/api/dicts/{old_name}", params=params, json={"name": new_name})["dict"]
|
|
233
|
+
|
|
234
|
+
def delete_dict(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
235
|
+
params = {"environment": environment} if environment else {}
|
|
236
|
+
return self._request("DELETE", f"/api/dicts/{name}", params=params)
|
|
237
|
+
|
|
238
|
+
def list_dict_entries(
|
|
239
|
+
self, name: str, limit: int = 100, search: str = "", environment: Optional[str] = None
|
|
240
|
+
) -> Dict[str, Any]:
|
|
241
|
+
params: Dict[str, str] = {"limit": str(limit)}
|
|
242
|
+
if search:
|
|
243
|
+
params["search"] = search
|
|
244
|
+
if environment is not None:
|
|
245
|
+
params["environment"] = environment
|
|
246
|
+
return self._request("GET", f"/api/dicts/{name}/entries", params=params)
|
|
247
|
+
|
|
248
|
+
def get_dict_entry(self, name: str, key: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
249
|
+
params = {"environment": environment} if environment else {}
|
|
250
|
+
return self._request("GET", f"/api/dicts/{name}/entries/{quote(key, safe='')}", params=params)
|
|
251
|
+
|
|
252
|
+
def put_dict_entry(self, name: str, key: str, value: Any, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
253
|
+
params = {"environment": environment} if environment else {}
|
|
254
|
+
return self._request(
|
|
255
|
+
"PUT", f"/api/dicts/{name}/entries/{quote(key, safe='')}", params=params, json={"value": value}
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def put_dict_entries(self, name: str, entries: Dict[str, Any], environment: Optional[str] = None) -> Dict[str, Any]:
|
|
259
|
+
params = {"environment": environment} if environment else {}
|
|
260
|
+
return self._request("POST", f"/api/dicts/{name}/entries", params=params, json={"entries": entries})
|
|
261
|
+
|
|
262
|
+
def delete_dict_entry(self, name: str, key: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
263
|
+
params = {"environment": environment} if environment else {}
|
|
264
|
+
return self._request("DELETE", f"/api/dicts/{name}/entries/{quote(key, safe='')}", params=params)
|
|
265
|
+
|
|
266
|
+
def clear_dict(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
267
|
+
params = {"environment": environment} if environment else {}
|
|
268
|
+
return self._request("POST", f"/api/dicts/{name}/clear", params=params)
|
|
269
|
+
|
|
270
|
+
# ---- queues ----
|
|
271
|
+
|
|
272
|
+
def create_queue(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
273
|
+
body: Dict[str, Any] = {"name": name}
|
|
274
|
+
if environment is not None:
|
|
275
|
+
body["environment"] = environment
|
|
276
|
+
return self._request("POST", "/api/queues", json=body)["queue"]
|
|
277
|
+
|
|
278
|
+
def list_queues(self, environment: Optional[str] = None) -> list:
|
|
279
|
+
params = {"environment": environment} if environment else {}
|
|
280
|
+
return self._request("GET", "/api/queues", params=params)["queues"]
|
|
281
|
+
|
|
282
|
+
def get_queue(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
283
|
+
params = {"environment": environment} if environment else {}
|
|
284
|
+
return self._request("GET", f"/api/queues/{name}", params=params)["queue"]
|
|
285
|
+
|
|
286
|
+
def rename_queue(self, old_name: str, new_name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
287
|
+
params = {"environment": environment} if environment else {}
|
|
288
|
+
return self._request("PATCH", f"/api/queues/{old_name}", params=params, json={"name": new_name})["queue"]
|
|
289
|
+
|
|
290
|
+
def delete_queue(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
291
|
+
params = {"environment": environment} if environment else {}
|
|
292
|
+
return self._request("DELETE", f"/api/queues/{name}", params=params)
|
|
293
|
+
|
|
294
|
+
def queue_put(self, name: str, items: List[Any], environment: Optional[str] = None) -> Dict[str, Any]:
|
|
295
|
+
params = {"environment": environment} if environment else {}
|
|
296
|
+
return self._request("POST", f"/api/queues/{name}/put", params=params, json={"items": items})
|
|
297
|
+
|
|
298
|
+
def queue_pop(self, name: str, n: int = 1, environment: Optional[str] = None) -> List[Any]:
|
|
299
|
+
params = {"environment": environment} if environment else {}
|
|
300
|
+
return self._request("POST", f"/api/queues/{name}/pop", params=params, json={"n": n})["items"]
|
|
301
|
+
|
|
302
|
+
def queue_peek(self, name: str, limit: int = 50, environment: Optional[str] = None) -> list:
|
|
303
|
+
params: Dict[str, str] = {"limit": str(limit)}
|
|
304
|
+
if environment is not None:
|
|
305
|
+
params["environment"] = environment
|
|
306
|
+
return self._request("GET", f"/api/queues/{name}/peek", params=params)["entries"]
|
|
307
|
+
|
|
308
|
+
def queue_len(self, name: str, environment: Optional[str] = None) -> int:
|
|
309
|
+
params = {"environment": environment} if environment else {}
|
|
310
|
+
return self._request("GET", f"/api/queues/{name}/len", params=params)["length"]
|
|
311
|
+
|
|
312
|
+
def clear_queue(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
313
|
+
params = {"environment": environment} if environment else {}
|
|
314
|
+
return self._request("POST", f"/api/queues/{name}/clear", params=params)
|
|
315
|
+
|
|
316
|
+
# ---- secrets ----
|
|
317
|
+
|
|
318
|
+
def create_secret(self, name: str, values: Dict[str, str], environment: Optional[str] = None) -> Dict[str, Any]:
|
|
319
|
+
body: Dict[str, Any] = {"name": name, "values": values}
|
|
320
|
+
if environment is not None:
|
|
321
|
+
body["environment"] = environment
|
|
322
|
+
return self._request("POST", "/api/secrets", json=body)["secret"]
|
|
323
|
+
|
|
324
|
+
def list_secrets(self, environment: Optional[str] = None) -> list:
|
|
325
|
+
params = {"environment": environment} if environment else {}
|
|
326
|
+
return self._request("GET", "/api/secrets", params=params)["secrets"]
|
|
327
|
+
|
|
328
|
+
def get_secret(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
329
|
+
params = {"environment": environment} if environment else {}
|
|
330
|
+
return self._request("GET", f"/api/secrets/{name}", params=params)["secret"]
|
|
331
|
+
|
|
332
|
+
def update_secret(
|
|
333
|
+
self,
|
|
334
|
+
name: str,
|
|
335
|
+
*,
|
|
336
|
+
new_name: Optional[str] = None,
|
|
337
|
+
values: Optional[Dict[str, str]] = None,
|
|
338
|
+
environment: Optional[str] = None,
|
|
339
|
+
) -> Dict[str, Any]:
|
|
340
|
+
body: Dict[str, Any] = {}
|
|
341
|
+
if new_name is not None:
|
|
342
|
+
body["name"] = new_name
|
|
343
|
+
if values is not None:
|
|
344
|
+
body["values"] = values
|
|
345
|
+
params = {"environment": environment} if environment else {}
|
|
346
|
+
return self._request("PATCH", f"/api/secrets/{name}", params=params, json=body)["secret"]
|
|
347
|
+
|
|
348
|
+
def delete_secret(self, name: str, environment: Optional[str] = None) -> Dict[str, Any]:
|
|
349
|
+
params = {"environment": environment} if environment else {}
|
|
350
|
+
return self._request("DELETE", f"/api/secrets/{name}", params=params)
|
|
351
|
+
|
|
352
|
+
# ---- deploy ----
|
|
353
|
+
|
|
354
|
+
def get_upload_url(self, endpoint_id: str) -> Dict[str, Any]:
|
|
355
|
+
return self._request("POST", f"/api/endpoints/{endpoint_id}/artifact/upload-url")
|
|
356
|
+
|
|
357
|
+
def upload_artifact(self, upload_url: str, bundle_path: str) -> None:
|
|
358
|
+
with open(bundle_path, "rb") as f:
|
|
359
|
+
res = requests.put(
|
|
360
|
+
upload_url, data=f, headers={"Content-Type": "application/gzip"}, timeout=600
|
|
361
|
+
)
|
|
362
|
+
if res.status_code >= 400:
|
|
363
|
+
raise ApiError(res.status_code, f"artifact upload failed: {res.text[:500]}")
|
|
364
|
+
|
|
365
|
+
def commit_artifact(
|
|
366
|
+
self,
|
|
367
|
+
endpoint_id: str,
|
|
368
|
+
key: str,
|
|
369
|
+
version: int,
|
|
370
|
+
handler: Optional[str] = None,
|
|
371
|
+
python: Optional[str] = None,
|
|
372
|
+
packages: Optional[List[Any]] = None,
|
|
373
|
+
files: Optional[List[Dict[str, Any]]] = None,
|
|
374
|
+
) -> Dict[str, Any]:
|
|
375
|
+
body: Dict[str, Any] = {"key": key, "version": version}
|
|
376
|
+
if handler:
|
|
377
|
+
body["handler"] = handler
|
|
378
|
+
if python:
|
|
379
|
+
body["python"] = python
|
|
380
|
+
if packages is not None:
|
|
381
|
+
body["packages"] = packages
|
|
382
|
+
if files is not None:
|
|
383
|
+
body["files"] = files
|
|
384
|
+
return self._request("POST", f"/api/endpoints/{endpoint_id}/artifact/commit", json=body)
|
runbay/cli/__init__.py
ADDED
runbay/cli/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules (each owns its Click group/commands)."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Account commands: login, whoami, register(hidden), apikey(hidden)."""
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ...api import ApiError, ManagementClient
|
|
9
|
+
from ...config import save_credentials
|
|
10
|
+
from ..utils.client import _browser_login, _client, _save_login
|
|
11
|
+
from ..utils.output import _die, _print_json
|
|
12
|
+
from ..utils.rich_help import RichCommand as _Cmd
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command(cls=_Cmd)
|
|
16
|
+
@click.argument("api_key", required=False)
|
|
17
|
+
@click.option("--browser", "use_browser", is_flag=True, help="Authorize through the web console")
|
|
18
|
+
@click.option("--api-url", default=None, help="Backend API URL (persisted)")
|
|
19
|
+
def login(api_key: Optional[str], use_browser: bool, api_url: Optional[str]) -> None:
|
|
20
|
+
"""Log in — through the browser (default) or with an API key.
|
|
21
|
+
|
|
22
|
+
rb login interactive: browser or paste an API key
|
|
23
|
+
rb login --browser straight to browser authorization
|
|
24
|
+
rb login rb_... use an existing API key
|
|
25
|
+
"""
|
|
26
|
+
if api_key:
|
|
27
|
+
return _save_login(api_key, api_url)
|
|
28
|
+
if use_browser:
|
|
29
|
+
return _browser_login(api_url)
|
|
30
|
+
|
|
31
|
+
choice = click.prompt(
|
|
32
|
+
"how do you want to authenticate?\n [1] browser (opens the web console)\n [2] paste an API key\n",
|
|
33
|
+
type=click.Choice(["1", "2"]),
|
|
34
|
+
default="1",
|
|
35
|
+
show_choices=False,
|
|
36
|
+
)
|
|
37
|
+
if choice == "1":
|
|
38
|
+
return _browser_login(api_url)
|
|
39
|
+
|
|
40
|
+
api_key = getpass.getpass("API key (rb_...): ").strip()
|
|
41
|
+
if not api_key:
|
|
42
|
+
_die("an API key is required")
|
|
43
|
+
_save_login(api_key, api_url)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.command(cls=_Cmd, hidden=True)
|
|
47
|
+
@click.option("--email", prompt=True)
|
|
48
|
+
@click.option("--name", prompt=True)
|
|
49
|
+
@click.option("--api-url", default=None, help="Backend API URL (persisted)")
|
|
50
|
+
def register(email: str, name: str, api_url: Optional[str]) -> None:
|
|
51
|
+
"""Create an account and an initial API key."""
|
|
52
|
+
password = getpass.getpass("Password: ")
|
|
53
|
+
client = ManagementClient(api_url=api_url)
|
|
54
|
+
try:
|
|
55
|
+
data = client.register(email, name, password)
|
|
56
|
+
# mint the first API key with the fresh session and store it
|
|
57
|
+
session = ManagementClient(api_url=api_url, token=data["token"])
|
|
58
|
+
key = session.create_api_key("default")["apiKey"]
|
|
59
|
+
except ApiError as exc:
|
|
60
|
+
_die(str(exc))
|
|
61
|
+
|
|
62
|
+
creds: Dict[str, Any] = {"api_key": key, "email": email}
|
|
63
|
+
if api_url:
|
|
64
|
+
creds["api_url"] = api_url
|
|
65
|
+
save_credentials(creds)
|
|
66
|
+
click.secho(f"account created for {email}", fg="green")
|
|
67
|
+
click.echo(f"API key (default): {key}")
|
|
68
|
+
click.secho("store it now — it cannot be retrieved again (saved to ~/.runbay)", fg="yellow")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@click.command(cls=_Cmd, hidden=True)
|
|
72
|
+
@click.option("--name", default="default", help="Label for the key")
|
|
73
|
+
@click.option("--save/--no-save", default=True, help="Store the key in ~/.runbay")
|
|
74
|
+
def apikey(name: str, save: bool) -> None:
|
|
75
|
+
"""Create a new API key."""
|
|
76
|
+
try:
|
|
77
|
+
data = _client().create_api_key(name)
|
|
78
|
+
except ApiError as exc:
|
|
79
|
+
_die(str(exc))
|
|
80
|
+
click.echo(f"API key ({name}): {data['apiKey']}")
|
|
81
|
+
click.secho("store it now — it cannot be retrieved again", fg="yellow")
|
|
82
|
+
if save:
|
|
83
|
+
save_credentials({"api_key": data["apiKey"]})
|
|
84
|
+
click.echo("saved to ~/.runbay/config.json (used for future CLI/SDK calls)")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@click.command(cls=_Cmd)
|
|
88
|
+
def whoami() -> None:
|
|
89
|
+
"""Show the logged-in account."""
|
|
90
|
+
try:
|
|
91
|
+
data = _client().me()
|
|
92
|
+
except ApiError as exc:
|
|
93
|
+
_die(str(exc))
|
|
94
|
+
_print_json(data)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Billing commands (hidden from `rb --help`): gpus, balance.
|
|
2
|
+
|
|
3
|
+
The catalog and usage live in the web console; these stay for power users.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ...api import ApiError
|
|
9
|
+
from ..utils.client import _client
|
|
10
|
+
from ..utils.output import _die, _format_bytes, _print_table
|
|
11
|
+
from ..utils.rich_help import RichCommand as _Cmd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command(cls=_Cmd, hidden=True)
|
|
15
|
+
def gpus() -> None:
|
|
16
|
+
"""List available compute types (GPU and CPU) and pricing."""
|
|
17
|
+
try:
|
|
18
|
+
catalog = _client().gpus()
|
|
19
|
+
except ApiError as exc:
|
|
20
|
+
_die(str(exc))
|
|
21
|
+
# group by kind (gpu first, then cpu) so the two sections read clearly
|
|
22
|
+
catalog.sort(key=lambda g: (0 if g.get("kind", "gpu") == "gpu" else 1, g["pricePerHour"]))
|
|
23
|
+
_print_table(
|
|
24
|
+
["TYPE", "KIND", "NAME", "MEMORY", "PRICE/HR"],
|
|
25
|
+
[
|
|
26
|
+
[g["id"], g.get("kind", "gpu"), g["name"], f"{g['memoryGb']} GB", f"${g['pricePerHour']:.2f}"]
|
|
27
|
+
for g in catalog
|
|
28
|
+
],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.command(cls=_Cmd, hidden=True)
|
|
33
|
+
@click.option("--limit", default=15, help="Usage records to show")
|
|
34
|
+
def balance(limit: int) -> None:
|
|
35
|
+
"""Show balance and recent GPU usage (billed per second)."""
|
|
36
|
+
try:
|
|
37
|
+
data = _client().usage(limit=limit)
|
|
38
|
+
except ApiError as exc:
|
|
39
|
+
_die(str(exc))
|
|
40
|
+
click.echo(f"balance: ${data['balance']:.4f}")
|
|
41
|
+
try:
|
|
42
|
+
storage = _client().storage()
|
|
43
|
+
gb = storage["totalBytes"] / (1024 ** 3)
|
|
44
|
+
click.echo(
|
|
45
|
+
f"storage: {gb:.2f} GB used ({storage['freeGb']} GB free tier, "
|
|
46
|
+
f"${storage['pricePerGbMonth']:.2f}/GB/mo -> est ${storage['estMonthlyUsd']:.2f}/mo)"
|
|
47
|
+
)
|
|
48
|
+
except ApiError:
|
|
49
|
+
pass
|
|
50
|
+
click.echo(f"total spent: ${data['totalSpent']:.4f} ({int(data['totalSeconds'])}s of GPU time)")
|
|
51
|
+
if data["usage"]:
|
|
52
|
+
click.echo("")
|
|
53
|
+
_print_table(
|
|
54
|
+
["WHEN", "KIND", "REF", "GPU", "SECONDS", "COST"],
|
|
55
|
+
[
|
|
56
|
+
[
|
|
57
|
+
str(u.get("startedAt", ""))[:19],
|
|
58
|
+
u.get("kind", ""),
|
|
59
|
+
u.get("refId", ""),
|
|
60
|
+
f"{u.get('gpuType', '')} x{u.get('gpuNum', 1)}",
|
|
61
|
+
str(int(u.get("seconds", 0))),
|
|
62
|
+
f"${u.get('amount', 0):.4f}",
|
|
63
|
+
]
|
|
64
|
+
for u in data["usage"][:limit]
|
|
65
|
+
],
|
|
66
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Deploy build utilities: handler scaffolding, source scanning, resource configs, manifest."""
|
|
2
|
+
|
|
3
|
+
from .handler_generator import generate_handler
|
|
4
|
+
from .manifest import finalize_manifest, RB_REQUIREMENTS_FILE, ARTIFACT_NAME
|
|
5
|
+
from .resource_config_generator import build_resource
|
|
6
|
+
from .scanner import discover, DiscoveredFn
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"generate_handler",
|
|
10
|
+
"finalize_manifest",
|
|
11
|
+
"build_resource",
|
|
12
|
+
"discover",
|
|
13
|
+
"DiscoveredFn",
|
|
14
|
+
"RB_REQUIREMENTS_FILE",
|
|
15
|
+
"ARTIFACT_NAME",
|
|
16
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Generate the starter `handler.py` for `rb init`."""
|
|
2
|
+
|
|
3
|
+
_HANDLER_TEMPLATE = '''\
|
|
4
|
+
from runbay import endpoint
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@endpoint(
|
|
8
|
+
name="{name}",
|
|
9
|
+
# gpu=GpuGroup.ADA_24, # GPU product id or alias (platform default if omitted)
|
|
10
|
+
workers=1, # max concurrent workers
|
|
11
|
+
idle_timeout=60, # seconds idle before scale-down (to zero)
|
|
12
|
+
dependencies=[], # pip packages installed on the worker
|
|
13
|
+
)
|
|
14
|
+
def {fn_name}(name="world"):
|
|
15
|
+
"""Runs on a serverless GPU worker. Return anything JSON-serializable."""
|
|
16
|
+
return {{"greeting": f"Hello, {{name}}!"}}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
# local test: python handler.py
|
|
21
|
+
# deploy: rb deploy
|
|
22
|
+
# remote call: {fn_name}("RunBay") (after deploy, from any script)
|
|
23
|
+
print({fn_name}.local(name="local"))
|
|
24
|
+
'''
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate_handler(name: str, fn_name: str) -> str:
|
|
28
|
+
"""Render the starter handler source for an endpoint named `name`."""
|
|
29
|
+
return _HANDLER_TEMPLATE.format(name=name, fn_name=fn_name)
|