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