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,306 @@
|
|
|
1
|
+
"""Daytona backend.
|
|
2
|
+
|
|
3
|
+
Creates boot a declarative image (the requested image plus an execd layer), which
|
|
4
|
+
Daytona builds once and caches; this needs no snapshot permission on the API key.
|
|
5
|
+
Each create starts execd and fetches the port preview link; clients reach execd at
|
|
6
|
+
the preview URL with Daytona's ``x-daytona-preview-token`` header plus execd's token.
|
|
7
|
+
|
|
8
|
+
Sandboxes are named ``stackbox-<gw_id>`` and labelled with the gateway id and owner, so a
|
|
9
|
+
create the gateway lost track of is found by name. Credentials: ``DAYTONA_API_KEY``
|
|
10
|
+
(and optionally ``DAYTONA_API_URL`` / ``DAYTONA_TARGET``), or ``api_key`` in config.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import math
|
|
18
|
+
import shlex
|
|
19
|
+
import time
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from stackbox import ledger as L
|
|
23
|
+
from stackbox.backends.base import (
|
|
24
|
+
BackendError,
|
|
25
|
+
CreateFailure,
|
|
26
|
+
ReconcileError,
|
|
27
|
+
UpstreamResponse,
|
|
28
|
+
)
|
|
29
|
+
from stackbox.backends.hosted import (
|
|
30
|
+
EXECD_PATH,
|
|
31
|
+
EXECD_PORT,
|
|
32
|
+
HostedBackend,
|
|
33
|
+
Provisioned,
|
|
34
|
+
entrypoint_command,
|
|
35
|
+
execd_command,
|
|
36
|
+
from_provider_tags,
|
|
37
|
+
iso,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from stackbox.ledger import SandboxRecord
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
PREVIEW_TOKEN_HEADER = "x-daytona-preview-token"
|
|
46
|
+
_LIVE = {"started", "starting", "creating", "pulling_snapshot", "restoring", "resuming"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _state(sb: Any) -> str:
|
|
50
|
+
return str(getattr(sb.state, "value", sb.state)).lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _live(sb: Any) -> bool:
|
|
54
|
+
"""Running and not already being destroyed (Daytona deletes asynchronously)."""
|
|
55
|
+
desired = str(getattr(sb.desired_state, "value", sb.desired_state) or "").lower()
|
|
56
|
+
return _state(sb) in _LIVE and desired not in ("destroyed", "stopped", "archived")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DaytonaBackend(HostedBackend):
|
|
60
|
+
kind = "daytona"
|
|
61
|
+
# Daytona builds a declarative image (base image + execd layer) on first use and
|
|
62
|
+
# caches it, so an unprepared image is served cold (measured: 16.8 s first create,
|
|
63
|
+
# then 1.3 s). Preparing does one throwaway create to warm that cache.
|
|
64
|
+
unprepared_support = "cold"
|
|
65
|
+
|
|
66
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
67
|
+
super().__init__(*args, **kwargs)
|
|
68
|
+
self._client: Any = None
|
|
69
|
+
self._client_lock = asyncio.Lock()
|
|
70
|
+
self._images: dict[str, Any] = {}
|
|
71
|
+
|
|
72
|
+
async def client(self) -> Any:
|
|
73
|
+
import daytona as d
|
|
74
|
+
|
|
75
|
+
async with self._client_lock:
|
|
76
|
+
if self._client is None:
|
|
77
|
+
cfg = (
|
|
78
|
+
d.DaytonaConfig(api_key=self.cfg.api_key)
|
|
79
|
+
if self.cfg.api_key
|
|
80
|
+
else None
|
|
81
|
+
)
|
|
82
|
+
self._client = d.AsyncDaytona(cfg)
|
|
83
|
+
return self._client
|
|
84
|
+
|
|
85
|
+
async def aclose(self) -> None:
|
|
86
|
+
if self._client is not None:
|
|
87
|
+
try:
|
|
88
|
+
await self._client.close()
|
|
89
|
+
except Exception:
|
|
90
|
+
logger.debug("closing the Daytona client failed", exc_info=True)
|
|
91
|
+
await super().aclose()
|
|
92
|
+
|
|
93
|
+
def declarative_image(self, image: str) -> Any:
|
|
94
|
+
import daytona as d
|
|
95
|
+
|
|
96
|
+
if image not in self._images:
|
|
97
|
+
self._images[image] = (
|
|
98
|
+
d.Image.base(image)
|
|
99
|
+
.add_local_file(str(self.execd_file), EXECD_PATH)
|
|
100
|
+
.run_commands(f"chmod 755 {EXECD_PATH}")
|
|
101
|
+
)
|
|
102
|
+
return self._images[image]
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def whole(cpu: float, memory_gib: float) -> tuple[int, int]:
|
|
106
|
+
"""Daytona takes whole vCPUs and GiB."""
|
|
107
|
+
return max(1, math.ceil(cpu)), max(1, math.ceil(memory_gib))
|
|
108
|
+
|
|
109
|
+
def allocated_resources(
|
|
110
|
+
self, image: str | None, cpu: float | None, memory_gib: float | None
|
|
111
|
+
) -> tuple[float, float]:
|
|
112
|
+
c, m = self.whole(*self.requested_resources(cpu, memory_gib))
|
|
113
|
+
return float(c), float(m)
|
|
114
|
+
|
|
115
|
+
async def build_image(self, image: str) -> tuple[str, dict[str, Any]]:
|
|
116
|
+
import daytona as d
|
|
117
|
+
|
|
118
|
+
dt = await self.client()
|
|
119
|
+
cpu, mem = self.whole(self.cfg.default_cpu, self.cfg.default_memory_gib)
|
|
120
|
+
t0 = time.monotonic()
|
|
121
|
+
sb = await dt.create(
|
|
122
|
+
d.CreateSandboxFromImageParams(
|
|
123
|
+
image=self.declarative_image(image),
|
|
124
|
+
resources=d.Resources(cpu=cpu, memory=mem),
|
|
125
|
+
labels={"stackbox_prepare": "1"},
|
|
126
|
+
ephemeral=True,
|
|
127
|
+
),
|
|
128
|
+
timeout=1800,
|
|
129
|
+
)
|
|
130
|
+
first_create_s = round(time.monotonic() - t0, 1)
|
|
131
|
+
await sb.delete()
|
|
132
|
+
return image, {"first_create_s": first_create_s}
|
|
133
|
+
|
|
134
|
+
async def provision(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
gw_id: str,
|
|
138
|
+
image: str,
|
|
139
|
+
image_rec: L.ImageRecord | None,
|
|
140
|
+
cpu: float,
|
|
141
|
+
memory_gib: float,
|
|
142
|
+
timeout_s: int,
|
|
143
|
+
env: dict[str, str],
|
|
144
|
+
entrypoint: list[str] | None,
|
|
145
|
+
token: str,
|
|
146
|
+
) -> Provisioned:
|
|
147
|
+
import daytona as d
|
|
148
|
+
|
|
149
|
+
dt = await self.client()
|
|
150
|
+
c, m = self.whole(cpu, memory_gib)
|
|
151
|
+
try:
|
|
152
|
+
sb = await dt.create(
|
|
153
|
+
d.CreateSandboxFromImageParams(
|
|
154
|
+
name=f"stackbox-{gw_id}",
|
|
155
|
+
image=self.declarative_image(image),
|
|
156
|
+
resources=d.Resources(cpu=c, memory=m),
|
|
157
|
+
labels={
|
|
158
|
+
"stackbox_gw_id": gw_id,
|
|
159
|
+
"stackbox_managed": self.instance_id,
|
|
160
|
+
},
|
|
161
|
+
env_vars=env or None,
|
|
162
|
+
ttl_minutes=max(1, math.ceil(timeout_s / 60)),
|
|
163
|
+
auto_stop_interval=0,
|
|
164
|
+
ephemeral=True,
|
|
165
|
+
),
|
|
166
|
+
timeout=self._settings.create_timeout_s,
|
|
167
|
+
)
|
|
168
|
+
except (d.DaytonaRateLimitError, d.DaytonaServiceUnavailableError) as exc:
|
|
169
|
+
raise CreateFailure("clear", f"Daytona at capacity: {exc}", 429) from exc
|
|
170
|
+
except (
|
|
171
|
+
d.DaytonaAuthenticationError,
|
|
172
|
+
d.DaytonaAuthorizationError,
|
|
173
|
+
d.DaytonaForbiddenError,
|
|
174
|
+
) as exc:
|
|
175
|
+
raise CreateFailure("clear", f"Daytona auth failed: {exc}", 401) from exc
|
|
176
|
+
except (
|
|
177
|
+
d.DaytonaBadRequestError,
|
|
178
|
+
d.DaytonaValidationError,
|
|
179
|
+
d.DaytonaUnprocessableEntityError,
|
|
180
|
+
) as exc:
|
|
181
|
+
msg = str(exc).lower()
|
|
182
|
+
if "limit exceeded" in msg or "quota" in msg or "concurrency limit" in msg:
|
|
183
|
+
# e.g. "Total memory limit exceeded. Maximum allowed: 10GiB."
|
|
184
|
+
raise CreateFailure(
|
|
185
|
+
"clear", f"Daytona account limit: {exc}", 429
|
|
186
|
+
) from exc
|
|
187
|
+
raise CreateFailure(
|
|
188
|
+
"clear", f"Daytona rejected the request: {exc}", 400
|
|
189
|
+
) from exc
|
|
190
|
+
except d.DaytonaConnectionError as exc:
|
|
191
|
+
raise CreateFailure("clear", f"{type(exc).__name__}: {exc}") from exc
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
raise CreateFailure("ambiguous", f"{type(exc).__name__}: {exc}") from exc
|
|
194
|
+
try:
|
|
195
|
+
link = await sb.get_preview_link(EXECD_PORT)
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
await self.discard(sb.id)
|
|
198
|
+
raise CreateFailure("clear", f"no preview link: {exc}") from exc
|
|
199
|
+
host = link.url.split("://", 1)[-1].rstrip("/")
|
|
200
|
+
headers = {PREVIEW_TOKEN_HEADER: link.token} if link.token else {}
|
|
201
|
+
return Provisioned(
|
|
202
|
+
upstream_id=sb.id,
|
|
203
|
+
host=host,
|
|
204
|
+
headers=headers,
|
|
205
|
+
backend_data={"preview_token": link.token},
|
|
206
|
+
handle=sb,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
async def start(
|
|
210
|
+
self,
|
|
211
|
+
prov: Provisioned,
|
|
212
|
+
*,
|
|
213
|
+
token: str,
|
|
214
|
+
env: dict[str, str],
|
|
215
|
+
entrypoint: list[str] | None,
|
|
216
|
+
) -> None:
|
|
217
|
+
sb = prov.handle
|
|
218
|
+
execd = shlex.join(execd_command(token, self.cfg.execd_graceful_shutdown))
|
|
219
|
+
cmd = f"nohup {execd} > /tmp/execd.log 2>&1 &"
|
|
220
|
+
user_cmd = entrypoint_command(entrypoint)
|
|
221
|
+
if user_cmd:
|
|
222
|
+
cmd += f" nohup sh -c {shlex.quote(user_cmd)} > /tmp/entrypoint.log 2>&1 &"
|
|
223
|
+
await sb.process.exec(f"sh -c {shlex.quote(cmd)}", env=env or None, timeout=30)
|
|
224
|
+
|
|
225
|
+
async def _get(self, id_or_name: str) -> Any | None:
|
|
226
|
+
import daytona as d
|
|
227
|
+
|
|
228
|
+
dt = await self.client()
|
|
229
|
+
try:
|
|
230
|
+
return await dt.get(id_or_name)
|
|
231
|
+
except d.DaytonaNotFoundError:
|
|
232
|
+
return None
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
raise BackendError(f"{type(exc).__name__}: {exc}") from exc
|
|
235
|
+
|
|
236
|
+
async def provider_state(self, upstream_id: str) -> tuple[str, float | None] | None:
|
|
237
|
+
sb = await self._get(upstream_id)
|
|
238
|
+
if sb is None or not _live(sb):
|
|
239
|
+
return None
|
|
240
|
+
return "Running", None
|
|
241
|
+
|
|
242
|
+
async def kill(self, upstream_id: str) -> bool:
|
|
243
|
+
sb = await self._get(upstream_id)
|
|
244
|
+
if sb is None or _state(sb) in ("destroyed", "destroying"):
|
|
245
|
+
return False
|
|
246
|
+
try:
|
|
247
|
+
await sb.delete()
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
raise BackendError(f"{type(exc).__name__}: {exc}") from exc
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
def endpoint_for(
|
|
253
|
+
self, rec: SandboxRecord, port: int
|
|
254
|
+
) -> tuple[str, dict[str, str]] | None:
|
|
255
|
+
bd = rec.backend_data or {}
|
|
256
|
+
host = bd.get("host")
|
|
257
|
+
if port != EXECD_PORT or not host:
|
|
258
|
+
return None
|
|
259
|
+
tok = bd.get("preview_token")
|
|
260
|
+
return host, ({PREVIEW_TOKEN_HEADER: tok} if tok else {})
|
|
261
|
+
|
|
262
|
+
async def extend(self, rec: SandboxRecord, expires_at: float) -> UpstreamResponse:
|
|
263
|
+
sb = await self._get(rec.upstream_id or "")
|
|
264
|
+
if sb is None:
|
|
265
|
+
return UpstreamResponse(
|
|
266
|
+
404, {"code": "SANDBOX::NOT_FOUND", "message": "gone"}
|
|
267
|
+
)
|
|
268
|
+
minutes = max(1, math.ceil((expires_at - time.time()) / 60))
|
|
269
|
+
try:
|
|
270
|
+
await sb.set_ttl(minutes)
|
|
271
|
+
except Exception as exc: # noqa: BLE001 - reported to the client
|
|
272
|
+
return UpstreamResponse(
|
|
273
|
+
502, {"code": "STACKBOX::BACKEND_ERROR", "message": str(exc)}
|
|
274
|
+
)
|
|
275
|
+
return UpstreamResponse(200, {"expiresAt": iso(time.time() + minutes * 60)})
|
|
276
|
+
|
|
277
|
+
def _as_dict(self, sb: Any) -> dict[str, Any]:
|
|
278
|
+
created = getattr(sb, "created_at", None)
|
|
279
|
+
return {
|
|
280
|
+
"id": sb.id,
|
|
281
|
+
"status": {"state": "Running"},
|
|
282
|
+
"createdAt": str(created) if created else None,
|
|
283
|
+
"metadata": from_provider_tags(dict(sb.labels or {})),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async def find_by_gw_id(self, gw_id: str) -> dict[str, Any] | None:
|
|
287
|
+
try:
|
|
288
|
+
sb = await self._get(f"stackbox-{gw_id}")
|
|
289
|
+
except BackendError as exc:
|
|
290
|
+
raise ReconcileError(f"{self.name}: {exc}") from exc
|
|
291
|
+
if sb is None or not _live(sb):
|
|
292
|
+
return None
|
|
293
|
+
return self._as_dict(sb)
|
|
294
|
+
|
|
295
|
+
async def list_provider(self, tags: dict[str, str]) -> list[dict[str, Any]]:
|
|
296
|
+
import daytona as d
|
|
297
|
+
|
|
298
|
+
dt = await self.client()
|
|
299
|
+
out: list[dict[str, Any]] = []
|
|
300
|
+
try:
|
|
301
|
+
async for sb in dt.list(d.ListSandboxesQuery(labels=tags or None)):
|
|
302
|
+
if _live(sb):
|
|
303
|
+
out.append(self._as_dict(sb))
|
|
304
|
+
except Exception as exc:
|
|
305
|
+
raise BackendError(f"{type(exc).__name__}: {exc}") from exc
|
|
306
|
+
return out
|
stackbox/backends/e2b.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""E2B backend: Firecracker microVMs, booted from templates.
|
|
2
|
+
|
|
3
|
+
E2B only boots templates, so an image must be prepared (built into a template with
|
|
4
|
+
execd baked in) before E2B can serve it. Until then the router does not consider
|
|
5
|
+
E2B for that image, and with ``auto_prepare`` it builds the template in the
|
|
6
|
+
background. A template fixes vCPU and memory, so requests asking for more than the
|
|
7
|
+
template has are not placed here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import math
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from stackbox import ledger as L
|
|
19
|
+
from stackbox.backends.base import BackendError, CreateFailure, UpstreamResponse
|
|
20
|
+
from stackbox.backends.hosted import (
|
|
21
|
+
EXECD_PATH,
|
|
22
|
+
EXECD_PORT,
|
|
23
|
+
HostedBackend,
|
|
24
|
+
Provisioned,
|
|
25
|
+
entrypoint_command,
|
|
26
|
+
execd_command,
|
|
27
|
+
from_provider_tags,
|
|
28
|
+
iso,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from stackbox.ledger import SandboxRecord
|
|
33
|
+
|
|
34
|
+
TRAFFIC_HEADER = "e2b-traffic-access-token"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class E2BBackend(HostedBackend):
|
|
38
|
+
kind = "e2b"
|
|
39
|
+
unprepared_support = None
|
|
40
|
+
|
|
41
|
+
def _api(self) -> dict[str, Any]:
|
|
42
|
+
opts: dict[str, Any] = {"request_timeout": self._settings.request_timeout_s}
|
|
43
|
+
if self.cfg.api_key:
|
|
44
|
+
opts["api_key"] = self.cfg.api_key
|
|
45
|
+
return opts
|
|
46
|
+
|
|
47
|
+
def template_resources(self) -> tuple[int, int]:
|
|
48
|
+
"""(cpu_count, memory_mb) for templates this backend builds."""
|
|
49
|
+
cpu = max(1, math.ceil(self.cfg.default_cpu))
|
|
50
|
+
mem = max(512, round(self.cfg.default_memory_gib * 1024))
|
|
51
|
+
return cpu, mem + (mem % 2)
|
|
52
|
+
|
|
53
|
+
def template_name(self, image: str) -> str:
|
|
54
|
+
cpu, mem = self.template_resources()
|
|
55
|
+
key = f"{image}|{self.execd_digest()}|{cpu}|{mem}".encode()
|
|
56
|
+
return f"stackbox-{hashlib.sha256(key).hexdigest()[:16]}"
|
|
57
|
+
|
|
58
|
+
def allocated_resources(
|
|
59
|
+
self, image: str | None, cpu: float | None, memory_gib: float | None
|
|
60
|
+
) -> tuple[float, float]:
|
|
61
|
+
# Billed for what the template allocates, not what was requested.
|
|
62
|
+
rec = self.image_record(image) if image else None
|
|
63
|
+
if rec is not None and rec.detail and "cpu" in rec.detail:
|
|
64
|
+
return float(rec.detail["cpu"]), float(rec.detail["memory_gib"])
|
|
65
|
+
c, m = self.template_resources()
|
|
66
|
+
return float(c), m / 1024
|
|
67
|
+
|
|
68
|
+
async def build_image(self, image: str) -> tuple[str, dict[str, Any]]:
|
|
69
|
+
from e2b import AsyncTemplate
|
|
70
|
+
|
|
71
|
+
name = self.template_name(image)
|
|
72
|
+
cpu, mem = self.template_resources()
|
|
73
|
+
detail: dict[str, Any] = {"cpu": cpu, "memory_gib": mem / 1024}
|
|
74
|
+
if await AsyncTemplate.exists(name, **self._api()):
|
|
75
|
+
return name, {**detail, "reused": True}
|
|
76
|
+
template = (
|
|
77
|
+
AsyncTemplate(file_context_path=str(self.execd_file.parent))
|
|
78
|
+
.from_image(image)
|
|
79
|
+
.copy(self.execd_file.name, EXECD_PATH, mode=0o755, user="root")
|
|
80
|
+
)
|
|
81
|
+
opts = {**self._api(), "request_timeout": 1800}
|
|
82
|
+
info = await AsyncTemplate.build(
|
|
83
|
+
template, name, cpu_count=cpu, memory_mb=mem, **opts
|
|
84
|
+
)
|
|
85
|
+
return name, {**detail, "template_id": getattr(info, "template_id", None)}
|
|
86
|
+
|
|
87
|
+
async def provision(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
gw_id: str,
|
|
91
|
+
image: str,
|
|
92
|
+
image_rec: L.ImageRecord | None,
|
|
93
|
+
cpu: float,
|
|
94
|
+
memory_gib: float,
|
|
95
|
+
timeout_s: int,
|
|
96
|
+
env: dict[str, str],
|
|
97
|
+
entrypoint: list[str] | None,
|
|
98
|
+
token: str,
|
|
99
|
+
) -> Provisioned:
|
|
100
|
+
from e2b import AsyncSandbox
|
|
101
|
+
from e2b import exceptions as ex
|
|
102
|
+
|
|
103
|
+
if image_rec is None or not image_rec.ref:
|
|
104
|
+
raise CreateFailure("clear", f"no E2B template for {image}")
|
|
105
|
+
try:
|
|
106
|
+
sb = await AsyncSandbox.create(
|
|
107
|
+
template=image_rec.ref,
|
|
108
|
+
timeout=timeout_s,
|
|
109
|
+
metadata={
|
|
110
|
+
"stackbox_gw_id": gw_id,
|
|
111
|
+
"stackbox_managed": self.instance_id,
|
|
112
|
+
},
|
|
113
|
+
envs=env or None,
|
|
114
|
+
idempotency_key=gw_id,
|
|
115
|
+
**{**self._api(), "request_timeout": self._settings.create_timeout_s},
|
|
116
|
+
)
|
|
117
|
+
except (ex.RateLimitException, ex.NotEnoughSpaceException) as exc:
|
|
118
|
+
raise CreateFailure("clear", f"E2B at capacity: {exc}", 429) from exc
|
|
119
|
+
except ex.AuthenticationException as exc:
|
|
120
|
+
raise CreateFailure("clear", f"E2B auth failed: {exc}", 401) from exc
|
|
121
|
+
except (ex.NotFoundException, ex.TemplateException) as exc:
|
|
122
|
+
# Template gone: forget the preparation so it is rebuilt.
|
|
123
|
+
self._ledger.set_image(
|
|
124
|
+
self.name, image, L.IMAGE_FAILED, error=f"template missing: {exc}"[:500]
|
|
125
|
+
)
|
|
126
|
+
raise CreateFailure("clear", f"E2B template missing: {exc}", 404) from exc
|
|
127
|
+
except ex.InvalidArgumentException as exc:
|
|
128
|
+
# The request was translated by us; another backend may accept it.
|
|
129
|
+
raise CreateFailure(
|
|
130
|
+
"clear", f"E2B rejected the request: {exc}", 400
|
|
131
|
+
) from exc
|
|
132
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
|
133
|
+
raise CreateFailure("clear", f"{type(exc).__name__}: {exc}") from exc
|
|
134
|
+
except Exception as exc: # timeouts, 5xx: it may exist
|
|
135
|
+
raise CreateFailure("ambiguous", f"{type(exc).__name__}: {exc}") from exc
|
|
136
|
+
tok = getattr(sb, "traffic_access_token", None)
|
|
137
|
+
return Provisioned(
|
|
138
|
+
upstream_id=sb.sandbox_id,
|
|
139
|
+
host=sb.get_host(EXECD_PORT),
|
|
140
|
+
headers={TRAFFIC_HEADER: tok} if tok else {},
|
|
141
|
+
backend_data={"domain": sb.sandbox_domain, "traffic_token": tok},
|
|
142
|
+
handle=sb,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def start(
|
|
146
|
+
self,
|
|
147
|
+
prov: Provisioned,
|
|
148
|
+
*,
|
|
149
|
+
token: str,
|
|
150
|
+
env: dict[str, str],
|
|
151
|
+
entrypoint: list[str] | None,
|
|
152
|
+
) -> None:
|
|
153
|
+
import shlex
|
|
154
|
+
|
|
155
|
+
sb = prov.handle
|
|
156
|
+
cmd = f"nohup {shlex.join(execd_command(token, self.cfg.execd_graceful_shutdown))} > /tmp/execd.log 2>&1 &"
|
|
157
|
+
await sb.commands.run(cmd, user="root", envs=env or None, background=True)
|
|
158
|
+
user_cmd = entrypoint_command(entrypoint)
|
|
159
|
+
if user_cmd:
|
|
160
|
+
await sb.commands.run(
|
|
161
|
+
f"nohup sh -c {shlex.quote(user_cmd)} > /tmp/entrypoint.log 2>&1 &",
|
|
162
|
+
user="root",
|
|
163
|
+
envs=env or None,
|
|
164
|
+
background=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
async def provider_state(self, upstream_id: str) -> tuple[str, float | None] | None:
|
|
168
|
+
from e2b import AsyncSandbox
|
|
169
|
+
from e2b import exceptions as ex
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
info = await AsyncSandbox.get_info(upstream_id, **self._api())
|
|
173
|
+
except ex.NotFoundException:
|
|
174
|
+
return None
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
raise BackendError(f"{type(exc).__name__}: {exc}") from exc
|
|
177
|
+
state = str(getattr(info.state, "value", info.state)).lower()
|
|
178
|
+
end = info.end_at.timestamp() if info.end_at else None
|
|
179
|
+
return ("Paused" if state == "paused" else "Running"), end
|
|
180
|
+
|
|
181
|
+
async def kill(self, upstream_id: str) -> bool:
|
|
182
|
+
from e2b import AsyncSandbox
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
return bool(await AsyncSandbox.kill(upstream_id, **self._api()))
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
raise BackendError(f"{type(exc).__name__}: {exc}") from exc
|
|
188
|
+
|
|
189
|
+
def endpoint_for(
|
|
190
|
+
self, rec: SandboxRecord, port: int
|
|
191
|
+
) -> tuple[str, dict[str, str]] | None:
|
|
192
|
+
from e2b.connection_config import ConnectionConfig
|
|
193
|
+
|
|
194
|
+
bd = rec.backend_data or {}
|
|
195
|
+
cc = ConnectionConfig(
|
|
196
|
+
**({"api_key": self.cfg.api_key} if self.cfg.api_key else {})
|
|
197
|
+
)
|
|
198
|
+
host = cc.get_host(rec.upstream_id, bd.get("domain"), port)
|
|
199
|
+
tok = bd.get("traffic_token")
|
|
200
|
+
return host, ({TRAFFIC_HEADER: tok} if tok else {})
|
|
201
|
+
|
|
202
|
+
async def extend(self, rec: SandboxRecord, expires_at: float) -> UpstreamResponse:
|
|
203
|
+
import time
|
|
204
|
+
|
|
205
|
+
from e2b import AsyncSandbox
|
|
206
|
+
|
|
207
|
+
seconds = max(1, min(int(expires_at - time.time()), self.cfg.max_timeout_s))
|
|
208
|
+
try:
|
|
209
|
+
await AsyncSandbox.set_timeout(rec.upstream_id, seconds, **self._api())
|
|
210
|
+
except Exception as exc: # noqa: BLE001 - reported to the client as 502
|
|
211
|
+
return UpstreamResponse(
|
|
212
|
+
502, {"code": "STACKBOX::BACKEND_ERROR", "message": str(exc)}
|
|
213
|
+
)
|
|
214
|
+
return UpstreamResponse(200, {"expiresAt": iso(time.time() + seconds)})
|
|
215
|
+
|
|
216
|
+
async def list_provider(self, tags: dict[str, str]) -> list[dict[str, Any]]:
|
|
217
|
+
from e2b import AsyncSandbox, SandboxQuery, SandboxState
|
|
218
|
+
|
|
219
|
+
out: list[dict[str, Any]] = []
|
|
220
|
+
try:
|
|
221
|
+
pager = AsyncSandbox.list(
|
|
222
|
+
query=SandboxQuery(
|
|
223
|
+
metadata=tags, state=[SandboxState.RUNNING, SandboxState.PAUSED]
|
|
224
|
+
),
|
|
225
|
+
limit=100,
|
|
226
|
+
**self._api(),
|
|
227
|
+
)
|
|
228
|
+
while pager.has_next:
|
|
229
|
+
for info in await pager.next_items():
|
|
230
|
+
meta = from_provider_tags(info.metadata)
|
|
231
|
+
out.append(
|
|
232
|
+
{
|
|
233
|
+
"id": info.sandbox_id,
|
|
234
|
+
"status": {"state": "Running"},
|
|
235
|
+
"createdAt": iso(info.started_at.timestamp()),
|
|
236
|
+
"metadata": meta,
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
raise BackendError(f"{type(exc).__name__}: {exc}") from exc
|
|
241
|
+
return out
|