drukbox-python-sdk 0.1.0__py3-none-any.whl → 0.3.0__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.
- {drukbox_python_sdk-0.1.0.dist-info → drukbox_python_sdk-0.3.0.dist-info}/METADATA +53 -4
- drukbox_python_sdk-0.3.0.dist-info/RECORD +7 -0
- drukbox_sdk/__init__.py +6 -0
- drukbox_sdk/api.py +98 -1
- drukbox_sdk/exceptions.py +6 -5
- drukbox_python_sdk-0.1.0.dist-info/RECORD +0 -7
- {drukbox_python_sdk-0.1.0.dist-info → drukbox_python_sdk-0.3.0.dist-info}/WHEEL +0 -0
- {drukbox_python_sdk-0.1.0.dist-info → drukbox_python_sdk-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: drukbox-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Async Python client for Drukbox.
|
|
5
5
|
Project-URL: Homepage, https://github.com/clawhaven/drukbox-python-sdk
|
|
6
6
|
Project-URL: Repository, https://github.com/clawhaven/drukbox-python-sdk
|
|
@@ -71,6 +71,9 @@ Public exports live in `drukbox_sdk`:
|
|
|
71
71
|
- `SandboxAPI`
|
|
72
72
|
- `SandboxHost`
|
|
73
73
|
- `SandboxTemplate`
|
|
74
|
+
- `Secret`
|
|
75
|
+
- `Issuer`
|
|
76
|
+
- `ServiceAccount`
|
|
74
77
|
- `DoctorReport` and `DoctorCheck`
|
|
75
78
|
- `HTTPProxy` and `HTTPProxyAttachment`
|
|
76
79
|
- `SandboxAPIError` and typed subclasses for auth, not found, conflict,
|
|
@@ -84,21 +87,66 @@ Supported host operations:
|
|
|
84
87
|
- `list_hosts`
|
|
85
88
|
- `renew_host`
|
|
86
89
|
- `delete_host`
|
|
90
|
+
- `refresh_secret`
|
|
87
91
|
- `doctor`
|
|
88
92
|
- `aclose`
|
|
89
93
|
|
|
90
94
|
`create_host` supports the service's optional `image`, `env`, `expires_at`,
|
|
91
|
-
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`,
|
|
92
|
-
`Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
95
|
+
`permanent`, `provider`, `instance_type`, `disk_gb`, `template`, `secrets`,
|
|
96
|
+
and `Idempotency-Key` inputs. `instance_type` (provider-native size, e.g.
|
|
93
97
|
`t3.xlarge` / `cx33`) and `disk_gb` pin the VM shape; omit either for the
|
|
94
98
|
provider default. Omit `expires_at` for the default lease, pass a datetime for
|
|
95
99
|
an explicit expiry, or pass `permanent=True` for a never-reaped host.
|
|
96
100
|
`template` accepts a template ID. An explicit `image` wins over it.
|
|
97
101
|
|
|
102
|
+
`secrets` gives the box credentials it never sees. Each entry is a `Secret`,
|
|
103
|
+
a value the caller holds, or an `Issuer`, a URL Drukbox fetches the value
|
|
104
|
+
from. The box gets a placeholder per entry in the service's variable, and the
|
|
105
|
+
Drukbox swaps it on the way out. A custom service names its `host` and
|
|
106
|
+
`auth_variable`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from drukbox_sdk import Issuer, Secret
|
|
110
|
+
|
|
111
|
+
host = await sandbox.create_host(
|
|
112
|
+
secrets={
|
|
113
|
+
"anthropic": Issuer(
|
|
114
|
+
"https://mint.example/box-1/anthropic",
|
|
115
|
+
headers={"Authorization": "Bearer ..."},
|
|
116
|
+
refresh="1h",
|
|
117
|
+
),
|
|
118
|
+
"openai": Secret("sk-..."),
|
|
119
|
+
"acme": Secret("ak_...", host="api.acme.test", auth_variable="ACME_TOKEN"),
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The response carries no secret. A service name Drukbox does not know raises
|
|
125
|
+
`SandboxValidationError`. A deployment without the secrets proxy raises
|
|
126
|
+
`SandboxConflictError`.
|
|
127
|
+
|
|
98
128
|
`renew_host` extends a host's lease via `POST /hosts/{id}/renew`. Omit
|
|
99
129
|
`expires_at` to extend by the service's default TTL; renewal never makes a host
|
|
100
130
|
permanent.
|
|
101
131
|
|
|
132
|
+
`SandboxHost.service_account` names the service account that created or
|
|
133
|
+
claimed the host, `admin` for an admin key, or `None` for an unclaimed warm
|
|
134
|
+
host.
|
|
135
|
+
|
|
136
|
+
`refresh_secret` makes the service forget the current value of one secret on
|
|
137
|
+
a host and fetch a new one from its issuer. A static secret raises
|
|
138
|
+
`SandboxConflictError`; an issuer or exchange that did not answer raises
|
|
139
|
+
`SandboxUnavailableError`.
|
|
140
|
+
|
|
141
|
+
Supported service account operations, admin key only:
|
|
142
|
+
|
|
143
|
+
- `create_service_account`
|
|
144
|
+
- `remove_service_account`
|
|
145
|
+
|
|
146
|
+
`create_service_account` returns a `ServiceAccount` with the token the
|
|
147
|
+
service hands out once. Store it. `remove_service_account` revokes it on the
|
|
148
|
+
next request.
|
|
149
|
+
|
|
102
150
|
Supported template operations:
|
|
103
151
|
|
|
104
152
|
- `create_template`
|
|
@@ -125,7 +173,8 @@ proxy at a host's backing VM (the host must be `bootstrapping` or `active`).
|
|
|
125
173
|
|
|
126
174
|
`doctor` fetches `GET /doctor` — read-only dependency health. The service
|
|
127
175
|
runs one cheap, non-mutating probe per dependency (database, active VM
|
|
128
|
-
provider, Tailscale when enabled) and always responds 200,
|
|
176
|
+
provider, secrets exchange, Tailscale when enabled) and always responds 200,
|
|
177
|
+
so callers branch
|
|
129
178
|
on `DoctorReport.ok` rather than the HTTP status. A failed `DoctorCheck`
|
|
130
179
|
carries a stable `hint` slug for remediation.
|
|
131
180
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
drukbox_sdk/__init__.py,sha256=bU2rOTzC3GMXSpiXvoMVwFOuKJUPZWSXhnteaRFxT_Y,1012
|
|
2
|
+
drukbox_sdk/api.py,sha256=eoXXnK8mkrSK9Q4tRSuVbN-6mSjbBTTeEsH_jF3U_wY,24118
|
|
3
|
+
drukbox_sdk/exceptions.py,sha256=Vzs4zVOywWsWfiZm6vgQ65ScI3Nn7zLGVj-8eT1YAR0,2224
|
|
4
|
+
drukbox_python_sdk-0.3.0.dist-info/METADATA,sha256=KL-_BzJPSEVgimNohBs8BIxhwcU8dreFTdVSihBlUaI,6599
|
|
5
|
+
drukbox_python_sdk-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
drukbox_python_sdk-0.3.0.dist-info/licenses/LICENSE,sha256=zM66rWL3BWGJBa_mZPnM0WUomSSZ1xtWVtGQovQ71M8,1078
|
|
7
|
+
drukbox_python_sdk-0.3.0.dist-info/RECORD,,
|
drukbox_sdk/__init__.py
CHANGED
|
@@ -9,9 +9,12 @@ from .api import (
|
|
|
9
9
|
DoctorReport,
|
|
10
10
|
HTTPProxy,
|
|
11
11
|
HTTPProxyAttachment,
|
|
12
|
+
Issuer,
|
|
12
13
|
SandboxAPI,
|
|
13
14
|
SandboxHost,
|
|
14
15
|
SandboxTemplate,
|
|
16
|
+
Secret,
|
|
17
|
+
ServiceAccount,
|
|
15
18
|
)
|
|
16
19
|
from .exceptions import (
|
|
17
20
|
SandboxAPIError,
|
|
@@ -29,6 +32,7 @@ __all__ = [
|
|
|
29
32
|
"DoctorReport",
|
|
30
33
|
"HTTPProxy",
|
|
31
34
|
"HTTPProxyAttachment",
|
|
35
|
+
"Issuer",
|
|
32
36
|
"SandboxAPI",
|
|
33
37
|
"SandboxAPIError",
|
|
34
38
|
"SandboxAuthError",
|
|
@@ -40,4 +44,6 @@ __all__ = [
|
|
|
40
44
|
"SandboxTemplate",
|
|
41
45
|
"SandboxUnavailableError",
|
|
42
46
|
"SandboxValidationError",
|
|
47
|
+
"Secret",
|
|
48
|
+
"ServiceAccount",
|
|
43
49
|
]
|
drukbox_sdk/api.py
CHANGED
|
@@ -39,7 +39,7 @@ For env-backed config use :meth:`SandboxAPI.from_env`.
|
|
|
39
39
|
import asyncio
|
|
40
40
|
import os
|
|
41
41
|
import uuid
|
|
42
|
-
from dataclasses import dataclass
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
43
|
from datetime import datetime
|
|
44
44
|
from typing import Any, Self
|
|
45
45
|
|
|
@@ -85,10 +85,15 @@ class SandboxHost:
|
|
|
85
85
|
different SSH auth model (exe.dev's edge proxy, Tailscale ACLs
|
|
86
86
|
via tailscaled-SSH) return None here. A later ``get_host`` call
|
|
87
87
|
always returns None — the key is not persisted server-side.
|
|
88
|
+
|
|
89
|
+
``service_account`` names the service account that created or claimed
|
|
90
|
+
the host, ``"admin"`` for an admin key, or None for an unclaimed warm
|
|
91
|
+
host.
|
|
88
92
|
"""
|
|
89
93
|
|
|
90
94
|
id: str
|
|
91
95
|
name: str
|
|
96
|
+
service_account: str | None
|
|
92
97
|
status: str
|
|
93
98
|
provider: str
|
|
94
99
|
image: str
|
|
@@ -108,6 +113,50 @@ class SandboxHost:
|
|
|
108
113
|
expires_at: str | None
|
|
109
114
|
|
|
110
115
|
|
|
116
|
+
@dataclass(frozen=True, kw_only=True)
|
|
117
|
+
class _Service:
|
|
118
|
+
"""The custom service fields of a secret. None is unset. An empty
|
|
119
|
+
``auth_prefix`` is set, and Drukbox reads it as no prefix."""
|
|
120
|
+
|
|
121
|
+
host: str | None = None
|
|
122
|
+
auth_variable: str | None = None
|
|
123
|
+
auth_header: str | None = None
|
|
124
|
+
auth_prefix: str | None = None
|
|
125
|
+
|
|
126
|
+
def get_service(self) -> dict[str, Any]:
|
|
127
|
+
fields = {
|
|
128
|
+
"host": self.host,
|
|
129
|
+
"auth_variable": self.auth_variable,
|
|
130
|
+
"auth_header": self.auth_header,
|
|
131
|
+
"auth_prefix": self.auth_prefix,
|
|
132
|
+
}
|
|
133
|
+
return {name: value for name, value in fields.items() if value is not None}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class Secret(_Service):
|
|
138
|
+
"""A credential the caller holds."""
|
|
139
|
+
|
|
140
|
+
value: str = field(repr=False)
|
|
141
|
+
|
|
142
|
+
def entry(self) -> dict[str, Any]:
|
|
143
|
+
return {**self.get_service(), "value": self.value}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class Issuer(_Service):
|
|
148
|
+
"""A credential Drukbox fetches from ``url`` with ``headers``, and again
|
|
149
|
+
every ``refresh`` when the answer carries no ``expires_at``."""
|
|
150
|
+
|
|
151
|
+
url: str
|
|
152
|
+
headers: dict[str, str] = field(repr=False)
|
|
153
|
+
refresh: str
|
|
154
|
+
|
|
155
|
+
def entry(self) -> dict[str, Any]:
|
|
156
|
+
issuer = {"url": self.url, "headers": dict(self.headers), "refresh": self.refresh}
|
|
157
|
+
return {**self.get_service(), "issuer": issuer}
|
|
158
|
+
|
|
159
|
+
|
|
111
160
|
@dataclass(frozen=True)
|
|
112
161
|
class SandboxTemplate:
|
|
113
162
|
"""Snapshot of a reusable sandbox template as returned by the service.
|
|
@@ -152,6 +201,15 @@ class DoctorReport:
|
|
|
152
201
|
checks: list[DoctorCheck]
|
|
153
202
|
|
|
154
203
|
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class ServiceAccount:
|
|
206
|
+
"""A name the service knows a caller by, and the token that caller
|
|
207
|
+
presents. The service returns the token once, at creation."""
|
|
208
|
+
|
|
209
|
+
name: str
|
|
210
|
+
token: str = field(repr=False)
|
|
211
|
+
|
|
212
|
+
|
|
155
213
|
@dataclass(frozen=True)
|
|
156
214
|
class HTTPProxy:
|
|
157
215
|
"""An account-bound HTTP proxy as returned by the service.
|
|
@@ -240,6 +298,7 @@ class SandboxAPI:
|
|
|
240
298
|
instance_type: str | None = None,
|
|
241
299
|
permanent: bool = False,
|
|
242
300
|
provider: str | None = None,
|
|
301
|
+
secrets: dict[str, Secret | Issuer] | None = None,
|
|
243
302
|
template: uuid.UUID | str | None = None,
|
|
244
303
|
) -> SandboxHost:
|
|
245
304
|
"""Provision a new host.
|
|
@@ -274,6 +333,9 @@ class SandboxAPI:
|
|
|
274
333
|
Lease: omit ``expires_at`` for the service's default lease, pass a
|
|
275
334
|
``datetime`` for an explicit expiry, or pass ``permanent=True`` for
|
|
276
335
|
a host the janitor never reaps.
|
|
336
|
+
|
|
337
|
+
``secrets`` maps a service name to a :class:`Secret` or an
|
|
338
|
+
:class:`Issuer`. The response carries no secret.
|
|
277
339
|
"""
|
|
278
340
|
|
|
279
341
|
if expires_at and permanent:
|
|
@@ -295,6 +357,8 @@ class SandboxAPI:
|
|
|
295
357
|
payload["instance_type"] = instance_type
|
|
296
358
|
if provider is not None:
|
|
297
359
|
payload["provider"] = provider
|
|
360
|
+
if secrets:
|
|
361
|
+
payload["secrets"] = {name: entry.entry() for name, entry in secrets.items()}
|
|
298
362
|
if template is not None:
|
|
299
363
|
payload["template"] = str(template)
|
|
300
364
|
|
|
@@ -351,6 +415,18 @@ class SandboxAPI:
|
|
|
351
415
|
async def delete_host(self, host_id: uuid.UUID | str) -> None:
|
|
352
416
|
await self._request("DELETE", f"/hosts/{host_id}")
|
|
353
417
|
|
|
418
|
+
async def refresh_secret(self, host_id: uuid.UUID | str, service: str) -> None:
|
|
419
|
+
"""Make the service forget the current value of ``service`` on the
|
|
420
|
+
host and fetch a new one from its issuer.
|
|
421
|
+
|
|
422
|
+
Raises :class:`SandboxNotFoundError` for an unknown host or secret,
|
|
423
|
+
:class:`SandboxConflictError` for a secret with a static value, and
|
|
424
|
+
:class:`SandboxUnavailableError` when the exchange or the issuer did
|
|
425
|
+
not answer.
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
await self._request("POST", f"/hosts/{host_id}/secrets/{service}/refresh")
|
|
429
|
+
|
|
354
430
|
async def create_template(
|
|
355
431
|
self,
|
|
356
432
|
*,
|
|
@@ -416,6 +492,27 @@ class SandboxAPI:
|
|
|
416
492
|
checks=[DoctorCheck(**check) for check in data["checks"]],
|
|
417
493
|
)
|
|
418
494
|
|
|
495
|
+
async def create_service_account(self, name: str) -> ServiceAccount:
|
|
496
|
+
"""Create a service account and return its token.
|
|
497
|
+
|
|
498
|
+
Only an admin key from the service's ``SERVICE_TOKENS`` may call
|
|
499
|
+
this. A name that already exists raises
|
|
500
|
+
:class:`SandboxConflictError`; an invalid name raises
|
|
501
|
+
:class:`SandboxValidationError`.
|
|
502
|
+
"""
|
|
503
|
+
|
|
504
|
+
data = await self._request("POST", "/service-accounts", json={"name": name})
|
|
505
|
+
return ServiceAccount(**data)
|
|
506
|
+
|
|
507
|
+
async def remove_service_account(self, name: str) -> None:
|
|
508
|
+
"""Revoke a service account's token and remove the account.
|
|
509
|
+
|
|
510
|
+
An unknown name raises :class:`SandboxNotFoundError`; the ``admin``
|
|
511
|
+
account raises :class:`SandboxConflictError`.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
await self._request("DELETE", f"/service-accounts/{name}")
|
|
515
|
+
|
|
419
516
|
async def create_http_proxy(
|
|
420
517
|
self,
|
|
421
518
|
*,
|
drukbox_sdk/exceptions.py
CHANGED
|
@@ -37,8 +37,8 @@ class SandboxNotFoundError(SandboxAPIError):
|
|
|
37
37
|
class SandboxConflictError(SandboxAPIError):
|
|
38
38
|
"""409 — the operation conflicts with the current state.
|
|
39
39
|
|
|
40
|
-
e.g.
|
|
41
|
-
the
|
|
40
|
+
e.g. a template that is still building, or secrets on a deployment
|
|
41
|
+
without the secrets proxy configured.
|
|
42
42
|
"""
|
|
43
43
|
|
|
44
44
|
|
|
@@ -53,9 +53,10 @@ class SandboxProvisioningError(SandboxAPIError):
|
|
|
53
53
|
class SandboxValidationError(SandboxAPIError):
|
|
54
54
|
"""422 — the service rejected the request payload as invalid.
|
|
55
55
|
|
|
56
|
-
e.g. an env key that isn't a valid environment-variable name,
|
|
57
|
-
proxy target URL carrying a path or credentials
|
|
58
|
-
|
|
56
|
+
e.g. an env key that isn't a valid environment-variable name, a
|
|
57
|
+
proxy target URL carrying a path or credentials, or a secret for a
|
|
58
|
+
service name Drukbox does not know. The request is malformed, so
|
|
59
|
+
retrying it unchanged won't help — fix the input.
|
|
59
60
|
"""
|
|
60
61
|
|
|
61
62
|
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
drukbox_sdk/__init__.py,sha256=8gZrxZhT8ivzDFZmTcnN5PbwL_kwRwHluJOQHfJJeaI,918
|
|
2
|
-
drukbox_sdk/api.py,sha256=kO8qOaxLZ3How5b-yJNW51A7JjnVt7jP4BDf4kgnv3A,20655
|
|
3
|
-
drukbox_sdk/exceptions.py,sha256=YecxsuwpHhwzgw-_h9KbXf1WqY6R0JIJrwPY3Eg4e6I,2146
|
|
4
|
-
drukbox_python_sdk-0.1.0.dist-info/METADATA,sha256=mOJsJfO_rokCOmynC-C9DbdGqpqkYD0elzzRoCX6vzc,4943
|
|
5
|
-
drukbox_python_sdk-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
-
drukbox_python_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=zM66rWL3BWGJBa_mZPnM0WUomSSZ1xtWVtGQovQ71M8,1078
|
|
7
|
-
drukbox_python_sdk-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|