stackbox 0.1.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.
- stackbox/__init__.py +9 -0
- stackbox/__main__.py +3 -0
- stackbox/app.py +405 -0
- stackbox/backends/__init__.py +107 -0
- stackbox/backends/base.py +181 -0
- stackbox/backends/cloudflare.py +241 -0
- stackbox/backends/daytona.py +306 -0
- stackbox/backends/e2b.py +241 -0
- stackbox/backends/hosted.py +536 -0
- stackbox/backends/modal.py +190 -0
- stackbox/backends/opensandbox.py +148 -0
- stackbox/backends/vercel.py +309 -0
- stackbox/catalog.py +156 -0
- stackbox/cli.py +873 -0
- stackbox/config.py +294 -0
- stackbox/execd.py +118 -0
- stackbox/ledger.py +387 -0
- stackbox/router.py +1049 -0
- stackbox/vcr.py +113 -0
- stackbox-0.1.0.dist-info/METADATA +288 -0
- stackbox-0.1.0.dist-info/RECORD +23 -0
- stackbox-0.1.0.dist-info/WHEEL +4 -0
- stackbox-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""What the router needs from a backend, and the errors backends raise.
|
|
2
|
+
|
|
3
|
+
Create failures are classified so the router can decide whether retrying elsewhere
|
|
4
|
+
is safe:
|
|
5
|
+
|
|
6
|
+
* ``clear`` no sandbox exists on the backend (or one was created and we already
|
|
7
|
+
cleaned it up) -> safe to try elsewhere
|
|
8
|
+
* ``ambiguous`` the request may have been processed -> reconcile before retrying
|
|
9
|
+
* ``client`` the request itself is invalid -> return to caller, no retry
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
16
|
+
|
|
17
|
+
from stackbox.config import BackendConfig
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from stackbox.ledger import SandboxRecord
|
|
21
|
+
|
|
22
|
+
GW_ID_KEY = "stackbox.dev/gw-id"
|
|
23
|
+
# Value: the owning gateway's instance id (one per ledger). The reconciler only ever
|
|
24
|
+
# deletes sandboxes carrying its own id, so gateways sharing a provider account (or a
|
|
25
|
+
# test gateway next to a production one) never delete each other's sandboxes.
|
|
26
|
+
MANAGED_KEY = "stackbox.dev/managed"
|
|
27
|
+
PIN_KEY = "stackbox.dev/backend"
|
|
28
|
+
# Per-request routing preferences as metadata (Harbor can set metadata, not headers).
|
|
29
|
+
STRATEGY_KEY = "stackbox.dev/strategy"
|
|
30
|
+
PROVIDERS_KEY = (
|
|
31
|
+
"stackbox.dev/providers" # allow-list in order: "e2b.modal" or "e2b,modal"
|
|
32
|
+
)
|
|
33
|
+
MAX_PRICE_KEY = "stackbox.dev/max-price" # USD per sandbox-hour
|
|
34
|
+
ROUTER_KEYS = (
|
|
35
|
+
GW_ID_KEY,
|
|
36
|
+
MANAGED_KEY,
|
|
37
|
+
PIN_KEY,
|
|
38
|
+
STRATEGY_KEY,
|
|
39
|
+
PROVIDERS_KEY,
|
|
40
|
+
MAX_PRICE_KEY,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
GONE_STATES = {"Terminated", "Failed", "Stopping"}
|
|
44
|
+
|
|
45
|
+
FailureKind = Literal["clear", "ambiguous", "client"]
|
|
46
|
+
|
|
47
|
+
# What a backend can do with an image right now.
|
|
48
|
+
# ready: prepared (or the backend pulls images itself and has no preparation step)
|
|
49
|
+
# cold: can serve it, but the first start includes an image build
|
|
50
|
+
# None: cannot serve it until it is prepared
|
|
51
|
+
ImageSupport = Literal["ready", "cold"] | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class UpstreamResponse:
|
|
56
|
+
status: int
|
|
57
|
+
body: Any
|
|
58
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Created:
|
|
63
|
+
"""A sandbox a backend created and reports as running."""
|
|
64
|
+
|
|
65
|
+
sandbox: dict[str, Any] # OpenSandbox Sandbox shape, upstream id, router metadata
|
|
66
|
+
cpu: float | None = None # what was actually allocated, if the backend knows
|
|
67
|
+
memory_gib: float | None = None
|
|
68
|
+
backend_data: dict[str, Any] | None = None # persisted; e.g. endpoint details
|
|
69
|
+
cold: bool = False # the start included an image build
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CreateFailure(Exception):
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
kind: FailureKind,
|
|
76
|
+
message: str,
|
|
77
|
+
status: int | None = None,
|
|
78
|
+
body: Any = None,
|
|
79
|
+
):
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.kind = kind
|
|
82
|
+
self.status = status
|
|
83
|
+
self.body = body
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class BackendError(Exception):
|
|
87
|
+
"""A non-create call to a backend failed (network, auth, 5xx)."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ReconcileError(BackendError):
|
|
91
|
+
"""The backend could not be queried, so an ambiguous create stays unresolved."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def error_body(code: str, message: str) -> dict[str, str]:
|
|
95
|
+
return {"code": code, "message": message}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Backend:
|
|
99
|
+
"""Interface every backend kind implements. Upstream ids never leave the router."""
|
|
100
|
+
|
|
101
|
+
kind: str = "abstract"
|
|
102
|
+
#: After an ambiguous create, a sandbox found upstream may be handed to the client.
|
|
103
|
+
#: Hosted backends set this False: a found sandbox may not have finished execd
|
|
104
|
+
#: setup, so it is deleted and the create retried instead.
|
|
105
|
+
adopt_ambiguous: bool = True
|
|
106
|
+
|
|
107
|
+
def __init__(self, cfg: BackendConfig, instance_id: str = "true"):
|
|
108
|
+
self.cfg = cfg
|
|
109
|
+
self.instance_id = instance_id
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def name(self) -> str:
|
|
113
|
+
return self.cfg.name
|
|
114
|
+
|
|
115
|
+
async def aclose(self) -> None:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
# --- placement ---------------------------------------------------------
|
|
119
|
+
def image_support(
|
|
120
|
+
self, image: str | None, cpu: float | None, memory_gib: float | None
|
|
121
|
+
) -> ImageSupport:
|
|
122
|
+
return "ready"
|
|
123
|
+
|
|
124
|
+
def allocated_resources(
|
|
125
|
+
self, image: str | None, cpu: float | None, memory_gib: float | None
|
|
126
|
+
) -> tuple[float, float]:
|
|
127
|
+
"""(vCPU, GiB) a create with these requests would be billed for."""
|
|
128
|
+
return (
|
|
129
|
+
cpu if cpu is not None else self.cfg.default_cpu,
|
|
130
|
+
memory_gib if memory_gib is not None else self.cfg.default_memory_gib,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def price_per_hour(
|
|
134
|
+
self, image: str | None, cpu: float | None, memory_gib: float | None
|
|
135
|
+
) -> float:
|
|
136
|
+
vcpu, gib = self.allocated_resources(image, cpu, memory_gib)
|
|
137
|
+
return (
|
|
138
|
+
vcpu * self.cfg.cost_per_vcpu_hour * self.cfg.cpu_billed_fraction
|
|
139
|
+
+ gib * self.cfg.cost_per_gib_hour
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def prepare(self, image: str) -> dict[str, Any]:
|
|
143
|
+
"""Make the image fast to start on this backend. Returns a summary."""
|
|
144
|
+
return {"backend": self.name, "image": image, "status": "ready", "ref": None}
|
|
145
|
+
|
|
146
|
+
# --- lifecycle ---------------------------------------------------------
|
|
147
|
+
async def create(self, body: dict[str, Any], *, gw_id: str) -> Created:
|
|
148
|
+
raise NotImplementedError
|
|
149
|
+
|
|
150
|
+
async def get(self, rec: SandboxRecord) -> UpstreamResponse:
|
|
151
|
+
raise NotImplementedError
|
|
152
|
+
|
|
153
|
+
async def delete(self, upstream_id: str) -> UpstreamResponse:
|
|
154
|
+
raise NotImplementedError
|
|
155
|
+
|
|
156
|
+
async def subresource(
|
|
157
|
+
self,
|
|
158
|
+
rec: SandboxRecord,
|
|
159
|
+
method: str,
|
|
160
|
+
rest: str,
|
|
161
|
+
*,
|
|
162
|
+
params: Any = None,
|
|
163
|
+
json: Any = None,
|
|
164
|
+
) -> UpstreamResponse:
|
|
165
|
+
raise NotImplementedError
|
|
166
|
+
|
|
167
|
+
async def list_sandboxes(
|
|
168
|
+
self, metadata: dict[str, str] | None = None
|
|
169
|
+
) -> list[dict[str, Any]]:
|
|
170
|
+
"""Sandboxes matching the metadata filter, in OpenSandbox shape. Raises BackendError."""
|
|
171
|
+
raise NotImplementedError
|
|
172
|
+
|
|
173
|
+
async def find_by_gw_id(self, gw_id: str) -> dict[str, Any] | None:
|
|
174
|
+
try:
|
|
175
|
+
found = await self.list_sandboxes({GW_ID_KEY: gw_id})
|
|
176
|
+
except BackendError as exc:
|
|
177
|
+
raise ReconcileError(f"{self.name}: {exc}") from exc
|
|
178
|
+
live = [
|
|
179
|
+
s for s in found if (s.get("status") or {}).get("state") not in GONE_STATES
|
|
180
|
+
]
|
|
181
|
+
return live[0] if live else None
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Cloudflare backend: Cloudflare Containers, through a bridge Worker you deploy.
|
|
2
|
+
|
|
3
|
+
Cloudflare has no hosted "create a sandbox" API: sandboxes run inside your own Worker.
|
|
4
|
+
`bridges/cloudflare/` is that Worker. It runs the task image plus execd on Cloudflare
|
|
5
|
+
Containers (the layer Cloudflare's Sandbox SDK is built on), exposes a small control
|
|
6
|
+
API to the gateway, and proxies execd traffic at ``<bridge>/x/<id>/...``, so no
|
|
7
|
+
wildcard custom domain is needed.
|
|
8
|
+
|
|
9
|
+
Config: ``url`` is the bridge (e.g. https://stackbox-bridge.<account>.workers.dev),
|
|
10
|
+
``api_key`` its BRIDGE_TOKEN. Images are fixed when the bridge is deployed (one container
|
|
11
|
+
class per image), so "preparing" an image only checks the bridge serves it; vCPU and
|
|
12
|
+
memory come from the class's instance type. Cloudflare bills CPU by active use only:
|
|
13
|
+
model that with ``cpu_billed_fraction``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from stackbox import ledger as L
|
|
25
|
+
from stackbox.backends.base import (
|
|
26
|
+
BackendError,
|
|
27
|
+
CreateFailure,
|
|
28
|
+
ReconcileError,
|
|
29
|
+
UpstreamResponse,
|
|
30
|
+
)
|
|
31
|
+
from stackbox.backends.hosted import (
|
|
32
|
+
EXECD_PORT,
|
|
33
|
+
HostedBackend,
|
|
34
|
+
Provisioned,
|
|
35
|
+
from_provider_tags,
|
|
36
|
+
iso,
|
|
37
|
+
parse_iso,
|
|
38
|
+
)
|
|
39
|
+
from stackbox.config import BackendConfig, GatewaySettings
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from stackbox.ledger import SandboxRecord
|
|
43
|
+
|
|
44
|
+
_NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CloudflareBackend(HostedBackend):
|
|
48
|
+
kind = "cloudflare"
|
|
49
|
+
unprepared_support = None # images are baked into the bridge deployment
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
cfg: BackendConfig,
|
|
54
|
+
*,
|
|
55
|
+
settings: GatewaySettings,
|
|
56
|
+
ledger: L.Ledger,
|
|
57
|
+
token_key: str,
|
|
58
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
59
|
+
):
|
|
60
|
+
super().__init__(cfg, settings=settings, ledger=ledger, token_key=token_key)
|
|
61
|
+
if not cfg.url:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"backend {cfg.name}: kind=cloudflare requires url (the bridge)"
|
|
64
|
+
)
|
|
65
|
+
parts = urlsplit(cfg.url.rstrip("/"))
|
|
66
|
+
self.scheme = parts.scheme or "https"
|
|
67
|
+
self.bridge_host = parts.netloc + parts.path # host[/prefix], no scheme
|
|
68
|
+
headers = {"authorization": f"Bearer {cfg.api_key}"} if cfg.api_key else {}
|
|
69
|
+
self._bridge = httpx.AsyncClient(
|
|
70
|
+
base_url=cfg.url.rstrip("/"),
|
|
71
|
+
headers=headers,
|
|
72
|
+
timeout=settings.request_timeout_s,
|
|
73
|
+
transport=transport,
|
|
74
|
+
)
|
|
75
|
+
self._images: tuple[float, dict[str, Any]] | None = None
|
|
76
|
+
|
|
77
|
+
async def aclose(self) -> None:
|
|
78
|
+
await self._bridge.aclose()
|
|
79
|
+
await super().aclose()
|
|
80
|
+
|
|
81
|
+
def sandbox_id(self, gw_id: str) -> str:
|
|
82
|
+
return f"stackbox-{gw_id}"
|
|
83
|
+
|
|
84
|
+
async def bridge_images(self) -> dict[str, Any]:
|
|
85
|
+
if self._images is None or time.monotonic() - self._images[0] > 60:
|
|
86
|
+
r = await self._bridge.get("/v1/images")
|
|
87
|
+
r.raise_for_status()
|
|
88
|
+
self._images = (time.monotonic(), r.json())
|
|
89
|
+
return self._images[1]
|
|
90
|
+
|
|
91
|
+
def allocated_resources(
|
|
92
|
+
self, image: str | None, cpu: float | None, memory_gib: float | None
|
|
93
|
+
) -> tuple[float, float]:
|
|
94
|
+
rec = self.image_record(image) if image else None
|
|
95
|
+
if rec is not None and rec.detail and "cpu" in rec.detail:
|
|
96
|
+
return float(rec.detail["cpu"]), float(rec.detail["memory_gib"])
|
|
97
|
+
return self.requested_resources(cpu, memory_gib)
|
|
98
|
+
|
|
99
|
+
async def build_image(self, image: str) -> tuple[str, dict[str, Any]]:
|
|
100
|
+
images = await self.bridge_images()
|
|
101
|
+
info = images.get(image)
|
|
102
|
+
if info is None:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
f"the bridge does not serve {image}; add a container class for it "
|
|
105
|
+
"(bridges/cloudflare/README.md) and redeploy"
|
|
106
|
+
)
|
|
107
|
+
return str(info["binding"]), {
|
|
108
|
+
"cpu": float(info["vcpu"]),
|
|
109
|
+
"memory_gib": float(info["memory_gib"]),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async def provision(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
gw_id: str,
|
|
116
|
+
image: str,
|
|
117
|
+
image_rec: L.ImageRecord | None,
|
|
118
|
+
cpu: float,
|
|
119
|
+
memory_gib: float,
|
|
120
|
+
timeout_s: int,
|
|
121
|
+
env: dict[str, str],
|
|
122
|
+
entrypoint: list[str] | None,
|
|
123
|
+
token: str,
|
|
124
|
+
) -> Provisioned:
|
|
125
|
+
sid = self.sandbox_id(gw_id)
|
|
126
|
+
body = {
|
|
127
|
+
"id": sid,
|
|
128
|
+
"image": image,
|
|
129
|
+
"env": env,
|
|
130
|
+
"token": token,
|
|
131
|
+
"ttl_s": timeout_s,
|
|
132
|
+
"labels": {"stackbox_gw_id": gw_id, "stackbox_managed": self.instance_id},
|
|
133
|
+
}
|
|
134
|
+
try:
|
|
135
|
+
r = await self._bridge.post(
|
|
136
|
+
"/v1/sandboxes", json=body, timeout=self._settings.create_timeout_s
|
|
137
|
+
)
|
|
138
|
+
except _NOT_SENT as exc:
|
|
139
|
+
raise CreateFailure("clear", f"{type(exc).__name__}: {exc}") from exc
|
|
140
|
+
except httpx.HTTPError as exc:
|
|
141
|
+
raise CreateFailure("ambiguous", f"{type(exc).__name__}: {exc}") from exc
|
|
142
|
+
if r.status_code == 201:
|
|
143
|
+
return Provisioned(
|
|
144
|
+
upstream_id=sid,
|
|
145
|
+
host=f"{self.bridge_host}/x/{sid}",
|
|
146
|
+
backend_data={"bridge_path": f"/x/{sid}"},
|
|
147
|
+
scheme=self.scheme,
|
|
148
|
+
)
|
|
149
|
+
detail = r.text[:300]
|
|
150
|
+
if r.status_code == 429:
|
|
151
|
+
raise CreateFailure("clear", f"Cloudflare at capacity: {detail}", 429)
|
|
152
|
+
if r.status_code == 404:
|
|
153
|
+
self._ledger.set_image(
|
|
154
|
+
self.name, image, L.IMAGE_FAILED, error=f"bridge: {detail}"[:500]
|
|
155
|
+
)
|
|
156
|
+
raise CreateFailure("clear", f"bridge does not serve {image}", 404)
|
|
157
|
+
if r.status_code in (400, 401, 403):
|
|
158
|
+
raise CreateFailure(
|
|
159
|
+
"clear", f"bridge rejected create: {detail}", r.status_code
|
|
160
|
+
)
|
|
161
|
+
if r.status_code == 502:
|
|
162
|
+
# The bridge reports a failed start after destroying the container.
|
|
163
|
+
raise CreateFailure("clear", f"container failed to start: {detail}", 502)
|
|
164
|
+
# 409 (id exists), other 5xx: it may exist; the router reconciles by id.
|
|
165
|
+
raise CreateFailure(
|
|
166
|
+
"ambiguous", f"bridge {r.status_code}: {detail}", r.status_code
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
async def provider_state(self, upstream_id: str) -> tuple[str, float | None] | None:
|
|
170
|
+
try:
|
|
171
|
+
r = await self._bridge.get(f"/v1/sandboxes/{upstream_id}")
|
|
172
|
+
except httpx.HTTPError as exc:
|
|
173
|
+
raise BackendError(f"{type(exc).__name__}: {exc}") from exc
|
|
174
|
+
if r.status_code == 404:
|
|
175
|
+
return None
|
|
176
|
+
if r.status_code != 200:
|
|
177
|
+
raise BackendError(f"bridge {r.status_code}: {r.text[:200]}")
|
|
178
|
+
exp = r.json().get("expires_at")
|
|
179
|
+
return "Running", parse_iso(exp) if exp else None
|
|
180
|
+
|
|
181
|
+
async def kill(self, upstream_id: str) -> bool:
|
|
182
|
+
try:
|
|
183
|
+
r = await self._bridge.delete(f"/v1/sandboxes/{upstream_id}")
|
|
184
|
+
except httpx.HTTPError as exc:
|
|
185
|
+
raise BackendError(f"{type(exc).__name__}: {exc}") from exc
|
|
186
|
+
if r.status_code == 404:
|
|
187
|
+
return False
|
|
188
|
+
if r.status_code not in (200, 204):
|
|
189
|
+
raise BackendError(f"bridge {r.status_code}: {r.text[:200]}")
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
def endpoint_for(
|
|
193
|
+
self, rec: SandboxRecord, port: int
|
|
194
|
+
) -> tuple[str, dict[str, str]] | None:
|
|
195
|
+
if port != EXECD_PORT or not rec.upstream_id:
|
|
196
|
+
return None
|
|
197
|
+
return f"{self.bridge_host}/x/{rec.upstream_id}", {}
|
|
198
|
+
|
|
199
|
+
async def extend(self, rec: SandboxRecord, expires_at: float) -> UpstreamResponse:
|
|
200
|
+
try:
|
|
201
|
+
r = await self._bridge.post(
|
|
202
|
+
f"/v1/sandboxes/{rec.upstream_id}/renew-expiration",
|
|
203
|
+
json={"expires_at": iso(expires_at)},
|
|
204
|
+
)
|
|
205
|
+
except httpx.HTTPError as exc:
|
|
206
|
+
return UpstreamResponse(
|
|
207
|
+
502, {"code": "STACKBOX::BACKEND_ERROR", "message": str(exc)}
|
|
208
|
+
)
|
|
209
|
+
if r.status_code != 200:
|
|
210
|
+
return UpstreamResponse(r.status_code, r.json() if r.content else None)
|
|
211
|
+
return UpstreamResponse(200, {"expiresAt": r.json()["expires_at"]})
|
|
212
|
+
|
|
213
|
+
def _as_dict(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
214
|
+
return {
|
|
215
|
+
"id": item["id"],
|
|
216
|
+
"status": {"state": "Running"},
|
|
217
|
+
"createdAt": item.get("created_at"),
|
|
218
|
+
"metadata": from_provider_tags(item.get("labels") or {}),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async def find_by_gw_id(self, gw_id: str) -> dict[str, Any] | None:
|
|
222
|
+
sid = self.sandbox_id(gw_id)
|
|
223
|
+
try:
|
|
224
|
+
r = await self._bridge.get(f"/v1/sandboxes/{sid}")
|
|
225
|
+
except httpx.HTTPError as exc:
|
|
226
|
+
raise ReconcileError(f"{self.name}: {type(exc).__name__}: {exc}") from exc
|
|
227
|
+
if r.status_code == 404:
|
|
228
|
+
return None
|
|
229
|
+
if r.status_code != 200:
|
|
230
|
+
raise ReconcileError(f"{self.name}: bridge {r.status_code}")
|
|
231
|
+
return self._as_dict(r.json())
|
|
232
|
+
|
|
233
|
+
async def list_provider(self, tags: dict[str, str]) -> list[dict[str, Any]]:
|
|
234
|
+
try:
|
|
235
|
+
r = await self._bridge.get(
|
|
236
|
+
"/v1/sandboxes", params={f"label.{k}": v for k, v in tags.items()}
|
|
237
|
+
)
|
|
238
|
+
r.raise_for_status()
|
|
239
|
+
except httpx.HTTPError as exc:
|
|
240
|
+
raise BackendError(f"{type(exc).__name__}: {exc}") from exc
|
|
241
|
+
return [self._as_dict(i) for i in r.json().get("items", [])]
|