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.
@@ -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
@@ -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