boxd 0.1.0.dev2__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.
- boxd/__init__.py +117 -0
- boxd/_generated/__init__.py +0 -0
- boxd/_generated/api_pb2.py +222 -0
- boxd/_generated/api_pb2_grpc.py +1776 -0
- boxd/_sync.py +448 -0
- boxd/_utils.py +73 -0
- boxd/aio.py +96 -0
- boxd/auth.py +118 -0
- boxd/box.py +320 -0
- boxd/boxes.py +164 -0
- boxd/client.py +141 -0
- boxd/disks.py +111 -0
- boxd/domains.py +54 -0
- boxd/errors.py +62 -0
- boxd/exec.py +122 -0
- boxd/networks.py +43 -0
- boxd/templates.py +113 -0
- boxd/tokens.py +51 -0
- boxd/types.py +142 -0
- boxd-0.1.0.dev2.dist-info/METADATA +329 -0
- boxd-0.1.0.dev2.dist-info/RECORD +24 -0
- boxd-0.1.0.dev2.dist-info/WHEEL +5 -0
- boxd-0.1.0.dev2.dist-info/licenses/LICENSE +21 -0
- boxd-0.1.0.dev2.dist-info/top_level.txt +1 -0
boxd/_sync.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Synchronous wrappers for the boxd async API.
|
|
2
|
+
|
|
3
|
+
These thin wrappers run the async methods on a dedicated event loop,
|
|
4
|
+
providing a blocking API that doesn't require async/await.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from .exec import ExecResult
|
|
13
|
+
from .types import (
|
|
14
|
+
BoxConfig,
|
|
15
|
+
ConfigResult,
|
|
16
|
+
Domain,
|
|
17
|
+
Network,
|
|
18
|
+
Proxy,
|
|
19
|
+
ResumeResult,
|
|
20
|
+
SuspendResult,
|
|
21
|
+
Template,
|
|
22
|
+
Token,
|
|
23
|
+
TokenInfo,
|
|
24
|
+
WhoamiResult,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _SyncBase:
|
|
32
|
+
"""Mixin that holds a reference to a shared event loop."""
|
|
33
|
+
|
|
34
|
+
_loop: asyncio.AbstractEventLoop
|
|
35
|
+
|
|
36
|
+
def _run(self, coro):
|
|
37
|
+
return self._loop.run_until_complete(coro)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Box(_SyncBase):
|
|
41
|
+
"""A boxd VM (synchronous API).
|
|
42
|
+
|
|
43
|
+
Data attributes (id, name, image, public_ip, status, url, boot_time_ms)
|
|
44
|
+
are forwarded automatically from the underlying async Box.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, async_box, loop: asyncio.AbstractEventLoop) -> None:
|
|
48
|
+
from .box import Box as AsyncBox
|
|
49
|
+
|
|
50
|
+
self._async: AsyncBox = async_box
|
|
51
|
+
self._loop = loop
|
|
52
|
+
|
|
53
|
+
def __getattr__(self, name: str):
|
|
54
|
+
# Forward data attributes from the async Box dataclass
|
|
55
|
+
if name in ("id", "name", "image", "public_ip", "status", "url", "boot_time_ms"):
|
|
56
|
+
return getattr(self._async, name)
|
|
57
|
+
raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return (
|
|
61
|
+
f"Box(id={self.id!r}, name={self.name!r}, image={self.image!r}, "
|
|
62
|
+
f"public_ip={self.public_ip!r}, status={self.status!r})"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ── Lifecycle ────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def destroy(self) -> None:
|
|
68
|
+
"""Destroy this VM."""
|
|
69
|
+
self._run(self._async.destroy())
|
|
70
|
+
|
|
71
|
+
def reboot(self) -> None:
|
|
72
|
+
"""Reboot this VM."""
|
|
73
|
+
self._run(self._async.reboot())
|
|
74
|
+
|
|
75
|
+
def start(self) -> None:
|
|
76
|
+
"""Start this VM."""
|
|
77
|
+
self._run(self._async.start())
|
|
78
|
+
|
|
79
|
+
def stop(self) -> None:
|
|
80
|
+
"""Stop this VM."""
|
|
81
|
+
self._run(self._async.stop())
|
|
82
|
+
|
|
83
|
+
def suspend(self) -> SuspendResult:
|
|
84
|
+
"""Suspend this VM."""
|
|
85
|
+
return self._run(self._async.suspend())
|
|
86
|
+
|
|
87
|
+
def resume(self) -> ResumeResult:
|
|
88
|
+
"""Resume this VM from suspension."""
|
|
89
|
+
return self._run(self._async.resume())
|
|
90
|
+
|
|
91
|
+
# ── Exec ─────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def exec(
|
|
94
|
+
self,
|
|
95
|
+
*args: str,
|
|
96
|
+
stream: bool = False,
|
|
97
|
+
interactive: bool = False,
|
|
98
|
+
pty: bool = False,
|
|
99
|
+
text: bool = True,
|
|
100
|
+
timeout: float | None = None,
|
|
101
|
+
env: dict[str, str] | None = None,
|
|
102
|
+
) -> ExecResult | SyncExecProcess:
|
|
103
|
+
"""Execute a command in this VM."""
|
|
104
|
+
result = self._run(
|
|
105
|
+
self._async.exec(
|
|
106
|
+
*args,
|
|
107
|
+
stream=stream,
|
|
108
|
+
interactive=interactive,
|
|
109
|
+
pty=pty,
|
|
110
|
+
text=text,
|
|
111
|
+
timeout=timeout,
|
|
112
|
+
env=env,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
if isinstance(result, ExecResult):
|
|
116
|
+
return result
|
|
117
|
+
return SyncExecProcess(result, self._loop)
|
|
118
|
+
|
|
119
|
+
# ── Files ────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def write_file(self, source: str | bytes | Path, dest: str) -> None:
|
|
122
|
+
"""Write a file to the VM."""
|
|
123
|
+
self._run(self._async.write_file(source, dest))
|
|
124
|
+
|
|
125
|
+
def read_file(self, path: str) -> bytes:
|
|
126
|
+
"""Download a file from the VM."""
|
|
127
|
+
return self._run(self._async.read_file(path))
|
|
128
|
+
|
|
129
|
+
# ── Proxies ──────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def proxies(self) -> list[Proxy]:
|
|
132
|
+
"""List proxies for this VM."""
|
|
133
|
+
return self._run(self._async.proxies())
|
|
134
|
+
|
|
135
|
+
def create_proxy(self, name: str, port: int = 0) -> Proxy:
|
|
136
|
+
"""Create a new proxy subdomain for this VM."""
|
|
137
|
+
return self._run(self._async.create_proxy(name, port))
|
|
138
|
+
|
|
139
|
+
def delete_proxy(self, name: str) -> None:
|
|
140
|
+
"""Delete a proxy subdomain."""
|
|
141
|
+
self._run(self._async.delete_proxy(name))
|
|
142
|
+
|
|
143
|
+
def set_proxy_port(self, port: int, name: str = "") -> None:
|
|
144
|
+
"""Change the port for a proxy."""
|
|
145
|
+
self._run(self._async.set_proxy_port(port, name))
|
|
146
|
+
|
|
147
|
+
# ── Logs ─────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
def stream_logs(self, follow: bool = False):
|
|
150
|
+
"""Stream console log chunks from the VM.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
follow: If True, keep the stream open for new chunks.
|
|
154
|
+
|
|
155
|
+
Yields:
|
|
156
|
+
``bytes`` chunks of raw log data.
|
|
157
|
+
"""
|
|
158
|
+
agen = self._async.stream_logs(follow=follow)
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
yield self._run(agen.__anext__())
|
|
162
|
+
except StopAsyncIteration:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class SyncExecProcess(_SyncBase):
|
|
167
|
+
"""Synchronous handle to a streaming exec process."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, async_process, loop: asyncio.AbstractEventLoop) -> None:
|
|
170
|
+
from .exec import ExecProcess as AsyncExecProcess
|
|
171
|
+
|
|
172
|
+
self._async: AsyncExecProcess = async_process
|
|
173
|
+
self._loop = loop
|
|
174
|
+
|
|
175
|
+
def wait(self) -> int:
|
|
176
|
+
"""Wait for the process to exit and return the exit code."""
|
|
177
|
+
return self._run(self._async.wait())
|
|
178
|
+
|
|
179
|
+
def close(self) -> None:
|
|
180
|
+
"""Cancel the relay task and close stdin."""
|
|
181
|
+
self._async.close()
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def stdin(self):
|
|
185
|
+
"""Access the stdin writer (write/write_eof are sync)."""
|
|
186
|
+
return self._async.stdin
|
|
187
|
+
|
|
188
|
+
def read_stdout(self) -> bytes:
|
|
189
|
+
"""Read the next stdout chunk, or empty bytes on EOF."""
|
|
190
|
+
return self._run(self._async.stdout.read())
|
|
191
|
+
|
|
192
|
+
def iter_stdout(self):
|
|
193
|
+
"""Iterate over stdout chunks until EOF."""
|
|
194
|
+
while True:
|
|
195
|
+
chunk = self.read_stdout()
|
|
196
|
+
if not chunk:
|
|
197
|
+
break
|
|
198
|
+
yield chunk
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class BoxService(_SyncBase):
|
|
202
|
+
"""Create, list, get, and fork VMs (synchronous API)."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
|
|
205
|
+
from .boxes import BoxService as AsyncBoxService
|
|
206
|
+
|
|
207
|
+
self._async: AsyncBoxService = async_service
|
|
208
|
+
self._loop = loop
|
|
209
|
+
|
|
210
|
+
def _wrap(self, async_box) -> Box:
|
|
211
|
+
return Box(async_box, self._loop)
|
|
212
|
+
|
|
213
|
+
def create(
|
|
214
|
+
self,
|
|
215
|
+
name: str = "",
|
|
216
|
+
image: str = "",
|
|
217
|
+
config: BoxConfig | None = None,
|
|
218
|
+
) -> Box:
|
|
219
|
+
"""Create a new VM."""
|
|
220
|
+
return self._wrap(self._run(self._async.create(name, image, config)))
|
|
221
|
+
|
|
222
|
+
def list(self) -> list[Box]:
|
|
223
|
+
"""List all VMs."""
|
|
224
|
+
return [self._wrap(b) for b in self._run(self._async.list())]
|
|
225
|
+
|
|
226
|
+
def get(self, vm_id: str) -> Box:
|
|
227
|
+
"""Get a VM by ID or name."""
|
|
228
|
+
return self._wrap(self._run(self._async.get(vm_id)))
|
|
229
|
+
|
|
230
|
+
def fork(
|
|
231
|
+
self,
|
|
232
|
+
source: str,
|
|
233
|
+
name: str | None = None,
|
|
234
|
+
config: BoxConfig | None = None,
|
|
235
|
+
) -> Box:
|
|
236
|
+
"""Fork (clone) a VM."""
|
|
237
|
+
return self._wrap(self._run(self._async.fork(source, name, config)))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class TemplateService(_SyncBase):
|
|
241
|
+
"""Manage VM templates (synchronous API)."""
|
|
242
|
+
|
|
243
|
+
def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
|
|
244
|
+
from .templates import TemplateService as AsyncTemplateService
|
|
245
|
+
|
|
246
|
+
self._async: AsyncTemplateService = async_service
|
|
247
|
+
self._loop = loop
|
|
248
|
+
|
|
249
|
+
def create(
|
|
250
|
+
self,
|
|
251
|
+
name: str,
|
|
252
|
+
image: str = "",
|
|
253
|
+
config: BoxConfig | None = None,
|
|
254
|
+
) -> Template:
|
|
255
|
+
"""Create a new VM template."""
|
|
256
|
+
return self._run(self._async.create(name, image, config))
|
|
257
|
+
|
|
258
|
+
def list(self) -> list[Template]:
|
|
259
|
+
"""List all templates."""
|
|
260
|
+
return self._run(self._async.list())
|
|
261
|
+
|
|
262
|
+
def delete(self, template_id: str) -> None:
|
|
263
|
+
"""Delete a template by ID."""
|
|
264
|
+
self._run(self._async.delete(template_id))
|
|
265
|
+
|
|
266
|
+
def create_vm(
|
|
267
|
+
self,
|
|
268
|
+
template: Template | str,
|
|
269
|
+
name: str = "",
|
|
270
|
+
config: BoxConfig | None = None,
|
|
271
|
+
) -> Box:
|
|
272
|
+
"""Create a VM from a template."""
|
|
273
|
+
return Box(self._run(self._async.create_vm(template, name, config)), self._loop)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class DiskHandle(_SyncBase):
|
|
277
|
+
"""A handle to a persistent disk (synchronous API).
|
|
278
|
+
|
|
279
|
+
Data attributes (id, name, size_bytes, status) are forwarded
|
|
280
|
+
automatically from the underlying async DiskHandle.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def __init__(self, async_handle, loop: asyncio.AbstractEventLoop) -> None:
|
|
284
|
+
from .disks import DiskHandle as AsyncDiskHandle
|
|
285
|
+
|
|
286
|
+
self._async: AsyncDiskHandle = async_handle
|
|
287
|
+
self._loop = loop
|
|
288
|
+
|
|
289
|
+
def __getattr__(self, name: str):
|
|
290
|
+
if name in ("id", "name", "size_bytes", "status"):
|
|
291
|
+
return getattr(self._async, name)
|
|
292
|
+
raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
|
|
293
|
+
|
|
294
|
+
def __repr__(self) -> str:
|
|
295
|
+
return (
|
|
296
|
+
f"DiskHandle(id={self.id!r}, name={self.name!r}, "
|
|
297
|
+
f"size_bytes={self.size_bytes}, status={self.status!r})"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def attach(self, box: Box | str, mount_path: str, read_only: bool = False) -> None:
|
|
301
|
+
"""Attach this disk to a VM."""
|
|
302
|
+
target = box._async if isinstance(box, Box) else box
|
|
303
|
+
self._run(self._async.attach(target, mount_path, read_only))
|
|
304
|
+
|
|
305
|
+
def detach(self, box: Box | str) -> None:
|
|
306
|
+
"""Detach this disk from a VM."""
|
|
307
|
+
target = box._async if isinstance(box, Box) else box
|
|
308
|
+
self._run(self._async.detach(target))
|
|
309
|
+
|
|
310
|
+
def destroy(self) -> None:
|
|
311
|
+
"""Permanently destroy this disk."""
|
|
312
|
+
self._run(self._async.destroy())
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class DiskService(_SyncBase):
|
|
316
|
+
"""Manage persistent disks (synchronous API)."""
|
|
317
|
+
|
|
318
|
+
def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
|
|
319
|
+
from .disks import DiskService as AsyncDiskService
|
|
320
|
+
|
|
321
|
+
self._async: AsyncDiskService = async_service
|
|
322
|
+
self._loop = loop
|
|
323
|
+
|
|
324
|
+
def create(self, name: str, size: str) -> DiskHandle:
|
|
325
|
+
"""Create a new persistent disk."""
|
|
326
|
+
return DiskHandle(self._run(self._async.create(name, size)), self._loop)
|
|
327
|
+
|
|
328
|
+
def list(self) -> list[DiskHandle]:
|
|
329
|
+
"""List all disks."""
|
|
330
|
+
return [DiskHandle(h, self._loop) for h in self._run(self._async.list())]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class DomainService(_SyncBase):
|
|
334
|
+
"""Manage external domains bound to VMs (synchronous API)."""
|
|
335
|
+
|
|
336
|
+
def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
|
|
337
|
+
from .domains import DomainService as AsyncDomainService
|
|
338
|
+
|
|
339
|
+
self._async: AsyncDomainService = async_service
|
|
340
|
+
self._loop = loop
|
|
341
|
+
|
|
342
|
+
def bind(self, domain: str, vm: Box | str) -> None:
|
|
343
|
+
"""Bind an external domain to a VM."""
|
|
344
|
+
target = vm._async if isinstance(vm, Box) else vm
|
|
345
|
+
self._run(self._async.bind(domain, target))
|
|
346
|
+
|
|
347
|
+
def unbind(self, domain: str) -> None:
|
|
348
|
+
"""Remove the binding for an external domain."""
|
|
349
|
+
self._run(self._async.unbind(domain))
|
|
350
|
+
|
|
351
|
+
def list(self) -> list[Domain]:
|
|
352
|
+
"""List bound domains."""
|
|
353
|
+
return self._run(self._async.list())
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class NetworkService(_SyncBase):
|
|
357
|
+
"""Manage user networks (synchronous API)."""
|
|
358
|
+
|
|
359
|
+
def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
|
|
360
|
+
from .networks import NetworkService as AsyncNetworkService
|
|
361
|
+
|
|
362
|
+
self._async: AsyncNetworkService = async_service
|
|
363
|
+
self._loop = loop
|
|
364
|
+
|
|
365
|
+
def create(self, name: str = "") -> Network:
|
|
366
|
+
"""Create a new network."""
|
|
367
|
+
return self._run(self._async.create(name))
|
|
368
|
+
|
|
369
|
+
def list(self) -> list[Network]:
|
|
370
|
+
"""List networks."""
|
|
371
|
+
return self._run(self._async.list())
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class TokenService(_SyncBase):
|
|
375
|
+
"""Manage scoped JWT tokens (synchronous API)."""
|
|
376
|
+
|
|
377
|
+
def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
|
|
378
|
+
from .tokens import TokenService as AsyncTokenService
|
|
379
|
+
|
|
380
|
+
self._async: AsyncTokenService = async_service
|
|
381
|
+
self._loop = loop
|
|
382
|
+
|
|
383
|
+
def create(self, expires_in: int = 0) -> Token:
|
|
384
|
+
"""Create a scoped JWT."""
|
|
385
|
+
return self._run(self._async.create(expires_in))
|
|
386
|
+
|
|
387
|
+
def list(self) -> list[TokenInfo]:
|
|
388
|
+
"""List token metadata."""
|
|
389
|
+
return self._run(self._async.list())
|
|
390
|
+
|
|
391
|
+
def revoke(self, jti: str) -> None:
|
|
392
|
+
"""Revoke a token by JTI."""
|
|
393
|
+
self._run(self._async.revoke(jti))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class Compute(_SyncBase):
|
|
397
|
+
"""Main client for the boxd API (synchronous API).
|
|
398
|
+
|
|
399
|
+
Usage::
|
|
400
|
+
|
|
401
|
+
with Compute(api_key="bxk_...") as c:
|
|
402
|
+
box = c.box.create("my-vm")
|
|
403
|
+
result = box.exec("echo", "hello")
|
|
404
|
+
print(result.stdout)
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
def __init__(
|
|
408
|
+
self,
|
|
409
|
+
*,
|
|
410
|
+
api_key: str | None = None,
|
|
411
|
+
token: str | None = None,
|
|
412
|
+
api_url: str | None = None,
|
|
413
|
+
exchange_url: str | None = None,
|
|
414
|
+
) -> None:
|
|
415
|
+
from .client import Compute as AsyncCompute
|
|
416
|
+
|
|
417
|
+
self._loop = asyncio.new_event_loop()
|
|
418
|
+
self._async = AsyncCompute(
|
|
419
|
+
api_key=api_key,
|
|
420
|
+
token=token,
|
|
421
|
+
api_url=api_url,
|
|
422
|
+
exchange_url=exchange_url,
|
|
423
|
+
)
|
|
424
|
+
self.box = BoxService(self._async.box, self._loop)
|
|
425
|
+
self.template = TemplateService(self._async.template, self._loop)
|
|
426
|
+
self.disk = DiskService(self._async.disk, self._loop)
|
|
427
|
+
self.domain = DomainService(self._async.domain, self._loop)
|
|
428
|
+
self.network = NetworkService(self._async.network, self._loop)
|
|
429
|
+
self.token = TokenService(self._async.token, self._loop)
|
|
430
|
+
|
|
431
|
+
def whoami(self) -> WhoamiResult:
|
|
432
|
+
"""Return information about the authenticated user."""
|
|
433
|
+
return self._run(self._async.whoami())
|
|
434
|
+
|
|
435
|
+
def config(self) -> ConfigResult:
|
|
436
|
+
"""Return server configuration."""
|
|
437
|
+
return self._run(self._async.config())
|
|
438
|
+
|
|
439
|
+
def close(self) -> None:
|
|
440
|
+
"""Close the gRPC channel and event loop."""
|
|
441
|
+
self._run(self._async.close())
|
|
442
|
+
self._loop.close()
|
|
443
|
+
|
|
444
|
+
def __enter__(self) -> Compute:
|
|
445
|
+
return self
|
|
446
|
+
|
|
447
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
448
|
+
self.close()
|
boxd/_utils.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Internal helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import NamedTuple, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import grpc.aio
|
|
9
|
+
|
|
10
|
+
from .errors import from_grpc_error
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .client import Compute
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Endpoint(NamedTuple):
|
|
17
|
+
"""Resolved gRPC endpoint: bare ``host:port`` and whether to use TLS."""
|
|
18
|
+
|
|
19
|
+
host: str
|
|
20
|
+
use_tls: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_endpoint(url: str) -> Endpoint:
|
|
24
|
+
"""Resolve an api_url into a ``(host, use_tls)`` pair.
|
|
25
|
+
|
|
26
|
+
- ``http://...`` → plaintext, scheme stripped
|
|
27
|
+
- ``https://...`` → TLS, scheme stripped
|
|
28
|
+
- bare ``host:port`` → TLS, except ``localhost``/``127.*`` which stay plaintext
|
|
29
|
+
|
|
30
|
+
Production gRPC currently lives at ``boxd.sh:9443`` over plain HTTP/2, so the
|
|
31
|
+
package default uses an explicit ``http://`` scheme. Self-hosted/dev clusters
|
|
32
|
+
can pass ``api_url="http://my-cluster:9443"`` to opt into plaintext.
|
|
33
|
+
"""
|
|
34
|
+
if url.startswith("http://"):
|
|
35
|
+
return Endpoint(host=url[len("http://"):], use_tls=False)
|
|
36
|
+
if url.startswith("https://"):
|
|
37
|
+
return Endpoint(host=url[len("https://"):], use_tls=True)
|
|
38
|
+
is_local = url.startswith("localhost") or url.startswith("127.")
|
|
39
|
+
return Endpoint(host=url, use_tls=not is_local)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_size(s: str) -> int:
|
|
43
|
+
"""Parse a human-readable size string to bytes.
|
|
44
|
+
|
|
45
|
+
Examples: "8G" -> 8589934592, "512M" -> 536870912, "100G" -> 107374182400
|
|
46
|
+
"""
|
|
47
|
+
if not s:
|
|
48
|
+
return 0
|
|
49
|
+
m = re.match(r"^(\d+)\s*([KMGT]?)i?[Bb]?$", s.strip(), re.IGNORECASE)
|
|
50
|
+
if not m:
|
|
51
|
+
raise ValueError(f"invalid size: {s!r}")
|
|
52
|
+
num = int(m.group(1))
|
|
53
|
+
unit = m.group(2).upper()
|
|
54
|
+
multipliers = {"": 1, "K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}
|
|
55
|
+
return num * multipliers[unit]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GrpcCaller:
|
|
59
|
+
"""Mixin for classes that call gRPC methods through a Compute client.
|
|
60
|
+
|
|
61
|
+
Subclasses must have a ``_client`` attribute of type :class:`Compute`.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
_client: Compute
|
|
65
|
+
|
|
66
|
+
async def _call(self, method_name: str, request):
|
|
67
|
+
"""Call a gRPC method on the stub, converting errors."""
|
|
68
|
+
stub = await self._client._ensure_channel()
|
|
69
|
+
method = getattr(stub, method_name)
|
|
70
|
+
try:
|
|
71
|
+
return await method(request)
|
|
72
|
+
except grpc.aio.AioRpcError as e:
|
|
73
|
+
raise from_grpc_error(e) from e
|
boxd/aio.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Async API for the boxd SDK.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from boxd.aio import Compute
|
|
6
|
+
|
|
7
|
+
async with Compute(api_key="bxk_...") as c:
|
|
8
|
+
box = await c.box.create("my-vm")
|
|
9
|
+
result = await box.exec("echo", "hello")
|
|
10
|
+
print(result.stdout)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# Re-export async classes under their original names
|
|
14
|
+
from .client import Compute
|
|
15
|
+
from .box import Box
|
|
16
|
+
from .exec import ExecResult, ExecProcess, StreamReader, StreamWriter
|
|
17
|
+
from .boxes import BoxService
|
|
18
|
+
from .templates import TemplateService
|
|
19
|
+
from .disks import DiskService, DiskHandle
|
|
20
|
+
from .domains import DomainService
|
|
21
|
+
from .networks import NetworkService
|
|
22
|
+
from .tokens import TokenService
|
|
23
|
+
|
|
24
|
+
# Types and errors are shared — re-export for convenience
|
|
25
|
+
from .types import (
|
|
26
|
+
BoxConfig,
|
|
27
|
+
ConfigResult,
|
|
28
|
+
Disk,
|
|
29
|
+
DiskAttachment,
|
|
30
|
+
Domain,
|
|
31
|
+
LifecycleConfig,
|
|
32
|
+
Network,
|
|
33
|
+
NetworkConfig,
|
|
34
|
+
Proxy,
|
|
35
|
+
ProxyEntry,
|
|
36
|
+
PtyInfo,
|
|
37
|
+
ResumeResult,
|
|
38
|
+
SuspendResult,
|
|
39
|
+
Template,
|
|
40
|
+
Token,
|
|
41
|
+
TokenInfo,
|
|
42
|
+
VolumeMount,
|
|
43
|
+
WhoamiResult,
|
|
44
|
+
)
|
|
45
|
+
from .errors import (
|
|
46
|
+
BoxdError,
|
|
47
|
+
AuthenticationError,
|
|
48
|
+
NotFoundError,
|
|
49
|
+
QuotaExceededError,
|
|
50
|
+
InvalidArgumentError,
|
|
51
|
+
InternalError,
|
|
52
|
+
TimeoutError,
|
|
53
|
+
ConnectionError,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"Compute",
|
|
58
|
+
"Box",
|
|
59
|
+
"BoxService",
|
|
60
|
+
"ExecResult",
|
|
61
|
+
"ExecProcess",
|
|
62
|
+
"StreamReader",
|
|
63
|
+
"StreamWriter",
|
|
64
|
+
"TemplateService",
|
|
65
|
+
"DiskService",
|
|
66
|
+
"DiskHandle",
|
|
67
|
+
"DomainService",
|
|
68
|
+
"NetworkService",
|
|
69
|
+
"TokenService",
|
|
70
|
+
"BoxConfig",
|
|
71
|
+
"LifecycleConfig",
|
|
72
|
+
"NetworkConfig",
|
|
73
|
+
"ProxyEntry",
|
|
74
|
+
"VolumeMount",
|
|
75
|
+
"Proxy",
|
|
76
|
+
"Template",
|
|
77
|
+
"Disk",
|
|
78
|
+
"DiskAttachment",
|
|
79
|
+
"Domain",
|
|
80
|
+
"Network",
|
|
81
|
+
"Token",
|
|
82
|
+
"TokenInfo",
|
|
83
|
+
"SuspendResult",
|
|
84
|
+
"ResumeResult",
|
|
85
|
+
"PtyInfo",
|
|
86
|
+
"WhoamiResult",
|
|
87
|
+
"ConfigResult",
|
|
88
|
+
"BoxdError",
|
|
89
|
+
"AuthenticationError",
|
|
90
|
+
"NotFoundError",
|
|
91
|
+
"QuotaExceededError",
|
|
92
|
+
"InvalidArgumentError",
|
|
93
|
+
"InternalError",
|
|
94
|
+
"TimeoutError",
|
|
95
|
+
"ConnectionError",
|
|
96
|
+
]
|