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/auth.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""API key authentication — exchange and JWT refresh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import grpc
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import AuthenticationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokenAuth:
|
|
14
|
+
"""Manages API key → JWT exchange and transparent refresh."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
api_key: str | None = None,
|
|
20
|
+
token: str | None = None,
|
|
21
|
+
exchange_url: str = "https://boxd.sh/api/v1/auth/token",
|
|
22
|
+
):
|
|
23
|
+
self._api_key = api_key
|
|
24
|
+
self._token = token
|
|
25
|
+
self._expires_at: float = 0
|
|
26
|
+
self._exchange_url = exchange_url
|
|
27
|
+
|
|
28
|
+
if not api_key and not token:
|
|
29
|
+
raise AuthenticationError("provide api_key or token")
|
|
30
|
+
|
|
31
|
+
async def get_token(self) -> str:
|
|
32
|
+
"""Return a valid JWT, exchanging/refreshing if needed."""
|
|
33
|
+
if self._token and not self._api_key:
|
|
34
|
+
# Direct token mode — no refresh
|
|
35
|
+
return self._token
|
|
36
|
+
|
|
37
|
+
# API key mode — exchange or refresh
|
|
38
|
+
if not self._token or time.time() > self._expires_at - 300:
|
|
39
|
+
await self._exchange()
|
|
40
|
+
|
|
41
|
+
assert self._token is not None # set by _exchange()
|
|
42
|
+
return self._token
|
|
43
|
+
|
|
44
|
+
async def _exchange(self) -> None:
|
|
45
|
+
"""Exchange API key for a short-lived JWT."""
|
|
46
|
+
async with httpx.AsyncClient() as client:
|
|
47
|
+
resp = await client.post(
|
|
48
|
+
self._exchange_url,
|
|
49
|
+
json={"api_key": self._api_key},
|
|
50
|
+
timeout=10,
|
|
51
|
+
)
|
|
52
|
+
if resp.status_code == 401:
|
|
53
|
+
raise AuthenticationError("invalid or expired API key")
|
|
54
|
+
if resp.status_code == 429:
|
|
55
|
+
raise AuthenticationError("rate limit exceeded — try again later")
|
|
56
|
+
if resp.status_code != 200:
|
|
57
|
+
raise AuthenticationError(f"exchange failed: {resp.status_code} {resp.text}")
|
|
58
|
+
|
|
59
|
+
data = resp.json()
|
|
60
|
+
self._token = data["token"]
|
|
61
|
+
self._expires_at = data["expires_at"]
|
|
62
|
+
|
|
63
|
+
async def metadata(self) -> list[tuple[str, str]]:
|
|
64
|
+
"""Return auth metadata for manual injection (streaming calls)."""
|
|
65
|
+
token = await self.get_token()
|
|
66
|
+
return [("authorization", f"Bearer {token}")]
|
|
67
|
+
|
|
68
|
+
def interceptor(self) -> "AuthInterceptor":
|
|
69
|
+
"""Return a gRPC interceptor that injects the Bearer token."""
|
|
70
|
+
return AuthInterceptor(self)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AuthInterceptor(
|
|
74
|
+
grpc.aio.UnaryUnaryClientInterceptor,
|
|
75
|
+
grpc.aio.UnaryStreamClientInterceptor,
|
|
76
|
+
grpc.aio.StreamUnaryClientInterceptor,
|
|
77
|
+
grpc.aio.StreamStreamClientInterceptor,
|
|
78
|
+
):
|
|
79
|
+
"""gRPC interceptor that injects Bearer token into metadata."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, auth: TokenAuth):
|
|
82
|
+
self._auth = auth
|
|
83
|
+
|
|
84
|
+
async def _add_auth(self, client_call_details):
|
|
85
|
+
token = await self._auth.get_token()
|
|
86
|
+
metadata = list(client_call_details.metadata or [])
|
|
87
|
+
metadata.append(("authorization", f"Bearer {token}"))
|
|
88
|
+
return client_call_details._replace(metadata=metadata)
|
|
89
|
+
|
|
90
|
+
async def _intercept(self, continuation, client_call_details, request_or_iterator):
|
|
91
|
+
try:
|
|
92
|
+
new_details = await self._add_auth(client_call_details)
|
|
93
|
+
except AuthenticationError:
|
|
94
|
+
raise _abort_unauthenticated()
|
|
95
|
+
return await continuation(new_details, request_or_iterator)
|
|
96
|
+
|
|
97
|
+
async def intercept_unary_unary(self, continuation, client_call_details, request):
|
|
98
|
+
return await self._intercept(continuation, client_call_details, request)
|
|
99
|
+
|
|
100
|
+
async def intercept_unary_stream(self, continuation, client_call_details, request):
|
|
101
|
+
return await self._intercept(continuation, client_call_details, request)
|
|
102
|
+
|
|
103
|
+
async def intercept_stream_unary(self, continuation, client_call_details, request_iterator):
|
|
104
|
+
return await self._intercept(continuation, client_call_details, request_iterator)
|
|
105
|
+
|
|
106
|
+
async def intercept_stream_stream(self, continuation, client_call_details, request_iterator):
|
|
107
|
+
return await self._intercept(continuation, client_call_details, request_iterator)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _abort_unauthenticated() -> grpc.aio.AioRpcError:
|
|
111
|
+
"""Create a proper gRPC UNAUTHENTICATED error for clean interceptor shutdown."""
|
|
112
|
+
return grpc.aio.AioRpcError(
|
|
113
|
+
code=grpc.StatusCode.UNAUTHENTICATED,
|
|
114
|
+
initial_metadata=grpc.aio.Metadata(),
|
|
115
|
+
trailing_metadata=grpc.aio.Metadata(),
|
|
116
|
+
details="authentication failed",
|
|
117
|
+
debug_error_string=None,
|
|
118
|
+
)
|
boxd/box.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Box — a running VM with lifecycle, exec, file, and proxy methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import grpc.aio
|
|
11
|
+
|
|
12
|
+
from ._generated import api_pb2
|
|
13
|
+
from ._utils import GrpcCaller
|
|
14
|
+
from .errors import from_grpc_error
|
|
15
|
+
from .exec import ExecProcess, ExecResult, StreamReader, StreamWriter
|
|
16
|
+
from .types import Proxy, ResumeResult, SuspendResult
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .client import Compute
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Box(GrpcCaller):
|
|
24
|
+
"""A boxd VM."""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
name: str
|
|
28
|
+
image: str
|
|
29
|
+
public_ip: str
|
|
30
|
+
status: str
|
|
31
|
+
_client: Compute = field(repr=False)
|
|
32
|
+
url: str = ""
|
|
33
|
+
boot_time_ms: int | None = None
|
|
34
|
+
#: Source VM id, set when this VM was created via ``compute.box.fork(...)``.
|
|
35
|
+
forked_from: str | None = None
|
|
36
|
+
#: Restart policy, populated by ``compute.box.get(...)`` (server doesn't return it on create/fork).
|
|
37
|
+
restart_policy: str | None = None
|
|
38
|
+
#: Disk size in bytes, populated by ``compute.box.get(...)``.
|
|
39
|
+
disk_bytes: int | None = None
|
|
40
|
+
#: Auto-suspend timeout in seconds (0 = disabled), populated by ``compute.box.get(...)``.
|
|
41
|
+
auto_suspend_timeout_secs: int | None = None
|
|
42
|
+
|
|
43
|
+
# ── Lifecycle ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
async def destroy(self) -> None:
|
|
46
|
+
"""Destroy this VM."""
|
|
47
|
+
await self._call("DestroyVm", api_pb2.DestroyVmRequest(vm_id=self.id))
|
|
48
|
+
self.status = "destroyed"
|
|
49
|
+
|
|
50
|
+
async def reboot(self) -> None:
|
|
51
|
+
"""Reboot this VM."""
|
|
52
|
+
await self._call("RebootVm", api_pb2.RebootVmRequest(vm_id=self.id))
|
|
53
|
+
self.status = "running"
|
|
54
|
+
|
|
55
|
+
async def start(self) -> None:
|
|
56
|
+
"""Start this VM."""
|
|
57
|
+
await self._call("StartVm", api_pb2.StartVmRequest(vm_id=self.id))
|
|
58
|
+
self.status = "running"
|
|
59
|
+
|
|
60
|
+
async def stop(self) -> None:
|
|
61
|
+
"""Stop this VM."""
|
|
62
|
+
await self._call("StopVm", api_pb2.StopVmRequest(vm_id=self.id))
|
|
63
|
+
self.status = "stopped"
|
|
64
|
+
|
|
65
|
+
async def suspend(self) -> SuspendResult:
|
|
66
|
+
"""Suspend this VM."""
|
|
67
|
+
resp = await self._call("SuspendVm", api_pb2.SuspendVmRequest(vm_id=self.id))
|
|
68
|
+
self.status = "suspended"
|
|
69
|
+
return SuspendResult(suspend_us=resp.suspend_us)
|
|
70
|
+
|
|
71
|
+
async def resume(self) -> ResumeResult:
|
|
72
|
+
"""Resume this VM from suspension."""
|
|
73
|
+
resp = await self._call("ResumeVm", api_pb2.ResumeVmRequest(vm_id=self.id))
|
|
74
|
+
self.status = "running"
|
|
75
|
+
return ResumeResult(resume_us=resp.resume_us)
|
|
76
|
+
|
|
77
|
+
# ── Exec ──────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async def exec(
|
|
80
|
+
self,
|
|
81
|
+
*args: str,
|
|
82
|
+
stream: bool = False,
|
|
83
|
+
interactive: bool = False,
|
|
84
|
+
pty: bool = False,
|
|
85
|
+
text: bool = True,
|
|
86
|
+
timeout: float | None = None,
|
|
87
|
+
env: dict[str, str] | None = None,
|
|
88
|
+
) -> ExecResult | ExecProcess:
|
|
89
|
+
"""Execute a command in this VM.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
*args: Command and arguments (e.g. ``"ls", "-la"``).
|
|
93
|
+
stream: If True, return an :class:`ExecProcess` for streaming I/O.
|
|
94
|
+
interactive: Enable interactive mode (implies pty).
|
|
95
|
+
pty: Allocate a pseudo-terminal.
|
|
96
|
+
text: (unused, reserved for future use).
|
|
97
|
+
timeout: Timeout in seconds for non-streaming mode.
|
|
98
|
+
env: Extra environment variables for the command.
|
|
99
|
+
"""
|
|
100
|
+
use_tty = pty or interactive
|
|
101
|
+
init_chunk = self._build_exec_chunk(list(args), use_tty, env)
|
|
102
|
+
|
|
103
|
+
if stream:
|
|
104
|
+
return await self._exec_stream(init_chunk)
|
|
105
|
+
return await self._exec_simple(init_chunk, timeout)
|
|
106
|
+
|
|
107
|
+
def _build_exec_chunk(
|
|
108
|
+
self,
|
|
109
|
+
command: list[str],
|
|
110
|
+
tty: bool,
|
|
111
|
+
env: dict[str, str] | None,
|
|
112
|
+
) -> api_pb2.ExecChunk:
|
|
113
|
+
"""Build the initial ExecChunk for a command."""
|
|
114
|
+
import shlex
|
|
115
|
+
|
|
116
|
+
cmd_str = " ".join(shlex.quote(arg) for arg in command)
|
|
117
|
+
if env:
|
|
118
|
+
env_prefix = " ".join(
|
|
119
|
+
f"{k}={shlex.quote(v)}" for k, v in env.items()
|
|
120
|
+
)
|
|
121
|
+
cmd_str = f"{env_prefix} {cmd_str}"
|
|
122
|
+
return api_pb2.ExecChunk(vm_id=self.id, command=cmd_str, tty=tty)
|
|
123
|
+
|
|
124
|
+
async def _exec_simple(
|
|
125
|
+
self,
|
|
126
|
+
init_chunk: api_pb2.ExecChunk,
|
|
127
|
+
timeout: float | None,
|
|
128
|
+
) -> ExecResult:
|
|
129
|
+
"""Run a command and collect all output."""
|
|
130
|
+
stub = await self._client._ensure_channel()
|
|
131
|
+
|
|
132
|
+
async def request_iter():
|
|
133
|
+
yield init_chunk
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
metadata = await self._client._auth.metadata()
|
|
137
|
+
call = stub.Exec(request_iter(), timeout=timeout, metadata=metadata)
|
|
138
|
+
stdout_parts: list[bytes] = []
|
|
139
|
+
exit_code = -1
|
|
140
|
+
|
|
141
|
+
async for chunk in call:
|
|
142
|
+
if chunk.data:
|
|
143
|
+
stdout_parts.append(chunk.data)
|
|
144
|
+
exit_code = chunk.exit_code
|
|
145
|
+
|
|
146
|
+
raw = b"".join(stdout_parts)
|
|
147
|
+
return ExecResult(
|
|
148
|
+
stdout=raw.decode("utf-8", errors="replace"),
|
|
149
|
+
stderr="",
|
|
150
|
+
exit_code=exit_code,
|
|
151
|
+
)
|
|
152
|
+
except grpc.aio.AioRpcError as e:
|
|
153
|
+
raise from_grpc_error(e) from e
|
|
154
|
+
|
|
155
|
+
async def _exec_stream(
|
|
156
|
+
self,
|
|
157
|
+
init_chunk: api_pb2.ExecChunk,
|
|
158
|
+
) -> ExecProcess:
|
|
159
|
+
"""Run a command with streaming I/O."""
|
|
160
|
+
stub = await self._client._ensure_channel()
|
|
161
|
+
|
|
162
|
+
stdout_reader = StreamReader()
|
|
163
|
+
stderr_reader = StreamReader()
|
|
164
|
+
stdin_writer = StreamWriter()
|
|
165
|
+
|
|
166
|
+
process = ExecProcess(
|
|
167
|
+
stdout=stdout_reader,
|
|
168
|
+
stderr=stderr_reader,
|
|
169
|
+
stdin=stdin_writer,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def request_iter():
|
|
173
|
+
yield init_chunk
|
|
174
|
+
while True:
|
|
175
|
+
data = await stdin_writer._get()
|
|
176
|
+
stdin_writer._task_done()
|
|
177
|
+
if data is None:
|
|
178
|
+
break
|
|
179
|
+
yield api_pb2.ExecChunk(data=data, stdin=True)
|
|
180
|
+
|
|
181
|
+
async def relay(call):
|
|
182
|
+
exit_code = -1
|
|
183
|
+
try:
|
|
184
|
+
async for chunk in call:
|
|
185
|
+
if chunk.data:
|
|
186
|
+
stdout_reader._feed(chunk.data)
|
|
187
|
+
exit_code = chunk.exit_code
|
|
188
|
+
except grpc.aio.AioRpcError as e:
|
|
189
|
+
process._set_error(from_grpc_error(e))
|
|
190
|
+
else:
|
|
191
|
+
process._set_exit_code(exit_code)
|
|
192
|
+
finally:
|
|
193
|
+
stdout_reader._feed_eof()
|
|
194
|
+
stderr_reader._feed_eof()
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
metadata = await self._client._auth.metadata()
|
|
198
|
+
call = stub.Exec(request_iter(), metadata=metadata)
|
|
199
|
+
except grpc.aio.AioRpcError as e:
|
|
200
|
+
raise from_grpc_error(e) from e
|
|
201
|
+
|
|
202
|
+
process._relay_task = asyncio.create_task(relay(call))
|
|
203
|
+
return process
|
|
204
|
+
|
|
205
|
+
# ── Files ─────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
async def write_file(self, source: str | bytes | Path, dest: str) -> None:
|
|
208
|
+
"""Write a file to the VM.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
source: File content as bytes or str, or a ``Path`` to read from disk.
|
|
212
|
+
dest: Destination path inside the VM.
|
|
213
|
+
|
|
214
|
+
To upload a local file, pass a ``Path`` object::
|
|
215
|
+
|
|
216
|
+
await box.write_file(Path("local/file.py"), "/app/file.py")
|
|
217
|
+
"""
|
|
218
|
+
if isinstance(source, bytes):
|
|
219
|
+
data = source
|
|
220
|
+
elif isinstance(source, Path):
|
|
221
|
+
data = source.read_bytes()
|
|
222
|
+
else:
|
|
223
|
+
data = source.encode()
|
|
224
|
+
await self._call(
|
|
225
|
+
"UploadFile",
|
|
226
|
+
api_pb2.UploadFileRequest(vm_id=self.id, path=dest, data=data),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def read_file(self, path: str) -> bytes:
|
|
230
|
+
"""Download a file from the VM.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
path: Path inside the VM.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
File contents as bytes.
|
|
237
|
+
"""
|
|
238
|
+
resp = await self._call(
|
|
239
|
+
"DownloadFile",
|
|
240
|
+
api_pb2.DownloadFileRequest(vm_id=self.id, path=path),
|
|
241
|
+
)
|
|
242
|
+
return resp.data
|
|
243
|
+
|
|
244
|
+
# ── Proxies ───────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
async def proxies(self) -> list[Proxy]:
|
|
247
|
+
"""List proxies for this VM."""
|
|
248
|
+
resp = await self._call(
|
|
249
|
+
"ListProxies",
|
|
250
|
+
api_pb2.ListProxiesRequest(vm_name=self.name),
|
|
251
|
+
)
|
|
252
|
+
return [
|
|
253
|
+
Proxy(
|
|
254
|
+
name=p.name,
|
|
255
|
+
vm_name=p.vm_name,
|
|
256
|
+
domain=p.domain,
|
|
257
|
+
port=p.port,
|
|
258
|
+
is_default=p.is_default,
|
|
259
|
+
)
|
|
260
|
+
for p in resp.proxies
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
async def create_proxy(self, name: str, port: int = 0) -> Proxy:
|
|
264
|
+
"""Create a new proxy subdomain for this VM."""
|
|
265
|
+
resp = await self._call(
|
|
266
|
+
"CreateProxy",
|
|
267
|
+
api_pb2.CreateProxyRequest(name=name, vm_name=self.name, port=port),
|
|
268
|
+
)
|
|
269
|
+
return Proxy(
|
|
270
|
+
name=resp.name,
|
|
271
|
+
vm_name=resp.vm_name,
|
|
272
|
+
domain=resp.domain,
|
|
273
|
+
port=resp.port,
|
|
274
|
+
is_default=False,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def delete_proxy(self, name: str) -> None:
|
|
278
|
+
"""Delete a proxy subdomain."""
|
|
279
|
+
await self._call(
|
|
280
|
+
"DeleteProxy",
|
|
281
|
+
api_pb2.DeleteProxyRequest(name=name, vm_name=self.name),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
async def set_proxy_port(self, port: int, name: str = "") -> None:
|
|
285
|
+
"""Change the port for a proxy.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
port: New target port.
|
|
289
|
+
name: Proxy name (empty string for the default proxy).
|
|
290
|
+
"""
|
|
291
|
+
await self._call(
|
|
292
|
+
"SetProxyPort",
|
|
293
|
+
api_pb2.SetProxyPortRequest(name=name, vm_name=self.name, port=str(port)),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# ── Logs ─────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
async def stream_logs(self, follow: bool = False):
|
|
299
|
+
"""Stream console log chunks from the VM.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
follow: If True, keep the stream open and yield new chunks as
|
|
303
|
+
they arrive. If False, yield only the log content currently
|
|
304
|
+
available, then end.
|
|
305
|
+
|
|
306
|
+
Yields:
|
|
307
|
+
``bytes`` chunks of raw log data.
|
|
308
|
+
"""
|
|
309
|
+
stub = await self._client._ensure_channel()
|
|
310
|
+
try:
|
|
311
|
+
metadata = await self._client._auth.metadata()
|
|
312
|
+
call = stub.StreamLogs(
|
|
313
|
+
api_pb2.StreamLogsRequest(vm_id=self.id, follow=follow),
|
|
314
|
+
metadata=metadata,
|
|
315
|
+
)
|
|
316
|
+
async for chunk in call:
|
|
317
|
+
if chunk.data:
|
|
318
|
+
yield chunk.data
|
|
319
|
+
except grpc.aio.AioRpcError as e:
|
|
320
|
+
raise from_grpc_error(e) from e
|
boxd/boxes.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""BoxService — create, list, get, and fork VMs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import grpc.aio
|
|
8
|
+
|
|
9
|
+
from ._generated import api_pb2
|
|
10
|
+
from ._utils import GrpcCaller, parse_size
|
|
11
|
+
from .errors import NotFoundError, from_grpc_error
|
|
12
|
+
from .types import BoxConfig
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .box import Box
|
|
16
|
+
from .client import Compute
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _box_config_to_proto(config: BoxConfig | None) -> api_pb2.VmConfig | None:
|
|
20
|
+
"""Convert a BoxConfig dataclass to a VmConfig protobuf message."""
|
|
21
|
+
if config is None:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
vm_config = api_pb2.VmConfig()
|
|
25
|
+
|
|
26
|
+
if config.vcpu:
|
|
27
|
+
vm_config.vcpu = config.vcpu
|
|
28
|
+
if config.memory:
|
|
29
|
+
vm_config.memory_bytes = parse_size(config.memory)
|
|
30
|
+
if config.disk:
|
|
31
|
+
vm_config.disk_bytes = parse_size(config.disk)
|
|
32
|
+
|
|
33
|
+
if config.lifecycle:
|
|
34
|
+
vm_config.srf.auto_suspend_timeout_secs = config.lifecycle.auto_suspend_timeout
|
|
35
|
+
vm_config.srf.auto_destroy_timeout_secs = config.lifecycle.auto_destroy_timeout
|
|
36
|
+
|
|
37
|
+
if config.network:
|
|
38
|
+
vm_config.network.ssh = config.network.ssh
|
|
39
|
+
if config.network.proxies:
|
|
40
|
+
for p in config.network.proxies:
|
|
41
|
+
entry = api_pb2.ProxyEntry(name=p.name, port=p.port)
|
|
42
|
+
vm_config.network.proxies.append(entry)
|
|
43
|
+
|
|
44
|
+
if config.volumes:
|
|
45
|
+
for v in config.volumes:
|
|
46
|
+
mount = api_pb2.VolumeMount(
|
|
47
|
+
disk_id=v.disk_id,
|
|
48
|
+
mount_path=v.mount_path,
|
|
49
|
+
read_only=v.read_only,
|
|
50
|
+
)
|
|
51
|
+
vm_config.volumes.append(mount)
|
|
52
|
+
|
|
53
|
+
return vm_config
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _vm_response_to_box(resp, client: Compute, *, has_url: bool = False) -> Box:
|
|
57
|
+
"""Convert a VM response proto to a Box dataclass.
|
|
58
|
+
|
|
59
|
+
Works with both GetVmResponse (image_ref field) and
|
|
60
|
+
CreateVmResponse/ForkVmResponse (image field, plus url/boot_time_ms).
|
|
61
|
+
"""
|
|
62
|
+
from .box import Box
|
|
63
|
+
|
|
64
|
+
image = getattr(resp, "image", "") or getattr(resp, "image_ref", "")
|
|
65
|
+
kwargs: dict = dict(
|
|
66
|
+
id=resp.vm_id,
|
|
67
|
+
name=resp.name,
|
|
68
|
+
image=image,
|
|
69
|
+
public_ip=resp.public_ip,
|
|
70
|
+
status=resp.status,
|
|
71
|
+
_client=client,
|
|
72
|
+
)
|
|
73
|
+
if has_url:
|
|
74
|
+
kwargs["url"] = resp.url
|
|
75
|
+
kwargs["boot_time_ms"] = resp.boot_time_ms or None
|
|
76
|
+
# ForkVmResponse carries source vm id; CreateVm/GetVm don't.
|
|
77
|
+
forked_from = getattr(resp, "forked_from", "")
|
|
78
|
+
if forked_from:
|
|
79
|
+
kwargs["forked_from"] = forked_from
|
|
80
|
+
# GetVmResponse-only fields (it's the only one with image_ref instead of image).
|
|
81
|
+
if hasattr(resp, "image_ref"):
|
|
82
|
+
kwargs["restart_policy"] = resp.restart_policy or None
|
|
83
|
+
kwargs["disk_bytes"] = resp.disk_bytes or None
|
|
84
|
+
kwargs["auto_suspend_timeout_secs"] = resp.auto_suspend_timeout_secs
|
|
85
|
+
return Box(**kwargs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class BoxService(GrpcCaller):
|
|
89
|
+
"""Create, list, get, and fork VMs."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, client: Compute) -> None:
|
|
92
|
+
self._client = client
|
|
93
|
+
|
|
94
|
+
async def create(
|
|
95
|
+
self,
|
|
96
|
+
name: str = "",
|
|
97
|
+
image: str = "",
|
|
98
|
+
config: BoxConfig | None = None,
|
|
99
|
+
) -> Box:
|
|
100
|
+
"""Create a new VM."""
|
|
101
|
+
req = api_pb2.CreateVmRequest(name=name)
|
|
102
|
+
if image:
|
|
103
|
+
req.image_ref = image
|
|
104
|
+
if config:
|
|
105
|
+
if config.env:
|
|
106
|
+
for k, v in config.env.items():
|
|
107
|
+
req.env.append(api_pb2.EnvVar(key=k, value=v))
|
|
108
|
+
if config.cmd:
|
|
109
|
+
req.cmd.extend(config.cmd)
|
|
110
|
+
if config.restart_policy:
|
|
111
|
+
req.restart_policy = config.restart_policy
|
|
112
|
+
|
|
113
|
+
proto_config = _box_config_to_proto(config)
|
|
114
|
+
if proto_config is not None:
|
|
115
|
+
req.config.CopyFrom(proto_config)
|
|
116
|
+
|
|
117
|
+
resp = await self._call("CreateVm", req)
|
|
118
|
+
return _vm_response_to_box(resp, self._client, has_url=True)
|
|
119
|
+
|
|
120
|
+
async def list(self) -> list[Box]:
|
|
121
|
+
"""List all VMs."""
|
|
122
|
+
resp = await self._call("ListVms", api_pb2.ListVmsRequest())
|
|
123
|
+
return [_vm_response_to_box(vm, self._client) for vm in resp.vms]
|
|
124
|
+
|
|
125
|
+
async def get(self, vm_id: str) -> Box:
|
|
126
|
+
"""Get a VM by ID or name.
|
|
127
|
+
|
|
128
|
+
Tries by ID first; on NotFoundError falls back to name lookup.
|
|
129
|
+
"""
|
|
130
|
+
stub = await self._client._ensure_channel()
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
resp = await stub.GetVm(api_pb2.GetVmRequest(vm_id=vm_id))
|
|
134
|
+
return _vm_response_to_box(resp, self._client)
|
|
135
|
+
except grpc.aio.AioRpcError as e:
|
|
136
|
+
err = from_grpc_error(e)
|
|
137
|
+
if not isinstance(err, NotFoundError):
|
|
138
|
+
raise err from e
|
|
139
|
+
|
|
140
|
+
# Fall back to name lookup via list
|
|
141
|
+
all_vms = await self.list()
|
|
142
|
+
for vm in all_vms:
|
|
143
|
+
if vm.name == vm_id:
|
|
144
|
+
return vm
|
|
145
|
+
raise NotFoundError(f"VM not found: {vm_id}")
|
|
146
|
+
|
|
147
|
+
async def fork(
|
|
148
|
+
self,
|
|
149
|
+
source: str,
|
|
150
|
+
name: str | None = None,
|
|
151
|
+
config: BoxConfig | None = None,
|
|
152
|
+
) -> Box:
|
|
153
|
+
"""Fork (clone) a VM."""
|
|
154
|
+
source_vm = await self.get(source)
|
|
155
|
+
req = api_pb2.ForkVmRequest(source_vm_id=source_vm.id)
|
|
156
|
+
if name:
|
|
157
|
+
req.name = name
|
|
158
|
+
|
|
159
|
+
proto_config = _box_config_to_proto(config)
|
|
160
|
+
if proto_config is not None:
|
|
161
|
+
req.config.CopyFrom(proto_config)
|
|
162
|
+
|
|
163
|
+
resp = await self._call("ForkVm", req)
|
|
164
|
+
return _vm_response_to_box(resp, self._client, has_url=True)
|