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/client.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Compute — main entry point for the boxd SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import grpc
|
|
8
|
+
import grpc.aio
|
|
9
|
+
|
|
10
|
+
from ._generated import api_pb2, api_pb2_grpc
|
|
11
|
+
from ._utils import resolve_endpoint
|
|
12
|
+
from .auth import TokenAuth
|
|
13
|
+
from .boxes import BoxService
|
|
14
|
+
from .disks import DiskService
|
|
15
|
+
from .domains import DomainService
|
|
16
|
+
from .errors import AuthenticationError, from_grpc_error
|
|
17
|
+
from .networks import NetworkService
|
|
18
|
+
from .templates import TemplateService
|
|
19
|
+
from .tokens import TokenService
|
|
20
|
+
from .types import ConfigResult, WhoamiResult
|
|
21
|
+
|
|
22
|
+
_DEFAULT_API_URL = "http://boxd.sh:9443"
|
|
23
|
+
_DEFAULT_EXCHANGE_URL = "https://boxd.sh/api/v1/auth/token"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Compute:
|
|
27
|
+
"""Main client for the boxd API.
|
|
28
|
+
|
|
29
|
+
Usage::
|
|
30
|
+
|
|
31
|
+
async with Compute(api_key="bxk_...") as c:
|
|
32
|
+
box = await c.box.create("my-vm")
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
api_key: str | None = None,
|
|
39
|
+
token: str | None = None,
|
|
40
|
+
api_url: str | None = None,
|
|
41
|
+
exchange_url: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._api_url = (
|
|
44
|
+
api_url
|
|
45
|
+
or os.environ.get("BOXD_API_URL")
|
|
46
|
+
or _DEFAULT_API_URL
|
|
47
|
+
)
|
|
48
|
+
self._exchange_url = (
|
|
49
|
+
exchange_url
|
|
50
|
+
or os.environ.get("BOXD_EXCHANGE_URL")
|
|
51
|
+
or _DEFAULT_EXCHANGE_URL
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
resolved_key = api_key or os.environ.get("BOXD_API_KEY")
|
|
55
|
+
resolved_token = token or os.environ.get("BOXD_TOKEN")
|
|
56
|
+
|
|
57
|
+
if not resolved_key and not resolved_token:
|
|
58
|
+
raise AuthenticationError(
|
|
59
|
+
"provide api_key, token, or set BOXD_API_KEY / BOXD_TOKEN"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
self._auth = TokenAuth(
|
|
63
|
+
api_key=resolved_key,
|
|
64
|
+
token=resolved_token,
|
|
65
|
+
exchange_url=self._exchange_url,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self._channel: grpc.aio.Channel | None = None
|
|
69
|
+
self._stub: api_pb2_grpc.BoxdApiStub | None = None
|
|
70
|
+
|
|
71
|
+
# Sub-services
|
|
72
|
+
self.box = BoxService(self)
|
|
73
|
+
self.template = TemplateService(self)
|
|
74
|
+
self.disk = DiskService(self)
|
|
75
|
+
self.domain = DomainService(self)
|
|
76
|
+
self.network = NetworkService(self)
|
|
77
|
+
self.token = TokenService(self)
|
|
78
|
+
|
|
79
|
+
async def _ensure_channel(self) -> api_pb2_grpc.BoxdApiStub:
|
|
80
|
+
"""Lazily create the gRPC channel and return the stub."""
|
|
81
|
+
if self._stub is not None:
|
|
82
|
+
return self._stub
|
|
83
|
+
|
|
84
|
+
interceptor = self._auth.interceptor()
|
|
85
|
+
endpoint = resolve_endpoint(self._api_url)
|
|
86
|
+
|
|
87
|
+
if endpoint.use_tls:
|
|
88
|
+
creds = grpc.ssl_channel_credentials()
|
|
89
|
+
channel = grpc.aio.secure_channel(
|
|
90
|
+
endpoint.host,
|
|
91
|
+
creds,
|
|
92
|
+
interceptors=[interceptor],
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
channel = grpc.aio.insecure_channel(
|
|
96
|
+
endpoint.host,
|
|
97
|
+
interceptors=[interceptor],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self._channel = channel
|
|
101
|
+
self._stub = api_pb2_grpc.BoxdApiStub(channel)
|
|
102
|
+
return self._stub
|
|
103
|
+
|
|
104
|
+
async def _call(self, method_name: str, request):
|
|
105
|
+
"""Call a gRPC method on the stub, converting errors."""
|
|
106
|
+
stub = await self._ensure_channel()
|
|
107
|
+
method = getattr(stub, method_name)
|
|
108
|
+
try:
|
|
109
|
+
return await method(request)
|
|
110
|
+
except grpc.aio.AioRpcError as e:
|
|
111
|
+
raise from_grpc_error(e) from e
|
|
112
|
+
|
|
113
|
+
async def whoami(self) -> WhoamiResult:
|
|
114
|
+
"""Return information about the authenticated user."""
|
|
115
|
+
resp = await self._call("Whoami", api_pb2.WhoamiRequest())
|
|
116
|
+
return WhoamiResult(
|
|
117
|
+
user_id=resp.user_id,
|
|
118
|
+
fingerprints=list(resp.pubkey_fingerprints),
|
|
119
|
+
default_network_id=resp.default_network_id,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def config(self) -> ConfigResult:
|
|
123
|
+
"""Return server configuration."""
|
|
124
|
+
resp = await self._call("GetConfig", api_pb2.GetConfigRequest())
|
|
125
|
+
return ConfigResult(
|
|
126
|
+
default_image=resp.default_image,
|
|
127
|
+
zone=resp.zone,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def close(self) -> None:
|
|
131
|
+
"""Close the gRPC channel."""
|
|
132
|
+
if self._channel is not None:
|
|
133
|
+
await self._channel.close()
|
|
134
|
+
self._channel = None
|
|
135
|
+
self._stub = None
|
|
136
|
+
|
|
137
|
+
async def __aenter__(self) -> Compute:
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
141
|
+
await self.close()
|
boxd/disks.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""DiskService — create and list persistent disks; DiskHandle — attach, detach, destroy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from ._generated import api_pb2
|
|
9
|
+
from ._utils import GrpcCaller, parse_size
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .box import Box
|
|
13
|
+
from .client import Compute
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve_vm_id(box: Box | str) -> str:
|
|
17
|
+
"""Extract a VM ID from a Box object or string."""
|
|
18
|
+
return box if isinstance(box, str) else box.id
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DiskHandle(GrpcCaller):
|
|
23
|
+
"""A handle to a persistent disk with attach/detach/destroy operations."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
size_bytes: int
|
|
28
|
+
status: str
|
|
29
|
+
_client: Compute = field(repr=False)
|
|
30
|
+
|
|
31
|
+
async def attach(
|
|
32
|
+
self,
|
|
33
|
+
box: Box | str,
|
|
34
|
+
mount_path: str,
|
|
35
|
+
read_only: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Attach this disk to a VM.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
box: A Box object or VM ID string.
|
|
41
|
+
mount_path: Path at which to mount the disk inside the VM.
|
|
42
|
+
read_only: Whether to mount the disk read-only.
|
|
43
|
+
"""
|
|
44
|
+
await self._call(
|
|
45
|
+
"AttachDisk",
|
|
46
|
+
api_pb2.AttachDiskRequest(
|
|
47
|
+
disk_id=self.id,
|
|
48
|
+
vm_id=_resolve_vm_id(box),
|
|
49
|
+
mount_path=mount_path,
|
|
50
|
+
read_only=read_only,
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def detach(self, box: Box | str) -> None:
|
|
55
|
+
"""Detach this disk from a VM.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
box: A Box object or VM ID string.
|
|
59
|
+
"""
|
|
60
|
+
await self._call(
|
|
61
|
+
"DetachDisk",
|
|
62
|
+
api_pb2.DetachDiskRequest(disk_id=self.id, vm_id=_resolve_vm_id(box)),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def destroy(self) -> None:
|
|
66
|
+
"""Permanently destroy this disk."""
|
|
67
|
+
await self._call("DestroyDisk", api_pb2.DestroyDiskRequest(disk_id=self.id))
|
|
68
|
+
self.status = "destroyed"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DiskService(GrpcCaller):
|
|
72
|
+
"""Manage persistent disks."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, client: Compute) -> None:
|
|
75
|
+
self._client = client
|
|
76
|
+
|
|
77
|
+
async def create(self, name: str, size: str) -> DiskHandle:
|
|
78
|
+
"""Create a new persistent disk.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
name: Disk name.
|
|
82
|
+
size: Human-readable size string, e.g. ``"10G"`` or ``"512M"``.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A DiskHandle for the created disk.
|
|
86
|
+
"""
|
|
87
|
+
resp = await self._call(
|
|
88
|
+
"CreateDisk",
|
|
89
|
+
api_pb2.CreateDiskRequest(name=name, size_bytes=parse_size(size)),
|
|
90
|
+
)
|
|
91
|
+
return DiskHandle(
|
|
92
|
+
id=resp.disk_id,
|
|
93
|
+
name=resp.name,
|
|
94
|
+
size_bytes=resp.size_bytes,
|
|
95
|
+
status=resp.status,
|
|
96
|
+
_client=self._client,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def list(self) -> list[DiskHandle]:
|
|
100
|
+
"""List all disks."""
|
|
101
|
+
resp = await self._call("ListDisks", api_pb2.ListDisksRequest())
|
|
102
|
+
return [
|
|
103
|
+
DiskHandle(
|
|
104
|
+
id=d.disk_id,
|
|
105
|
+
name=d.name,
|
|
106
|
+
size_bytes=d.size_bytes,
|
|
107
|
+
status=d.status,
|
|
108
|
+
_client=self._client,
|
|
109
|
+
)
|
|
110
|
+
for d in resp.disks
|
|
111
|
+
]
|
boxd/domains.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""DomainService — bind, unbind, and list external domains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ._generated import api_pb2
|
|
8
|
+
from ._utils import GrpcCaller
|
|
9
|
+
from .types import Domain
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .box import Box
|
|
13
|
+
from .client import Compute
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DomainService(GrpcCaller):
|
|
17
|
+
"""Manage external domains bound to VMs."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, client: Compute) -> None:
|
|
20
|
+
self._client = client
|
|
21
|
+
|
|
22
|
+
async def bind(self, domain: str, vm: Box | str) -> None:
|
|
23
|
+
"""Bind an external domain to a VM.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
domain: The external domain name (e.g. ``"app.example.com"``).
|
|
27
|
+
vm: A Box object, VM name, or VM ID.
|
|
28
|
+
|
|
29
|
+
The domain's DNS must already point to the boxd proxy. This call only
|
|
30
|
+
configures the proxy to route traffic for ``domain`` to the given VM.
|
|
31
|
+
"""
|
|
32
|
+
# Resolve Box/name/id to VM ID
|
|
33
|
+
if hasattr(vm, "id"):
|
|
34
|
+
vm_id = vm.id
|
|
35
|
+
else:
|
|
36
|
+
resolved = await self._client.box.get(vm)
|
|
37
|
+
vm_id = resolved.id
|
|
38
|
+
|
|
39
|
+
await self._call(
|
|
40
|
+
"BindDomain",
|
|
41
|
+
api_pb2.BindDomainRequest(domain=domain, vm_id=vm_id),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def unbind(self, domain: str) -> None:
|
|
45
|
+
"""Remove the binding for an external domain."""
|
|
46
|
+
await self._call(
|
|
47
|
+
"UnbindDomain",
|
|
48
|
+
api_pb2.UnbindDomainRequest(domain=domain),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def list(self) -> list[Domain]:
|
|
52
|
+
"""List all external domains bound to this user's VMs."""
|
|
53
|
+
resp = await self._call("ListDomains", api_pb2.ListDomainsRequest())
|
|
54
|
+
return [Domain(domain=d.domain, vm_id=d.vm_id) for d in resp.domains]
|
boxd/errors.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Exception hierarchy for the boxd SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import grpc
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BoxdError(Exception):
|
|
9
|
+
"""Base exception for all boxd SDK errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, grpc_code: int | None = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message = message
|
|
14
|
+
self.grpc_code = grpc_code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthenticationError(BoxdError):
|
|
18
|
+
"""API key invalid, expired, or revoked."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotFoundError(BoxdError):
|
|
22
|
+
"""Resource not found."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class QuotaExceededError(BoxdError):
|
|
26
|
+
"""Resource limit reached."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InvalidArgumentError(BoxdError):
|
|
30
|
+
"""Invalid request parameters."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InternalError(BoxdError):
|
|
34
|
+
"""Server-side error."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TimeoutError(BoxdError):
|
|
38
|
+
"""Operation timed out."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConnectionError(BoxdError):
|
|
42
|
+
"""Failed to connect to boxd API."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_STATUS_MAP = {
|
|
46
|
+
grpc.StatusCode.UNAUTHENTICATED: AuthenticationError,
|
|
47
|
+
grpc.StatusCode.PERMISSION_DENIED: AuthenticationError,
|
|
48
|
+
grpc.StatusCode.NOT_FOUND: NotFoundError,
|
|
49
|
+
grpc.StatusCode.ALREADY_EXISTS: InvalidArgumentError,
|
|
50
|
+
grpc.StatusCode.RESOURCE_EXHAUSTED: QuotaExceededError,
|
|
51
|
+
grpc.StatusCode.INVALID_ARGUMENT: InvalidArgumentError,
|
|
52
|
+
grpc.StatusCode.INTERNAL: InternalError,
|
|
53
|
+
grpc.StatusCode.UNKNOWN: InternalError,
|
|
54
|
+
grpc.StatusCode.DEADLINE_EXCEEDED: TimeoutError,
|
|
55
|
+
grpc.StatusCode.UNAVAILABLE: ConnectionError,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def from_grpc_error(err: grpc.aio.AioRpcError) -> BoxdError:
|
|
60
|
+
"""Convert a gRPC error to the appropriate BoxdError subclass."""
|
|
61
|
+
cls = _STATUS_MAP.get(err.code(), BoxdError)
|
|
62
|
+
return cls(err.details() or str(err), grpc_code=err.code().value[0])
|
boxd/exec.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Exec primitives: result, stream readers/writers, and process handle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ExecResult:
|
|
11
|
+
"""Result of a non-streaming exec call."""
|
|
12
|
+
|
|
13
|
+
stdout: str
|
|
14
|
+
stderr: str
|
|
15
|
+
exit_code: int
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def success(self) -> bool:
|
|
19
|
+
return self.exit_code == 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StreamReader:
|
|
23
|
+
"""Async reader that buffers chunks from an exec stream."""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
27
|
+
self._eof = False
|
|
28
|
+
|
|
29
|
+
async def read(self) -> bytes:
|
|
30
|
+
"""Read the next chunk, or return empty bytes on EOF."""
|
|
31
|
+
if self._eof:
|
|
32
|
+
return b""
|
|
33
|
+
chunk = await self._queue.get()
|
|
34
|
+
if chunk is None:
|
|
35
|
+
self._eof = True
|
|
36
|
+
return b""
|
|
37
|
+
return chunk
|
|
38
|
+
|
|
39
|
+
def _feed(self, data: bytes) -> None:
|
|
40
|
+
self._queue.put_nowait(data)
|
|
41
|
+
|
|
42
|
+
def _feed_eof(self) -> None:
|
|
43
|
+
self._queue.put_nowait(None)
|
|
44
|
+
|
|
45
|
+
def __aiter__(self):
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
async def __anext__(self) -> bytes:
|
|
49
|
+
chunk = await self.read()
|
|
50
|
+
if not chunk and self._eof:
|
|
51
|
+
raise StopAsyncIteration
|
|
52
|
+
return chunk
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class StreamWriter:
|
|
56
|
+
"""Async writer that queues stdin chunks for an exec stream."""
|
|
57
|
+
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
60
|
+
self._closed = False
|
|
61
|
+
|
|
62
|
+
def write(self, data: bytes | str) -> None:
|
|
63
|
+
"""Queue data to be sent to stdin."""
|
|
64
|
+
if self._closed:
|
|
65
|
+
raise RuntimeError("writer is closed")
|
|
66
|
+
if isinstance(data, str):
|
|
67
|
+
data = data.encode("utf-8")
|
|
68
|
+
self._queue.put_nowait(data)
|
|
69
|
+
|
|
70
|
+
def write_eof(self) -> None:
|
|
71
|
+
"""Signal end of stdin."""
|
|
72
|
+
if not self._closed:
|
|
73
|
+
self._closed = True
|
|
74
|
+
self._queue.put_nowait(None)
|
|
75
|
+
|
|
76
|
+
async def drain(self) -> None:
|
|
77
|
+
"""Wait until the queue is empty."""
|
|
78
|
+
await self._queue.join()
|
|
79
|
+
|
|
80
|
+
async def _get(self) -> bytes | None:
|
|
81
|
+
return await self._queue.get()
|
|
82
|
+
|
|
83
|
+
def _task_done(self) -> None:
|
|
84
|
+
self._queue.task_done()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ExecProcess:
|
|
89
|
+
"""Handle to a streaming exec process."""
|
|
90
|
+
|
|
91
|
+
stdout: StreamReader
|
|
92
|
+
stderr: StreamReader
|
|
93
|
+
stdin: StreamWriter
|
|
94
|
+
_exit_code: int | None = field(default=None, repr=False)
|
|
95
|
+
_error: BaseException | None = field(default=None, repr=False)
|
|
96
|
+
_done: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
|
|
97
|
+
_relay_task: asyncio.Task | None = field(default=None, repr=False)
|
|
98
|
+
|
|
99
|
+
async def wait(self) -> int:
|
|
100
|
+
"""Wait for the process to exit and return the exit code.
|
|
101
|
+
|
|
102
|
+
Raises the original exception if the exec stream failed.
|
|
103
|
+
"""
|
|
104
|
+
await self._done.wait()
|
|
105
|
+
if self._error is not None:
|
|
106
|
+
raise self._error
|
|
107
|
+
return self._exit_code if self._exit_code is not None else -1
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
"""Cancel the relay task and close stdin."""
|
|
111
|
+
self.stdin.write_eof()
|
|
112
|
+
if self._relay_task and not self._relay_task.done():
|
|
113
|
+
self._relay_task.cancel()
|
|
114
|
+
|
|
115
|
+
def _set_exit_code(self, code: int) -> None:
|
|
116
|
+
self._exit_code = code
|
|
117
|
+
self._done.set()
|
|
118
|
+
|
|
119
|
+
def _set_error(self, error: BaseException) -> None:
|
|
120
|
+
self._error = error
|
|
121
|
+
self._exit_code = -1
|
|
122
|
+
self._done.set()
|
boxd/networks.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""NetworkService — create and list user networks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ._generated import api_pb2
|
|
8
|
+
from ._utils import GrpcCaller
|
|
9
|
+
from .types import Network
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .client import Compute
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NetworkService(GrpcCaller):
|
|
16
|
+
"""Manage user networks."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, client: Compute) -> None:
|
|
19
|
+
self._client = client
|
|
20
|
+
|
|
21
|
+
async def create(self, name: str = "") -> Network:
|
|
22
|
+
"""Create a new network.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
name: Optional network name.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A Network with only ``id`` populated. Call :meth:`list` to get
|
|
29
|
+
subnet and status.
|
|
30
|
+
"""
|
|
31
|
+
resp = await self._call(
|
|
32
|
+
"CreateNetwork",
|
|
33
|
+
api_pb2.CreateNetworkRequest(name=name),
|
|
34
|
+
)
|
|
35
|
+
return Network(id=resp.network_id, subnet="", status="")
|
|
36
|
+
|
|
37
|
+
async def list(self) -> list[Network]:
|
|
38
|
+
"""List all of this user's networks."""
|
|
39
|
+
resp = await self._call("ListNetworks", api_pb2.ListNetworksRequest())
|
|
40
|
+
return [
|
|
41
|
+
Network(id=n.network_id, subnet=n.subnet, status=n.status)
|
|
42
|
+
for n in resp.networks
|
|
43
|
+
]
|
boxd/templates.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""TemplateService — create, list, delete, and instantiate VM templates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ._generated import api_pb2
|
|
8
|
+
from ._utils import GrpcCaller, parse_size
|
|
9
|
+
from .types import BoxConfig, Template
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .box import Box
|
|
13
|
+
from .client import Compute
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _template_info_to_template(info) -> Template:
|
|
17
|
+
"""Convert a TemplateInfo proto to a Template dataclass."""
|
|
18
|
+
return Template(
|
|
19
|
+
id=info.template_id,
|
|
20
|
+
name=info.name,
|
|
21
|
+
image=info.image_ref,
|
|
22
|
+
status=info.status,
|
|
23
|
+
vcpu=info.vcpu,
|
|
24
|
+
memory_bytes=info.memory_bytes,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TemplateService(GrpcCaller):
|
|
29
|
+
"""Manage VM templates."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, client: Compute) -> None:
|
|
32
|
+
self._client = client
|
|
33
|
+
|
|
34
|
+
async def create(
|
|
35
|
+
self,
|
|
36
|
+
name: str,
|
|
37
|
+
image: str = "",
|
|
38
|
+
config: BoxConfig | None = None,
|
|
39
|
+
) -> Template:
|
|
40
|
+
"""Create a new VM template.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
name: Template name.
|
|
44
|
+
image: Base image reference.
|
|
45
|
+
config: Optional VM configuration.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
The created Template.
|
|
49
|
+
"""
|
|
50
|
+
from .boxes import _box_config_to_proto
|
|
51
|
+
|
|
52
|
+
req = api_pb2.CreateTemplateRequest(name=name)
|
|
53
|
+
if image:
|
|
54
|
+
req.image_ref = image
|
|
55
|
+
if config is not None:
|
|
56
|
+
proto_config = _box_config_to_proto(config)
|
|
57
|
+
if proto_config is not None:
|
|
58
|
+
req.config.CopyFrom(proto_config)
|
|
59
|
+
|
|
60
|
+
resp = await self._call("CreateTemplate", req)
|
|
61
|
+
return Template(
|
|
62
|
+
id=resp.template_id,
|
|
63
|
+
name=resp.name,
|
|
64
|
+
image=image,
|
|
65
|
+
status=resp.status,
|
|
66
|
+
vcpu=config.vcpu if config else 0,
|
|
67
|
+
memory_bytes=parse_size(config.memory) if config and config.memory else 0,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def list(self) -> list[Template]:
|
|
71
|
+
"""List all templates."""
|
|
72
|
+
resp = await self._call("ListTemplates", api_pb2.ListTemplatesRequest())
|
|
73
|
+
return [_template_info_to_template(t) for t in resp.templates]
|
|
74
|
+
|
|
75
|
+
async def delete(self, template_id: str) -> None:
|
|
76
|
+
"""Delete a template by ID."""
|
|
77
|
+
await self._call(
|
|
78
|
+
"DeleteTemplate",
|
|
79
|
+
api_pb2.DeleteTemplateRequest(template_id=template_id),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def create_vm(
|
|
83
|
+
self,
|
|
84
|
+
template: Template | str,
|
|
85
|
+
name: str = "",
|
|
86
|
+
config: BoxConfig | None = None,
|
|
87
|
+
) -> Box:
|
|
88
|
+
"""Create a VM from a template.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
template: A Template object or template ID string.
|
|
92
|
+
name: Optional name for the new VM.
|
|
93
|
+
config: Optional VM configuration overrides.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The newly created Box.
|
|
97
|
+
"""
|
|
98
|
+
from .boxes import _box_config_to_proto
|
|
99
|
+
|
|
100
|
+
template_id = template.id if isinstance(template, Template) else template
|
|
101
|
+
|
|
102
|
+
req = api_pb2.CreateVmFromTemplateRequest(template_id=template_id)
|
|
103
|
+
if name:
|
|
104
|
+
req.name = name
|
|
105
|
+
if config is not None:
|
|
106
|
+
proto_config = _box_config_to_proto(config)
|
|
107
|
+
if proto_config is not None:
|
|
108
|
+
req.config.CopyFrom(proto_config)
|
|
109
|
+
|
|
110
|
+
resp = await self._call("CreateVmFromTemplate", req)
|
|
111
|
+
from .boxes import _vm_response_to_box
|
|
112
|
+
|
|
113
|
+
return _vm_response_to_box(resp, self._client, has_url=True)
|
boxd/tokens.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""TokenService — create, list, and revoke scoped JWT tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ._generated import api_pb2
|
|
8
|
+
from ._utils import GrpcCaller
|
|
9
|
+
from .types import Token, TokenInfo
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .client import Compute
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TokenService(GrpcCaller):
|
|
16
|
+
"""Manage scoped JWT tokens for delegated access."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, client: Compute) -> None:
|
|
19
|
+
self._client = client
|
|
20
|
+
|
|
21
|
+
async def create(self, expires_in: int = 0) -> Token:
|
|
22
|
+
"""Create a new scoped JWT token for the current user.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
expires_in: TTL in seconds. 0 means server default (24h).
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A :class:`Token` with the raw JWT string. Save it — the raw token
|
|
29
|
+
cannot be retrieved later via :meth:`list`.
|
|
30
|
+
"""
|
|
31
|
+
resp = await self._call(
|
|
32
|
+
"CreateToken",
|
|
33
|
+
api_pb2.CreateTokenRequest(expires_in_secs=expires_in),
|
|
34
|
+
)
|
|
35
|
+
return Token(token=resp.token, expires_at=resp.expires_at)
|
|
36
|
+
|
|
37
|
+
async def list(self) -> list[TokenInfo]:
|
|
38
|
+
"""List metadata about previously-issued tokens (no raw token strings)."""
|
|
39
|
+
resp = await self._call("ListTokens", api_pb2.ListTokensRequest())
|
|
40
|
+
return [
|
|
41
|
+
TokenInfo(
|
|
42
|
+
jti=t.jti,
|
|
43
|
+
created_at=t.created_at,
|
|
44
|
+
expires_at=t.expires_at,
|
|
45
|
+
)
|
|
46
|
+
for t in resp.tokens
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
async def revoke(self, jti: str) -> None:
|
|
50
|
+
"""Revoke a token by its JTI (JWT ID)."""
|
|
51
|
+
await self._call("RevokeToken", api_pb2.RevokeTokenRequest(jti=jti))
|