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/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)