boax-py 0.1.0__tar.gz
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.
- boax_py-0.1.0/.gitignore +4 -0
- boax_py-0.1.0/PKG-INFO +156 -0
- boax_py-0.1.0/README.md +146 -0
- boax_py-0.1.0/boax/__init__.py +22 -0
- boax_py-0.1.0/boax/_admin.py +179 -0
- boax_py-0.1.0/boax/_client.py +71 -0
- boax_py-0.1.0/boax/_errors.py +74 -0
- boax_py-0.1.0/boax/_http.py +199 -0
- boax_py-0.1.0/boax/_resources.py +181 -0
- boax_py-0.1.0/boax/_sandbox.py +384 -0
- boax_py-0.1.0/boax/openenv.py +147 -0
- boax_py-0.1.0/boax/py.typed +0 -0
- boax_py-0.1.0/boax/types.py +446 -0
- boax_py-0.1.0/pyproject.toml +25 -0
- boax_py-0.1.0/tests/mock_server.py +160 -0
- boax_py-0.1.0/tests/test_client.py +208 -0
- boax_py-0.1.0/tests/test_sandboxes.py +254 -0
boax_py-0.1.0/.gitignore
ADDED
boax_py-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: boax-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Boax, the self-hosted sandbox and microVM platform.
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
7
|
+
Classifier: Typing :: Typed
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# boax
|
|
12
|
+
|
|
13
|
+
Python SDK for Boax, the self-hosted sandbox and microVM
|
|
14
|
+
platform. Python 3.9 or newer, standard library only.
|
|
15
|
+
|
|
16
|
+
Your code asks for a profile, capabilities and a placement policy. Boax
|
|
17
|
+
picks the pool; the SDK never needs to know which backend runs the sandbox.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pip install boax
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Until the package is published, install from a checkout: `pip install ./sdk/python`.
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from boax import Boax
|
|
31
|
+
|
|
32
|
+
# Defaults: BOAX_API_KEY and BOAX_URL (or http://localhost:8080).
|
|
33
|
+
client = Boax()
|
|
34
|
+
|
|
35
|
+
# Waits until the sandbox is running; pass wait=False to return at once.
|
|
36
|
+
sandbox = client.sandboxes.create(
|
|
37
|
+
profile="small",
|
|
38
|
+
placement={"strategy": "private-first", "external": "never"},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
result = sandbox.exec("uname -sm") # a string runs via /bin/sh -c
|
|
42
|
+
print(result.exit_code, result.stdout)
|
|
43
|
+
sandbox.exec(["ls", "-la", "/work"], cwd="/work", timeout_seconds=30) # a list runs directly
|
|
44
|
+
|
|
45
|
+
sandbox.files.write("/work/a.txt", "hi")
|
|
46
|
+
text = sandbox.files.read_text("/work/a.txt")
|
|
47
|
+
|
|
48
|
+
sandbox.delete()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A sandbox is also a context manager that deletes itself on exit:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
with client.sandboxes.create(profile="small") as sandbox:
|
|
55
|
+
print(sandbox.exec("uname -a").stdout)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Streaming output
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import sys
|
|
62
|
+
|
|
63
|
+
for ev in sandbox.exec_stream("make test"):
|
|
64
|
+
if ev.type == "stdout":
|
|
65
|
+
sys.stdout.write(ev.data)
|
|
66
|
+
elif ev.type == "stderr":
|
|
67
|
+
sys.stderr.write(ev.data)
|
|
68
|
+
elif ev.type == "exit":
|
|
69
|
+
print("exit code", ev.exit_code)
|
|
70
|
+
else:
|
|
71
|
+
print("backend error:", ev.error)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The command starts when `exec_stream` is called. The iterator ends after
|
|
75
|
+
`exit` or `error`. If the connection drops before either arrives, it raises a
|
|
76
|
+
`connection_error`.
|
|
77
|
+
|
|
78
|
+
## Errors
|
|
79
|
+
|
|
80
|
+
Every failure is a `BoaxError` with `code`, `status`, `details` and
|
|
81
|
+
`request_id`. Branch on `code`, not on the message.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from boax import BoaxError
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
client.sandboxes.create(profile="gpu-large")
|
|
88
|
+
except BoaxError as err:
|
|
89
|
+
if err.code != "quota_exceeded":
|
|
90
|
+
raise
|
|
91
|
+
print("over quota on", err.details.get("dimension"), "request", err.request_id)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Server codes include `invalid_request`, `unauthenticated`, `permission_denied`,
|
|
95
|
+
`not_found`, `conflict`, `quota_exceeded`, `no_eligible_pool`,
|
|
96
|
+
`unsupported_capability`, `unsupported_operation`, `provider_unavailable`,
|
|
97
|
+
`invalid_state`, `rate_limited`, `suspended` and `internal`. The SDK adds
|
|
98
|
+
`timeout` and `connection_error` (with `status` 0). Waiting raises
|
|
99
|
+
`invalid_state` if the sandbox fails or terminates first.
|
|
100
|
+
|
|
101
|
+
**Retries.** GET, PUT and DELETE requests, and sandbox creation, are retried
|
|
102
|
+
up to `max_retries` times (default 3). Retries happen on 503, on 429
|
|
103
|
+
`rate_limited` (never `quota_exceeded`) and on connection errors, with
|
|
104
|
+
exponential backoff that respects `Retry-After`. Each `create` call sends its
|
|
105
|
+
own `Idempotency-Key`, so a retried create never makes a second sandbox. Pass
|
|
106
|
+
`idempotency_key` to keep the key stable across process restarts. Exec
|
|
107
|
+
and other POSTs are never retried.
|
|
108
|
+
|
|
109
|
+
## What's covered
|
|
110
|
+
|
|
111
|
+
| Namespace | Methods |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `sandboxes` | `create`, `get`, `list`, `iterate`, `update`, `delete`, `wait` |
|
|
114
|
+
| `Sandbox` handle | `exec`, `exec_stream`, `files.list/read/read_text/write`, `stop`, `start`, `pause`, `resume`, `snapshot`, `fork`, `expose_port`, `explain_placement`, `refresh`, `update`, `wait`, `delete` |
|
|
115
|
+
| `snapshots` | `list`, `get`, `delete` |
|
|
116
|
+
| `projects` | `list`, `create`, `get`, `delete` |
|
|
117
|
+
| `api_keys` | `list`, `create`, `revoke` |
|
|
118
|
+
| `quotas`, `usage` | `get` |
|
|
119
|
+
| `audit_events` | `list`, `iterate` |
|
|
120
|
+
| `policies` | `list`, `put`, `delete` |
|
|
121
|
+
| `capabilities`, `profiles`, `templates`, `classifications` | `list` |
|
|
122
|
+
| `admin` | `organizations.{list,create,get,update,suspend,resume}`, `projects.set_quota`, `pools.{list,get,create,update,drain,undrain,delete}`, `agents.list`, `trust_report`, `verify_audit`, `terminate_sandbox`, `reconcile` |
|
|
123
|
+
| client | `me`, `version` |
|
|
124
|
+
|
|
125
|
+
Keyword arguments are snake_case. Records come back as plain dicts keyed exactly
|
|
126
|
+
like the API's JSON (camelCase), typed with `TypedDict` in `boax.types`.
|
|
127
|
+
`ExecResult`, `ExecEvent` and `Page` are dataclasses.
|
|
128
|
+
|
|
129
|
+
`list` returns one `Page` (`items`, `next_cursor`); `iterate` walks every page:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
for sb in client.sandboxes.iterate(status="running", labels={"team": "ml"}):
|
|
133
|
+
print(sb.id)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Options
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
Boax(
|
|
140
|
+
api_key="dly_...", # default: BOAX_API_KEY
|
|
141
|
+
base_url="https://...", # default: BOAX_URL, then http://localhost:8080
|
|
142
|
+
timeout=120.0, # socket timeout in seconds; waits and streams set their own
|
|
143
|
+
max_retries=3,
|
|
144
|
+
headers={}, # extra headers on every request
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Plain `exec` uses the client timeout unless you pass `timeout_seconds`. For long
|
|
149
|
+
commands, pass `timeout_seconds` or use `exec_stream`, which has no socket
|
|
150
|
+
timeout.
|
|
151
|
+
|
|
152
|
+
## Development
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
python3 -m unittest discover -s tests # runs against an in-process http.server
|
|
156
|
+
```
|
boax_py-0.1.0/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# boax
|
|
2
|
+
|
|
3
|
+
Python SDK for Boax, the self-hosted sandbox and microVM
|
|
4
|
+
platform. Python 3.9 or newer, standard library only.
|
|
5
|
+
|
|
6
|
+
Your code asks for a profile, capabilities and a placement policy. Boax
|
|
7
|
+
picks the pool; the SDK never needs to know which backend runs the sandbox.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install boax
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Until the package is published, install from a checkout: `pip install ./sdk/python`.
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from boax import Boax
|
|
21
|
+
|
|
22
|
+
# Defaults: BOAX_API_KEY and BOAX_URL (or http://localhost:8080).
|
|
23
|
+
client = Boax()
|
|
24
|
+
|
|
25
|
+
# Waits until the sandbox is running; pass wait=False to return at once.
|
|
26
|
+
sandbox = client.sandboxes.create(
|
|
27
|
+
profile="small",
|
|
28
|
+
placement={"strategy": "private-first", "external": "never"},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
result = sandbox.exec("uname -sm") # a string runs via /bin/sh -c
|
|
32
|
+
print(result.exit_code, result.stdout)
|
|
33
|
+
sandbox.exec(["ls", "-la", "/work"], cwd="/work", timeout_seconds=30) # a list runs directly
|
|
34
|
+
|
|
35
|
+
sandbox.files.write("/work/a.txt", "hi")
|
|
36
|
+
text = sandbox.files.read_text("/work/a.txt")
|
|
37
|
+
|
|
38
|
+
sandbox.delete()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A sandbox is also a context manager that deletes itself on exit:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
with client.sandboxes.create(profile="small") as sandbox:
|
|
45
|
+
print(sandbox.exec("uname -a").stdout)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Streaming output
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import sys
|
|
52
|
+
|
|
53
|
+
for ev in sandbox.exec_stream("make test"):
|
|
54
|
+
if ev.type == "stdout":
|
|
55
|
+
sys.stdout.write(ev.data)
|
|
56
|
+
elif ev.type == "stderr":
|
|
57
|
+
sys.stderr.write(ev.data)
|
|
58
|
+
elif ev.type == "exit":
|
|
59
|
+
print("exit code", ev.exit_code)
|
|
60
|
+
else:
|
|
61
|
+
print("backend error:", ev.error)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The command starts when `exec_stream` is called. The iterator ends after
|
|
65
|
+
`exit` or `error`. If the connection drops before either arrives, it raises a
|
|
66
|
+
`connection_error`.
|
|
67
|
+
|
|
68
|
+
## Errors
|
|
69
|
+
|
|
70
|
+
Every failure is a `BoaxError` with `code`, `status`, `details` and
|
|
71
|
+
`request_id`. Branch on `code`, not on the message.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from boax import BoaxError
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
client.sandboxes.create(profile="gpu-large")
|
|
78
|
+
except BoaxError as err:
|
|
79
|
+
if err.code != "quota_exceeded":
|
|
80
|
+
raise
|
|
81
|
+
print("over quota on", err.details.get("dimension"), "request", err.request_id)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Server codes include `invalid_request`, `unauthenticated`, `permission_denied`,
|
|
85
|
+
`not_found`, `conflict`, `quota_exceeded`, `no_eligible_pool`,
|
|
86
|
+
`unsupported_capability`, `unsupported_operation`, `provider_unavailable`,
|
|
87
|
+
`invalid_state`, `rate_limited`, `suspended` and `internal`. The SDK adds
|
|
88
|
+
`timeout` and `connection_error` (with `status` 0). Waiting raises
|
|
89
|
+
`invalid_state` if the sandbox fails or terminates first.
|
|
90
|
+
|
|
91
|
+
**Retries.** GET, PUT and DELETE requests, and sandbox creation, are retried
|
|
92
|
+
up to `max_retries` times (default 3). Retries happen on 503, on 429
|
|
93
|
+
`rate_limited` (never `quota_exceeded`) and on connection errors, with
|
|
94
|
+
exponential backoff that respects `Retry-After`. Each `create` call sends its
|
|
95
|
+
own `Idempotency-Key`, so a retried create never makes a second sandbox. Pass
|
|
96
|
+
`idempotency_key` to keep the key stable across process restarts. Exec
|
|
97
|
+
and other POSTs are never retried.
|
|
98
|
+
|
|
99
|
+
## What's covered
|
|
100
|
+
|
|
101
|
+
| Namespace | Methods |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `sandboxes` | `create`, `get`, `list`, `iterate`, `update`, `delete`, `wait` |
|
|
104
|
+
| `Sandbox` handle | `exec`, `exec_stream`, `files.list/read/read_text/write`, `stop`, `start`, `pause`, `resume`, `snapshot`, `fork`, `expose_port`, `explain_placement`, `refresh`, `update`, `wait`, `delete` |
|
|
105
|
+
| `snapshots` | `list`, `get`, `delete` |
|
|
106
|
+
| `projects` | `list`, `create`, `get`, `delete` |
|
|
107
|
+
| `api_keys` | `list`, `create`, `revoke` |
|
|
108
|
+
| `quotas`, `usage` | `get` |
|
|
109
|
+
| `audit_events` | `list`, `iterate` |
|
|
110
|
+
| `policies` | `list`, `put`, `delete` |
|
|
111
|
+
| `capabilities`, `profiles`, `templates`, `classifications` | `list` |
|
|
112
|
+
| `admin` | `organizations.{list,create,get,update,suspend,resume}`, `projects.set_quota`, `pools.{list,get,create,update,drain,undrain,delete}`, `agents.list`, `trust_report`, `verify_audit`, `terminate_sandbox`, `reconcile` |
|
|
113
|
+
| client | `me`, `version` |
|
|
114
|
+
|
|
115
|
+
Keyword arguments are snake_case. Records come back as plain dicts keyed exactly
|
|
116
|
+
like the API's JSON (camelCase), typed with `TypedDict` in `boax.types`.
|
|
117
|
+
`ExecResult`, `ExecEvent` and `Page` are dataclasses.
|
|
118
|
+
|
|
119
|
+
`list` returns one `Page` (`items`, `next_cursor`); `iterate` walks every page:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
for sb in client.sandboxes.iterate(status="running", labels={"team": "ml"}):
|
|
123
|
+
print(sb.id)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Options
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
Boax(
|
|
130
|
+
api_key="dly_...", # default: BOAX_API_KEY
|
|
131
|
+
base_url="https://...", # default: BOAX_URL, then http://localhost:8080
|
|
132
|
+
timeout=120.0, # socket timeout in seconds; waits and streams set their own
|
|
133
|
+
max_retries=3,
|
|
134
|
+
headers={}, # extra headers on every request
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Plain `exec` uses the client timeout unless you pass `timeout_seconds`. For long
|
|
139
|
+
commands, pass `timeout_seconds` or use `exec_stream`, which has no socket
|
|
140
|
+
timeout.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
python3 -m unittest discover -s tests # runs against an in-process http.server
|
|
146
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Official Python SDK for Boax, the self-hosted sandbox and microVM platform."""
|
|
2
|
+
|
|
3
|
+
from ._client import DEFAULT_BASE_URL, Boax
|
|
4
|
+
from ._errors import BoaxError
|
|
5
|
+
from ._http import __version__
|
|
6
|
+
from ._sandbox import Sandbox, Sandboxes, SandboxFiles
|
|
7
|
+
from .openenv import BoaxEnvironment
|
|
8
|
+
from .types import ExecEvent, ExecResult, Page
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DEFAULT_BASE_URL",
|
|
12
|
+
"Boax",
|
|
13
|
+
"BoaxError",
|
|
14
|
+
"BoaxEnvironment",
|
|
15
|
+
"ExecEvent",
|
|
16
|
+
"ExecResult",
|
|
17
|
+
"Page",
|
|
18
|
+
"Sandbox",
|
|
19
|
+
"SandboxFiles",
|
|
20
|
+
"Sandboxes",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
from ._http import HttpClient, path_segment
|
|
6
|
+
from ._sandbox import compact
|
|
7
|
+
from .types import (
|
|
8
|
+
AuditVerifyResult,
|
|
9
|
+
CostModel,
|
|
10
|
+
CreateOrganizationResult,
|
|
11
|
+
CredentialRef,
|
|
12
|
+
Organization,
|
|
13
|
+
Pool,
|
|
14
|
+
Project,
|
|
15
|
+
Quota,
|
|
16
|
+
Secret,
|
|
17
|
+
UsePoolResult,
|
|
18
|
+
SandboxInfo,
|
|
19
|
+
SuspendOrganizationResult,
|
|
20
|
+
TrustReport,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AdminOrganizations:
|
|
25
|
+
def __init__(self, http: HttpClient) -> None:
|
|
26
|
+
self._http = http
|
|
27
|
+
|
|
28
|
+
def list(self) -> List[Organization]:
|
|
29
|
+
return self._http.json("GET", "/v1/admin/organizations").get("items") or []
|
|
30
|
+
|
|
31
|
+
def create(
|
|
32
|
+
self, name: str, *, quota: Optional[Quota] = None, default_project: Optional[str] = None
|
|
33
|
+
) -> CreateOrganizationResult:
|
|
34
|
+
"""Creates an organization, plus a project named ``default_project`` if given."""
|
|
35
|
+
body = compact(name=name, quota=quota, defaultProject=default_project)
|
|
36
|
+
return self._http.json("POST", "/v1/admin/organizations", json=body)
|
|
37
|
+
|
|
38
|
+
def get(self, org_id: str) -> Organization:
|
|
39
|
+
return self._http.json("GET", f"/v1/admin/organizations/{path_segment(org_id)}")
|
|
40
|
+
|
|
41
|
+
def update(self, org_id: str, *, name: Optional[str] = None, quota: Optional[Quota] = None) -> Organization:
|
|
42
|
+
body = compact(name=name, quota=quota)
|
|
43
|
+
return self._http.json("PATCH", f"/v1/admin/organizations/{path_segment(org_id)}", json=body)
|
|
44
|
+
|
|
45
|
+
def suspend(
|
|
46
|
+
self, org_id: str, *, terminate: bool = False, reason: Optional[str] = None
|
|
47
|
+
) -> SuspendOrganizationResult:
|
|
48
|
+
"""Blocks new work for the organization; ``terminate=True`` also ends its sandboxes."""
|
|
49
|
+
body = compact(terminate=terminate or None, reason=reason)
|
|
50
|
+
return self._http.json("POST", f"/v1/admin/organizations/{path_segment(org_id)}/suspend", json=body)
|
|
51
|
+
|
|
52
|
+
def resume(self, org_id: str, *, reason: Optional[str] = None) -> SuspendOrganizationResult:
|
|
53
|
+
body = compact(reason=reason)
|
|
54
|
+
return self._http.json("POST", f"/v1/admin/organizations/{path_segment(org_id)}/resume", json=body)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AdminProjects:
|
|
58
|
+
def __init__(self, http: HttpClient) -> None:
|
|
59
|
+
self._http = http
|
|
60
|
+
|
|
61
|
+
def set_quota(self, project_id: str, quota: Quota) -> Project:
|
|
62
|
+
"""Replaces a project's quota."""
|
|
63
|
+
return self._http.json("PATCH", f"/v1/admin/projects/{path_segment(project_id)}", json={"quota": quota})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AdminPools:
|
|
67
|
+
def __init__(self, http: HttpClient) -> None:
|
|
68
|
+
self._http = http
|
|
69
|
+
|
|
70
|
+
def list(self) -> List[Pool]:
|
|
71
|
+
return self._http.json("GET", "/v1/admin/pools").get("items") or []
|
|
72
|
+
|
|
73
|
+
def get(self, name: str) -> Pool:
|
|
74
|
+
return self._http.json("GET", f"/v1/admin/pools/{path_segment(name)}")
|
|
75
|
+
|
|
76
|
+
def create(
|
|
77
|
+
self,
|
|
78
|
+
name: str,
|
|
79
|
+
adapter: str,
|
|
80
|
+
*,
|
|
81
|
+
trust: Optional[str] = None,
|
|
82
|
+
region: Optional[str] = None,
|
|
83
|
+
labels: Optional[Mapping[str, str]] = None,
|
|
84
|
+
enabled: Optional[bool] = None,
|
|
85
|
+
max_concurrency: Optional[int] = None,
|
|
86
|
+
cost_model: Optional[CostModel] = None,
|
|
87
|
+
config: Optional[Mapping[str, str]] = None,
|
|
88
|
+
credentials: Optional[CredentialRef] = None,
|
|
89
|
+
) -> Pool:
|
|
90
|
+
"""Registers a pool at runtime. ``credentials`` names a server env var, file or stored ``secret``, never a value."""
|
|
91
|
+
body = compact(
|
|
92
|
+
name=name,
|
|
93
|
+
adapter=adapter,
|
|
94
|
+
trust=trust,
|
|
95
|
+
region=region,
|
|
96
|
+
labels=dict(labels) if labels is not None else None,
|
|
97
|
+
enabled=enabled,
|
|
98
|
+
maxConcurrency=max_concurrency,
|
|
99
|
+
costModel=cost_model,
|
|
100
|
+
config=dict(config) if config is not None else None,
|
|
101
|
+
credentials=credentials,
|
|
102
|
+
)
|
|
103
|
+
return self._http.json("POST", "/v1/admin/pools", json=body)
|
|
104
|
+
|
|
105
|
+
def update(
|
|
106
|
+
self,
|
|
107
|
+
name: str,
|
|
108
|
+
*,
|
|
109
|
+
enabled: Optional[bool] = None,
|
|
110
|
+
max_concurrency: Optional[int] = None,
|
|
111
|
+
cost_model: Optional[CostModel] = None,
|
|
112
|
+
region: Optional[str] = None,
|
|
113
|
+
credential_ref: Optional[str] = None,
|
|
114
|
+
) -> Pool:
|
|
115
|
+
"""``credential_ref`` (API-registered pools only) is ``env:NAME``, ``file:/path`` or ``secret:name``."""
|
|
116
|
+
body = compact(enabled=enabled, maxConcurrency=max_concurrency, costModel=cost_model, region=region, credentialRef=credential_ref)
|
|
117
|
+
return self._http.json("PATCH", f"/v1/admin/pools/{path_segment(name)}", json=body)
|
|
118
|
+
|
|
119
|
+
def drain(self, name: str) -> Pool:
|
|
120
|
+
"""Stops new placements on the pool; running sandboxes continue."""
|
|
121
|
+
return self._http.json("POST", f"/v1/admin/pools/{path_segment(name)}/drain")
|
|
122
|
+
|
|
123
|
+
def undrain(self, name: str) -> Pool:
|
|
124
|
+
return self._http.json("POST", f"/v1/admin/pools/{path_segment(name)}/undrain")
|
|
125
|
+
|
|
126
|
+
def use(self, name: str, *, approve_external: bool = False) -> UsePoolResult:
|
|
127
|
+
"""Sends new sandboxes to this pool and drains every other one; running sandboxes stay.
|
|
128
|
+
An external pool must be approved by the global policy, or approved here."""
|
|
129
|
+
return self._http.json("POST", f"/v1/admin/pools/{path_segment(name)}/use", json=compact(approveExternal=approve_external or None))
|
|
130
|
+
|
|
131
|
+
def delete(self, name: str) -> None:
|
|
132
|
+
"""Removes an API-registered pool. Pools from the config file cannot be deleted here."""
|
|
133
|
+
self._http.json("DELETE", f"/v1/admin/pools/{path_segment(name)}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class AdminSecrets:
|
|
137
|
+
"""Provider credentials stored in Boax, sealed with the server's secrets key."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, http: HttpClient) -> None:
|
|
140
|
+
self._http = http
|
|
141
|
+
|
|
142
|
+
def list(self) -> List[Secret]:
|
|
143
|
+
return self._http.json("GET", "/v1/admin/secrets").get("items") or []
|
|
144
|
+
|
|
145
|
+
def put(self, name: str, value: str) -> Secret:
|
|
146
|
+
"""Stores or replaces a value. Pools using it switch to the new value without a restart."""
|
|
147
|
+
return self._http.json("PUT", f"/v1/admin/secrets/{path_segment(name)}", json={"value": value})
|
|
148
|
+
|
|
149
|
+
def delete(self, name: str) -> None:
|
|
150
|
+
"""Fails while a pool uses the secret."""
|
|
151
|
+
self._http.json("DELETE", f"/v1/admin/secrets/{path_segment(name)}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Admin:
|
|
155
|
+
"""Operator-only endpoints. Every call needs a key with the ``admin`` scope."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, http: HttpClient) -> None:
|
|
158
|
+
self._http = http
|
|
159
|
+
self.organizations = AdminOrganizations(http)
|
|
160
|
+
self.projects = AdminProjects(http)
|
|
161
|
+
self.pools = AdminPools(http)
|
|
162
|
+
self.secrets = AdminSecrets(http)
|
|
163
|
+
|
|
164
|
+
def trust_report(self) -> TrustReport:
|
|
165
|
+
"""Where workloads can go, where they went, and what Boax cannot prove."""
|
|
166
|
+
return self._http.json("GET", "/v1/admin/trust-report")
|
|
167
|
+
|
|
168
|
+
def verify_audit(self) -> AuditVerifyResult:
|
|
169
|
+
"""Verifies the hash chain of the whole audit log."""
|
|
170
|
+
return self._http.json("GET", "/v1/admin/audit/verify")
|
|
171
|
+
|
|
172
|
+
def terminate_sandbox(self, sandbox_id: str, *, reason: Optional[str] = None) -> SandboxInfo:
|
|
173
|
+
"""Terminates any tenant's sandbox."""
|
|
174
|
+
body: Any = compact(reason=reason)
|
|
175
|
+
return self._http.json("POST", f"/v1/admin/sandboxes/{path_segment(sandbox_id)}/terminate", json=body)
|
|
176
|
+
|
|
177
|
+
def reconcile(self) -> None:
|
|
178
|
+
"""Runs one scheduler reconcile pass now."""
|
|
179
|
+
self._http.json("POST", "/v1/admin/reconcile")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Mapping, Optional
|
|
5
|
+
|
|
6
|
+
from ._admin import Admin
|
|
7
|
+
from ._errors import BoaxError
|
|
8
|
+
from ._http import HttpClient
|
|
9
|
+
from ._resources import ApiKeys, AuditEvents, Listing, Policies, Projects, Quotas, Snapshots, Usage
|
|
10
|
+
from ._sandbox import Sandboxes
|
|
11
|
+
from .types import Classification, PoolCapabilities, Principal, Profile, Template, VersionInfo
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "http://localhost:8080"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Boax:
|
|
17
|
+
"""Client for the Boax /v1 API.
|
|
18
|
+
|
|
19
|
+
``api_key`` defaults to ``BOAX_API_KEY`` and ``base_url`` to
|
|
20
|
+
``BOAX_URL``, then http://localhost:8080. ``timeout`` is the socket
|
|
21
|
+
timeout in seconds; long polls and streams size their own. Idempotent
|
|
22
|
+
requests are retried up to ``max_retries`` times on 429 (rate limited),
|
|
23
|
+
503 and connection errors.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: Optional[str] = None,
|
|
29
|
+
*,
|
|
30
|
+
base_url: Optional[str] = None,
|
|
31
|
+
timeout: float = 120.0,
|
|
32
|
+
max_retries: int = 3,
|
|
33
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
api_key = api_key or os.environ.get("BOAX_API_KEY")
|
|
36
|
+
if not api_key:
|
|
37
|
+
raise BoaxError("unauthenticated", "missing API key: pass api_key or set BOAX_API_KEY")
|
|
38
|
+
self._http = HttpClient(
|
|
39
|
+
api_key=api_key,
|
|
40
|
+
base_url=base_url or os.environ.get("BOAX_URL") or DEFAULT_BASE_URL,
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
max_retries=max_retries,
|
|
43
|
+
headers=headers or {},
|
|
44
|
+
)
|
|
45
|
+
self.base_url = self._http.base_url
|
|
46
|
+
self.sandboxes = Sandboxes(self._http)
|
|
47
|
+
self.snapshots = Snapshots(self._http)
|
|
48
|
+
self.projects = Projects(self._http)
|
|
49
|
+
self.api_keys = ApiKeys(self._http)
|
|
50
|
+
self.quotas = Quotas(self._http)
|
|
51
|
+
self.usage = Usage(self._http)
|
|
52
|
+
self.audit_events = AuditEvents(self._http)
|
|
53
|
+
self.policies = Policies(self._http)
|
|
54
|
+
#: Pools as tenants see them: trust, region, health and supported features.
|
|
55
|
+
self.capabilities: Listing[PoolCapabilities] = Listing(self._http, "/v1/capabilities")
|
|
56
|
+
self.profiles: Listing[Profile] = Listing(self._http, "/v1/profiles")
|
|
57
|
+
self.templates: Listing[Template] = Listing(self._http, "/v1/templates")
|
|
58
|
+
self.classifications: Listing[Classification] = Listing(self._http, "/v1/classifications")
|
|
59
|
+
#: Operator-only endpoints.
|
|
60
|
+
self.admin = Admin(self._http)
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
return f"Boax(base_url={self.base_url!r})"
|
|
64
|
+
|
|
65
|
+
def me(self) -> Principal:
|
|
66
|
+
"""Describes the calling key: organization, project, scopes and operator status."""
|
|
67
|
+
return self._http.json("GET", "/v1/me")
|
|
68
|
+
|
|
69
|
+
def version(self) -> VersionInfo:
|
|
70
|
+
"""Server version and installation ID."""
|
|
71
|
+
return self._http.json("GET", "/version")
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Dict, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
_STATUS_CODES = {
|
|
7
|
+
400: "invalid_request",
|
|
8
|
+
401: "unauthenticated",
|
|
9
|
+
403: "permission_denied",
|
|
10
|
+
404: "not_found",
|
|
11
|
+
409: "conflict",
|
|
12
|
+
422: "no_eligible_pool",
|
|
13
|
+
429: "rate_limited",
|
|
14
|
+
501: "unsupported_operation",
|
|
15
|
+
503: "provider_unavailable",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BoaxError(Exception):
|
|
20
|
+
"""Every failure the SDK reports.
|
|
21
|
+
|
|
22
|
+
Branch on ``code``, never on the message. Server codes include
|
|
23
|
+
``invalid_request``, ``unauthenticated``, ``permission_denied``,
|
|
24
|
+
``not_found``, ``conflict``, ``quota_exceeded``, ``no_eligible_pool``,
|
|
25
|
+
``unsupported_capability``, ``unsupported_operation``,
|
|
26
|
+
``provider_unavailable``, ``invalid_state``, ``rate_limited``,
|
|
27
|
+
``suspended`` and ``internal``. The SDK itself raises ``timeout`` and
|
|
28
|
+
``connection_error`` with ``status`` 0.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
code: str,
|
|
34
|
+
message: str,
|
|
35
|
+
*,
|
|
36
|
+
status: int = 0,
|
|
37
|
+
details: Optional[Mapping[str, str]] = None,
|
|
38
|
+
request_id: Optional[str] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.code = code
|
|
42
|
+
self.message = message
|
|
43
|
+
self.status = status
|
|
44
|
+
self.details: Dict[str, str] = dict(details or {})
|
|
45
|
+
self.request_id = request_id
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
return f"{self.code}: {self.message}"
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
return f"BoaxError(code={self.code!r}, status={self.status}, message={self.message!r})"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def error_from_response(status: int, body: bytes, header_request_id: Optional[str]) -> BoaxError:
|
|
55
|
+
"""Builds an error from a non-2xx response, tolerating non-JSON bodies from proxies."""
|
|
56
|
+
err: dict = {}
|
|
57
|
+
try:
|
|
58
|
+
envelope = json.loads(body.decode("utf-8"))
|
|
59
|
+
if isinstance(envelope, dict) and isinstance(envelope.get("error"), dict):
|
|
60
|
+
err = envelope["error"]
|
|
61
|
+
except (ValueError, UnicodeDecodeError):
|
|
62
|
+
pass
|
|
63
|
+
message = err.get("message")
|
|
64
|
+
if not isinstance(message, str):
|
|
65
|
+
text = body.decode("utf-8", "replace")[:200]
|
|
66
|
+
message = f"HTTP {status}" + (f": {text}" if text else "")
|
|
67
|
+
details = err.get("details")
|
|
68
|
+
return BoaxError(
|
|
69
|
+
err["code"] if isinstance(err.get("code"), str) else _STATUS_CODES.get(status, "internal"),
|
|
70
|
+
message,
|
|
71
|
+
status=status,
|
|
72
|
+
details=details if isinstance(details, dict) else None,
|
|
73
|
+
request_id=err.get("requestId") or header_request_id,
|
|
74
|
+
)
|