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,536 @@
1
+ """Shared logic for hosted providers that do not speak the OpenSandbox API themselves.
2
+
3
+ The gateway emulates the OpenSandbox lifecycle on top of the provider SDK and runs
4
+ OpenSandbox's ``execd`` inside every sandbox, so clients get the same execd endpoint
5
+ (``GET /sandboxes/{id}/endpoints/44772`` -> public URL + headers) on every provider.
6
+
7
+ A create here:
8
+ 1. resolves the image to something the provider can boot fast (prepared template or
9
+ image layer with execd baked in, see ``prepare``),
10
+ 2. creates the provider sandbox tagged with the gateway id,
11
+ 3. starts execd with a per-sandbox access token,
12
+ 4. waits until execd answers on its public URL.
13
+ Any failure after step 2 deletes the provider sandbox before reporting a clear failure;
14
+ if that delete fails, the sandbox still carries the managed tag and the reconciler
15
+ deletes it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import datetime as dt
22
+ import hashlib
23
+ import hmac
24
+ import logging
25
+ import shlex
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ import httpx
32
+
33
+ from stackbox import ledger as L
34
+ from stackbox.backends.base import (
35
+ GW_ID_KEY,
36
+ MANAGED_KEY,
37
+ Backend,
38
+ BackendError,
39
+ Created,
40
+ CreateFailure,
41
+ ImageSupport,
42
+ UpstreamResponse,
43
+ error_body,
44
+ )
45
+ from stackbox.config import BackendConfig, GatewaySettings
46
+
47
+ if TYPE_CHECKING:
48
+ from stackbox.ledger import SandboxRecord
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ EXECD_PORT = 44772
53
+ EXECD_PATH = "/opt/opensandbox/execd"
54
+ EXECD_TOKEN_HEADER = "X-EXECD-ACCESS-TOKEN"
55
+ # OpenSandbox's egress sidecar port. The SDK resolves it on every create, but hosted
56
+ # backends run no sidecar, so it gets a placeholder that fails loudly if used.
57
+ EGRESS_PORT = 18080
58
+ DEFAULT_ENTRYPOINT = ["tail", "-f", "/dev/null"]
59
+ _NOOP_ENTRYPOINTS = {"tail -f /dev/null", "sleep infinity", "sleep inf"}
60
+
61
+ # Provider tag/metadata keys. Providers restrict key characters, so the router's
62
+ # namespaced keys are mapped to plain ones.
63
+ PROVIDER_KEYS = {GW_ID_KEY: "stackbox_gw_id", MANAGED_KEY: "stackbox_managed"}
64
+ _FROM_PROVIDER = {v: k for k, v in PROVIDER_KEYS.items()}
65
+
66
+ # A failed preparation is retried automatically only after this long.
67
+ PREPARE_RETRY_S = 600
68
+
69
+
70
+ def to_provider_tags(metadata: dict[str, str]) -> dict[str, str]:
71
+ return {PROVIDER_KEYS.get(k, k): v for k, v in metadata.items()}
72
+
73
+
74
+ def from_provider_tags(tags: dict[str, str] | None) -> dict[str, str]:
75
+ return {
76
+ _FROM_PROVIDER[k]: v for k, v in (tags or {}).items() if k in _FROM_PROVIDER
77
+ }
78
+
79
+
80
+ def image_uri(body: dict[str, Any]) -> str | None:
81
+ img = body.get("image")
82
+ if isinstance(img, dict):
83
+ return img.get("uri")
84
+ return img if isinstance(img, str) else None
85
+
86
+
87
+ def iso(ts: float) -> str:
88
+ return (
89
+ dt.datetime.fromtimestamp(ts, dt.UTC)
90
+ .isoformat(timespec="seconds")
91
+ .replace("+00:00", "Z")
92
+ )
93
+
94
+
95
+ def parse_iso(value: str) -> float:
96
+ return dt.datetime.fromisoformat(value).timestamp()
97
+
98
+
99
+ def entrypoint_command(entrypoint: list[str] | None) -> str | None:
100
+ """Shell command for a client entrypoint, or None when it only keeps the box alive."""
101
+ if not entrypoint or " ".join(entrypoint) in _NOOP_ENTRYPOINTS:
102
+ return None
103
+ return shlex.join(entrypoint)
104
+
105
+
106
+ def execd_command(token: str, graceful_shutdown: str = "50ms") -> list[str]:
107
+ return [
108
+ EXECD_PATH,
109
+ "--port",
110
+ str(EXECD_PORT),
111
+ "--access-token",
112
+ token,
113
+ "--graceful-shutdown-timeout",
114
+ graceful_shutdown,
115
+ ]
116
+
117
+
118
+ @dataclass
119
+ class Provisioned:
120
+ """A provider sandbox that exists (and must be deleted if anything later fails)."""
121
+
122
+ upstream_id: str
123
+ host: str # public host for the execd port, no scheme
124
+ headers: dict[str, str] = field(default_factory=dict) # provider auth for that host
125
+ backend_data: dict[str, Any] = field(default_factory=dict)
126
+ handle: Any = None # provider SDK object, for the start step
127
+ scheme: str = "https" # how the gateway reaches the host (clients choose their own)
128
+
129
+
130
+ class PostCreateError(Exception):
131
+ """The provider sandbox exists, but finishing setup failed."""
132
+
133
+
134
+ class HostedBackend(Backend):
135
+ #: what image_support returns for an image that has not been prepared
136
+ unprepared_support: ImageSupport = None
137
+ adopt_ambiguous = False
138
+
139
+ def __init__(
140
+ self,
141
+ cfg: BackendConfig,
142
+ *,
143
+ settings: GatewaySettings,
144
+ ledger: L.Ledger,
145
+ token_key: str,
146
+ ):
147
+ super().__init__(cfg)
148
+ self._settings = settings
149
+ self._ledger = ledger
150
+ self._token_key = token_key.encode()
151
+ self._preparing: dict[str, asyncio.Task[dict[str, Any]]] = {}
152
+ self._http = httpx.AsyncClient(timeout=10)
153
+ self._execd_digest: str | None = None
154
+
155
+ async def aclose(self) -> None:
156
+ for task in self._preparing.values():
157
+ task.cancel()
158
+ await self._http.aclose()
159
+
160
+ # ------------------------------------------------------------- helpers
161
+ def execd_token(self, gw_id: str) -> str:
162
+ """Per-sandbox execd token, derived so it survives gateway restarts unstored."""
163
+ return hmac.new(self._token_key, gw_id.encode(), hashlib.sha256).hexdigest()[
164
+ :40
165
+ ]
166
+
167
+ def execd_digest(self) -> str:
168
+ if self._execd_digest is None:
169
+ h = hashlib.sha256()
170
+ with open(self.execd_file, "rb") as f:
171
+ for chunk in iter(lambda: f.read(1 << 20), b""):
172
+ h.update(chunk)
173
+ self._execd_digest = h.hexdigest()
174
+ return self._execd_digest
175
+
176
+ @property
177
+ def execd_file(self) -> Path:
178
+ """Configured execd_path, else the cached download (fetched on first use)."""
179
+ from stackbox import execd
180
+
181
+ return execd.ensure(self.cfg.execd_path)
182
+
183
+ def requested_resources(
184
+ self, cpu: float | None, memory_gib: float | None
185
+ ) -> tuple[float, float]:
186
+ return (
187
+ cpu if cpu is not None else self.cfg.default_cpu,
188
+ memory_gib if memory_gib is not None else self.cfg.default_memory_gib,
189
+ )
190
+
191
+ def clamp_timeout(self, requested: Any) -> int:
192
+ try:
193
+ seconds = int(requested) if requested is not None else 600
194
+ except (TypeError, ValueError):
195
+ seconds = 600
196
+ return max(60, min(seconds, self.cfg.max_timeout_s))
197
+
198
+ # -------------------------------------------------------- image support
199
+ def image_record(self, image: str) -> L.ImageRecord | None:
200
+ return self._ledger.get_image(self.name, image)
201
+
202
+ def fits(
203
+ self, rec: L.ImageRecord, cpu: float | None, memory_gib: float | None
204
+ ) -> bool:
205
+ """A prepared image that fixes resources (an E2B template) serves only requests
206
+ within them. Images without fixed resources fit any request."""
207
+ d = rec.detail or {}
208
+ if "cpu" not in d:
209
+ return True
210
+ return (cpu or 0) <= d["cpu"] + 1e-9 and (memory_gib or 0) <= d.get(
211
+ "memory_gib", 0
212
+ ) + 1e-9
213
+
214
+ def image_support(
215
+ self, image: str | None, cpu: float | None, memory_gib: float | None
216
+ ) -> ImageSupport:
217
+ if not image:
218
+ return None
219
+ rec = self.image_record(image)
220
+ if rec is not None and rec.status == L.IMAGE_READY:
221
+ return "ready" if self.fits(rec, cpu, memory_gib) else None
222
+ if self.cfg.auto_prepare:
223
+ self.schedule_prepare(image)
224
+ return self.unprepared_support
225
+
226
+ def schedule_prepare(self, image: str) -> None:
227
+ if image in self._preparing:
228
+ return
229
+ rec = self.image_record(image)
230
+ if (
231
+ rec is not None
232
+ and rec.status == L.IMAGE_FAILED
233
+ and time.time() - rec.updated_at < PREPARE_RETRY_S
234
+ ):
235
+ return
236
+ try:
237
+ asyncio.get_running_loop()
238
+ except RuntimeError:
239
+ return
240
+ task = asyncio.create_task(self._prepare_logged(image))
241
+ self._preparing[image] = task
242
+ task.add_done_callback(lambda _t: self._preparing.pop(image, None))
243
+
244
+ async def _prepare_logged(self, image: str) -> dict[str, Any]:
245
+ try:
246
+ return await self.prepare(image)
247
+ except Exception:
248
+ logger.exception("background prepare of %s on %s failed", image, self.name)
249
+ return {}
250
+
251
+ async def prepare(self, image: str) -> dict[str, Any]:
252
+ t0 = time.monotonic()
253
+ self._ledger.set_image(self.name, image, L.IMAGE_BUILDING)
254
+ logger.info("preparing %s on %s", image, self.name)
255
+ try:
256
+ ref, detail = await self.build_image(image)
257
+ except Exception as exc:
258
+ self._ledger.set_image(
259
+ self.name,
260
+ image,
261
+ L.IMAGE_FAILED,
262
+ error=f"{type(exc).__name__}: {exc}"[:500],
263
+ )
264
+ raise
265
+ detail = {**detail, "prepare_s": round(time.monotonic() - t0, 1)}
266
+ self._ledger.set_image(self.name, image, L.IMAGE_READY, ref=ref, detail=detail)
267
+ logger.info("prepared %s on %s as %s (%s)", image, self.name, ref, detail)
268
+ return {
269
+ "backend": self.name,
270
+ "image": image,
271
+ "status": L.IMAGE_READY,
272
+ "ref": ref,
273
+ **detail,
274
+ }
275
+
276
+ # ------------------------------------------------------ provider hooks
277
+ async def build_image(self, image: str) -> tuple[str, dict[str, Any]]:
278
+ raise NotImplementedError
279
+
280
+ async def provision(
281
+ self,
282
+ *,
283
+ gw_id: str,
284
+ image: str,
285
+ image_rec: L.ImageRecord | None,
286
+ cpu: float,
287
+ memory_gib: float,
288
+ timeout_s: int,
289
+ env: dict[str, str],
290
+ entrypoint: list[str] | None,
291
+ token: str,
292
+ ) -> Provisioned:
293
+ """Create the provider sandbox. Raises CreateFailure if it may not exist."""
294
+ raise NotImplementedError
295
+
296
+ async def start(
297
+ self,
298
+ prov: Provisioned,
299
+ *,
300
+ token: str,
301
+ env: dict[str, str],
302
+ entrypoint: list[str] | None,
303
+ ) -> None:
304
+ """Start execd (and the client entrypoint) if provisioning did not already."""
305
+ return
306
+
307
+ async def provider_state(self, upstream_id: str) -> tuple[str, float | None] | None:
308
+ """(OpenSandbox state, expires_at) or None if the sandbox is gone. Raises BackendError."""
309
+ raise NotImplementedError
310
+
311
+ async def kill(self, upstream_id: str) -> bool:
312
+ """Delete; False if it did not exist."""
313
+ raise NotImplementedError
314
+
315
+ def endpoint_for(
316
+ self, rec: SandboxRecord, port: int
317
+ ) -> tuple[str, dict[str, str]] | None:
318
+ raise NotImplementedError
319
+
320
+ async def extend(self, rec: SandboxRecord, expires_at: float) -> UpstreamResponse:
321
+ return UpstreamResponse(
322
+ 409,
323
+ error_body(
324
+ "STACKBOX::RENEW_UNSUPPORTED",
325
+ f"backend {self.name} ({self.kind}) cannot extend a running sandbox",
326
+ ),
327
+ )
328
+
329
+ async def list_provider(self, tags: dict[str, str]) -> list[dict[str, Any]]:
330
+ """Provider sandboxes with these tags as OpenSandbox-shaped dicts (router metadata
331
+ mapped back). Raises BackendError."""
332
+ raise NotImplementedError
333
+
334
+ # ------------------------------------------------------------ lifecycle
335
+ async def create(self, body: dict[str, Any], *, gw_id: str) -> Created:
336
+ from stackbox.router import parse_cpu, parse_memory_gib
337
+
338
+ image = image_uri(body)
339
+ if not image:
340
+ raise CreateFailure("client", "image.uri is required", 400)
341
+ limits = body.get("resourceLimits") or {}
342
+ req_cpu = parse_cpu(limits.get("cpu"))
343
+ req_mem = parse_memory_gib(limits.get("memory"))
344
+ support = self.image_support(image, req_cpu, req_mem)
345
+ rec = self.image_record(image)
346
+ cpu, memory_gib = self.requested_resources(req_cpu, req_mem)
347
+ if support is None:
348
+ raise CreateFailure(
349
+ "clear", f"image {image} is not prepared on {self.name}"
350
+ )
351
+ timeout_s = self.clamp_timeout(body.get("timeout"))
352
+ env = {str(k): str(v) for k, v in (body.get("env") or {}).items()}
353
+ entrypoint = body.get("entrypoint")
354
+ token = self.execd_token(gw_id)
355
+ t0 = time.time()
356
+ deadline = time.monotonic() + self._settings.create_timeout_s
357
+
358
+ prov = await self.provision(
359
+ gw_id=gw_id,
360
+ image=image,
361
+ image_rec=rec if support == "ready" else None,
362
+ cpu=cpu,
363
+ memory_gib=memory_gib,
364
+ timeout_s=timeout_s,
365
+ env=env,
366
+ entrypoint=entrypoint,
367
+ token=token,
368
+ )
369
+ try:
370
+ await self.start(prov, token=token, env=env, entrypoint=entrypoint)
371
+ headers = {**prov.headers, EXECD_TOKEN_HEADER: token}
372
+ await self.wait_execd(f"{prov.scheme}://{prov.host}", headers, deadline)
373
+ except Exception as exc:
374
+ await self.discard(prov.upstream_id)
375
+ raise CreateFailure(
376
+ "clear",
377
+ f"sandbox started but execd setup failed: {type(exc).__name__}: {exc}",
378
+ ) from exc
379
+
380
+ alloc_cpu, alloc_mem = self.allocated_resources(image, cpu, memory_gib)
381
+ expires_at = t0 + timeout_s
382
+ backend_data = {
383
+ **prov.backend_data,
384
+ "host": prov.host,
385
+ "image": image,
386
+ "entrypoint": entrypoint or DEFAULT_ENTRYPOINT,
387
+ "createdAt": iso(t0),
388
+ "expiresAt": iso(expires_at),
389
+ }
390
+ sandbox = self.shape(
391
+ prov.upstream_id, gw_id, "Running", backend_data=backend_data
392
+ )
393
+ return Created(
394
+ sandbox=sandbox,
395
+ cpu=alloc_cpu,
396
+ memory_gib=alloc_mem,
397
+ backend_data=backend_data,
398
+ cold=support == "cold",
399
+ )
400
+
401
+ async def wait_execd(
402
+ self, url: str, headers: dict[str, str], deadline: float
403
+ ) -> None:
404
+ last: str = "no response"
405
+ while time.monotonic() < deadline:
406
+ try:
407
+ r = await self._http.get(f"{url}/ping", headers=headers)
408
+ if r.status_code == 200:
409
+ return
410
+ last = f"HTTP {r.status_code}"
411
+ except httpx.HTTPError as exc:
412
+ last = type(exc).__name__
413
+ await asyncio.sleep(0.25)
414
+ raise TimeoutError(f"execd at {url} not ready ({last})")
415
+
416
+ async def discard(self, upstream_id: str) -> None:
417
+ try:
418
+ await self.kill(upstream_id)
419
+ except Exception as exc: # noqa: BLE001 - the reconciler deletes it later
420
+ logger.warning(
421
+ "could not delete %s on %s after a failed create: %s",
422
+ upstream_id,
423
+ self.name,
424
+ exc,
425
+ )
426
+
427
+ def shape(
428
+ self,
429
+ upstream_id: str,
430
+ gw_id: str | None,
431
+ state: str,
432
+ *,
433
+ backend_data: dict[str, Any] | None = None,
434
+ expires_at: float | None = None,
435
+ ) -> dict[str, Any]:
436
+ bd = backend_data or {}
437
+ out: dict[str, Any] = {
438
+ "id": upstream_id,
439
+ "status": {"state": state, "lastTransitionAt": bd.get("createdAt")},
440
+ "entrypoint": bd.get("entrypoint") or DEFAULT_ENTRYPOINT,
441
+ "createdAt": bd.get("createdAt") or iso(time.time()),
442
+ "metadata": {
443
+ MANAGED_KEY: self.instance_id,
444
+ **({GW_ID_KEY: gw_id} if gw_id else {}),
445
+ },
446
+ }
447
+ if bd.get("image"):
448
+ out["image"] = {"uri": bd["image"]}
449
+ exp = iso(expires_at) if expires_at else bd.get("expiresAt")
450
+ if exp:
451
+ out["expiresAt"] = exp
452
+ return out
453
+
454
+ async def get(self, rec: SandboxRecord) -> UpstreamResponse:
455
+ assert rec.upstream_id is not None
456
+ try:
457
+ found = await self.provider_state(rec.upstream_id)
458
+ except BackendError as exc:
459
+ return UpstreamResponse(
460
+ 502, error_body("STACKBOX::BACKEND_ERROR", str(exc))
461
+ )
462
+ if found is None:
463
+ return UpstreamResponse(
464
+ 404, error_body("SANDBOX::NOT_FOUND", "sandbox no longer exists")
465
+ )
466
+ state, expires_at = found
467
+ return UpstreamResponse(
468
+ 200,
469
+ self.shape(
470
+ rec.upstream_id,
471
+ rec.gw_id,
472
+ state,
473
+ backend_data=rec.backend_data,
474
+ expires_at=expires_at,
475
+ ),
476
+ )
477
+
478
+ async def delete(self, upstream_id: str) -> UpstreamResponse:
479
+ try:
480
+ existed = await self.kill(upstream_id)
481
+ except BackendError as exc:
482
+ return UpstreamResponse(
483
+ 502, error_body("STACKBOX::BACKEND_ERROR", str(exc))
484
+ )
485
+ return UpstreamResponse(204 if existed else 404, None)
486
+
487
+ async def subresource(
488
+ self,
489
+ rec: SandboxRecord,
490
+ method: str,
491
+ rest: str,
492
+ *,
493
+ params: Any = None,
494
+ json: Any = None,
495
+ ) -> UpstreamResponse:
496
+ parts = [p for p in rest.split("/") if p]
497
+ if method == "GET" and len(parts) == 2 and parts[0] == "endpoints":
498
+ try:
499
+ port = int(parts[1])
500
+ except ValueError:
501
+ return UpstreamResponse(400, error_body("STACKBOX::BAD_PORT", parts[1]))
502
+ found = self.endpoint_for(rec, port)
503
+ if found is None and port == EGRESS_PORT:
504
+ found = (f"no-egress-sidecar-on-{self.kind}.invalid", {})
505
+ if found is None:
506
+ return UpstreamResponse(
507
+ 404,
508
+ error_body(
509
+ "STACKBOX::PORT_NOT_EXPOSED",
510
+ f"port {port} is not exposed on backend {self.name}",
511
+ ),
512
+ )
513
+ host, headers = found
514
+ if port == EXECD_PORT:
515
+ headers = {**headers, EXECD_TOKEN_HEADER: self.execd_token(rec.gw_id)}
516
+ return UpstreamResponse(200, {"endpoint": host, "headers": headers})
517
+ if method == "POST" and parts == ["renew-expiration"]:
518
+ try:
519
+ expires_at = parse_iso(str((json or {})["expiresAt"]))
520
+ except (KeyError, ValueError) as exc:
521
+ return UpstreamResponse(
522
+ 400, error_body("STACKBOX::BAD_REQUEST", f"expiresAt: {exc}")
523
+ )
524
+ return await self.extend(rec, expires_at)
525
+ return UpstreamResponse(
526
+ 501,
527
+ error_body(
528
+ "STACKBOX::UNSUPPORTED",
529
+ f"{method} /sandboxes/{{id}}/{rest} is not supported on {self.kind} backends",
530
+ ),
531
+ )
532
+
533
+ async def list_sandboxes(
534
+ self, metadata: dict[str, str] | None = None
535
+ ) -> list[dict[str, Any]]:
536
+ return await self.list_provider(to_provider_tags(metadata or {}))