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
stackbox/__init__.py
ADDED
stackbox/__main__.py
ADDED
stackbox/app.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""OpenSandbox-compatible lifecycle API that places sandboxes across backends.
|
|
2
|
+
|
|
3
|
+
Only lifecycle calls pass through the gateway. Command/file traffic goes from the
|
|
4
|
+
client straight to execd, at whatever endpoint the owning backend reports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
16
|
+
from fastapi.responses import JSONResponse, Response
|
|
17
|
+
|
|
18
|
+
from stackbox import ledger as L
|
|
19
|
+
from stackbox.backends import MANAGED_KEY, BackendError, make_backend
|
|
20
|
+
from stackbox.config import RouterConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _when(value: str | None) -> float | None:
|
|
24
|
+
"""ISO time, or a relative time ago: 30m, 24h, 7d."""
|
|
25
|
+
import datetime as dt
|
|
26
|
+
import re
|
|
27
|
+
import time as _time
|
|
28
|
+
|
|
29
|
+
if not value:
|
|
30
|
+
return None
|
|
31
|
+
m = re.fullmatch(r"(\d+(?:\.\d+)?)([smhd])", value.strip())
|
|
32
|
+
if m:
|
|
33
|
+
n, unit = float(m.group(1)), m.group(2)
|
|
34
|
+
return _time.time() - n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
|
35
|
+
try:
|
|
36
|
+
return dt.datetime.fromisoformat(value).timestamp()
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
raise ValueError(f"can't read time '{value}'") from exc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
from stackbox.router import PlacementError, Router, parse_preferences
|
|
42
|
+
from stackbox.router import Request as PlacementRequest
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
API_KEY_HEADER = "OPEN-SANDBOX-API-KEY"
|
|
47
|
+
PIN_HEADER = "X-Stackbox-Backend"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _error(
|
|
51
|
+
status: int, code: str, message: str, details: Any = None, hint: str | None = None
|
|
52
|
+
) -> JSONResponse:
|
|
53
|
+
# SDKs show code and message only, so the hint also goes into the message.
|
|
54
|
+
body: dict[str, Any] = {
|
|
55
|
+
"code": code,
|
|
56
|
+
"message": f"{message}. Hint: {hint}" if hint else message,
|
|
57
|
+
}
|
|
58
|
+
if details is not None:
|
|
59
|
+
body["details"] = details
|
|
60
|
+
if hint:
|
|
61
|
+
body["hint"] = hint
|
|
62
|
+
return JSONResponse(body, status_code=status)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _passthrough(status: int, body: Any) -> Response:
|
|
66
|
+
if status == 204 or body is None:
|
|
67
|
+
return Response(status_code=status)
|
|
68
|
+
return JSONResponse(body, status_code=status)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def create_app(
|
|
72
|
+
cfg: RouterConfig,
|
|
73
|
+
*,
|
|
74
|
+
transports: dict[str, httpx.AsyncBaseTransport] | None = None,
|
|
75
|
+
run_reconciler: bool = True,
|
|
76
|
+
) -> FastAPI:
|
|
77
|
+
transports = transports or {}
|
|
78
|
+
ledger = L.Ledger(cfg.gateway.db_path)
|
|
79
|
+
backends = {
|
|
80
|
+
b.name: make_backend(b, cfg.gateway, ledger, transport=transports.get(b.name))
|
|
81
|
+
for b in cfg.backends
|
|
82
|
+
}
|
|
83
|
+
router = Router(cfg, ledger, backends)
|
|
84
|
+
|
|
85
|
+
@contextlib.asynccontextmanager
|
|
86
|
+
async def lifespan(app: FastAPI):
|
|
87
|
+
task = (
|
|
88
|
+
asyncio.create_task(router.reconcile_forever()) if run_reconciler else None
|
|
89
|
+
)
|
|
90
|
+
yield
|
|
91
|
+
if task:
|
|
92
|
+
task.cancel()
|
|
93
|
+
for b in backends.values():
|
|
94
|
+
await b.aclose()
|
|
95
|
+
|
|
96
|
+
app = FastAPI(title="stackbox", lifespan=lifespan)
|
|
97
|
+
app.state.router = router
|
|
98
|
+
|
|
99
|
+
@app.middleware("http")
|
|
100
|
+
async def auth(request: Request, call_next):
|
|
101
|
+
key = cfg.gateway.api_key
|
|
102
|
+
if (
|
|
103
|
+
key
|
|
104
|
+
and request.url.path not in ("/health", "/v1/health")
|
|
105
|
+
and request.headers.get(API_KEY_HEADER) != key
|
|
106
|
+
):
|
|
107
|
+
return _error(
|
|
108
|
+
401,
|
|
109
|
+
"STACKBOX::UNAUTHORIZED",
|
|
110
|
+
"missing or invalid API key",
|
|
111
|
+
hint="send the gateway's api_key in the OPEN-SANDBOX-API-KEY header "
|
|
112
|
+
"(OpenSandbox SDK: ConnectionConfig(api_key=...))",
|
|
113
|
+
)
|
|
114
|
+
return await call_next(request)
|
|
115
|
+
|
|
116
|
+
api = APIRouter()
|
|
117
|
+
|
|
118
|
+
@api.get("/health")
|
|
119
|
+
async def health():
|
|
120
|
+
return {"status": "healthy"}
|
|
121
|
+
|
|
122
|
+
@api.post("/sandboxes")
|
|
123
|
+
async def create_sandbox(request: Request):
|
|
124
|
+
body = await request.json()
|
|
125
|
+
try:
|
|
126
|
+
created = await router.create(
|
|
127
|
+
body,
|
|
128
|
+
request.headers.get(PIN_HEADER),
|
|
129
|
+
dict(request.headers),
|
|
130
|
+
)
|
|
131
|
+
except PlacementError as exc:
|
|
132
|
+
if exc.code == "STACKBOX::UPSTREAM_REJECTED" and isinstance(
|
|
133
|
+
exc.details, dict
|
|
134
|
+
):
|
|
135
|
+
return JSONResponse(exc.details, status_code=exc.status)
|
|
136
|
+
return _error(exc.status, exc.code, str(exc), exc.details, exc.hint)
|
|
137
|
+
return JSONResponse(created, status_code=202)
|
|
138
|
+
|
|
139
|
+
@api.get("/sandboxes")
|
|
140
|
+
async def list_sandboxes(request: Request):
|
|
141
|
+
page = int(request.query_params.get("page", 1))
|
|
142
|
+
page_size = int(request.query_params.get("pageSize", 20))
|
|
143
|
+
states = set(request.query_params.getlist("state"))
|
|
144
|
+
items: list[dict[str, Any]] = []
|
|
145
|
+
for name, backend in backends.items():
|
|
146
|
+
try:
|
|
147
|
+
upstream = await backend.list_sandboxes(
|
|
148
|
+
{MANAGED_KEY: router.instance_id}
|
|
149
|
+
)
|
|
150
|
+
except BackendError:
|
|
151
|
+
logger.warning("list failed for backend %s", name)
|
|
152
|
+
continue
|
|
153
|
+
for s in upstream:
|
|
154
|
+
rec = router.ledger.by_upstream(name, s["id"])
|
|
155
|
+
if rec is None or rec.state != L.ACTIVE:
|
|
156
|
+
continue
|
|
157
|
+
if states and (s.get("status") or {}).get("state") not in states:
|
|
158
|
+
continue
|
|
159
|
+
items.append(router.to_client(s, rec.gw_id))
|
|
160
|
+
meta_filter = request.query_params.get("metadata")
|
|
161
|
+
if meta_filter:
|
|
162
|
+
wanted = dict(p.split("=", 1) for p in meta_filter.split("&") if "=" in p)
|
|
163
|
+
items = [
|
|
164
|
+
i
|
|
165
|
+
for i in items
|
|
166
|
+
if all((i.get("metadata") or {}).get(k) == v for k, v in wanted.items())
|
|
167
|
+
]
|
|
168
|
+
total = len(items)
|
|
169
|
+
start = (page - 1) * page_size
|
|
170
|
+
return {
|
|
171
|
+
"items": items[start : start + page_size],
|
|
172
|
+
"pagination": {
|
|
173
|
+
"page": page,
|
|
174
|
+
"pageSize": page_size,
|
|
175
|
+
"totalItems": total,
|
|
176
|
+
"totalPages": (total + page_size - 1) // page_size,
|
|
177
|
+
"hasNextPage": start + page_size < total,
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
@api.get("/sandboxes/{gw_id}")
|
|
182
|
+
async def get_sandbox(gw_id: str):
|
|
183
|
+
try:
|
|
184
|
+
rec, backend = router.resolve(gw_id)
|
|
185
|
+
except PlacementError as exc:
|
|
186
|
+
return _error(exc.status, exc.code, str(exc))
|
|
187
|
+
try:
|
|
188
|
+
resp = await backend.get(rec)
|
|
189
|
+
except httpx.HTTPError as exc:
|
|
190
|
+
return _error(502, "STACKBOX::BACKEND_ERROR", str(exc))
|
|
191
|
+
if resp.status == 404:
|
|
192
|
+
router.ledger.set_state(gw_id, L.EXPIRED, "upstream returned 404")
|
|
193
|
+
if resp.status == 200 and isinstance(resp.body, dict):
|
|
194
|
+
return router.to_client(resp.body, gw_id)
|
|
195
|
+
return _passthrough(resp.status, resp.body)
|
|
196
|
+
|
|
197
|
+
@api.delete("/sandboxes/{gw_id}")
|
|
198
|
+
async def delete_sandbox(gw_id: str):
|
|
199
|
+
try:
|
|
200
|
+
status = await router.delete(gw_id)
|
|
201
|
+
except PlacementError as exc:
|
|
202
|
+
return _error(exc.status, exc.code, str(exc))
|
|
203
|
+
return Response(status_code=204 if status in (200, 202, 204, 404) else status)
|
|
204
|
+
|
|
205
|
+
@api.api_route(
|
|
206
|
+
"/sandboxes/{gw_id}/{rest:path}",
|
|
207
|
+
methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
208
|
+
)
|
|
209
|
+
async def sandbox_subresource(gw_id: str, rest: str, request: Request):
|
|
210
|
+
"""Endpoints, renew-expiration, pause/resume, metadata, network policy, ..."""
|
|
211
|
+
try:
|
|
212
|
+
rec, backend = router.resolve(gw_id)
|
|
213
|
+
except PlacementError as exc:
|
|
214
|
+
return _error(exc.status, exc.code, str(exc))
|
|
215
|
+
raw = await request.body()
|
|
216
|
+
try:
|
|
217
|
+
resp = await backend.subresource(
|
|
218
|
+
rec,
|
|
219
|
+
request.method,
|
|
220
|
+
rest,
|
|
221
|
+
params=list(request.query_params.multi_items()),
|
|
222
|
+
json=(await request.json()) if raw else None,
|
|
223
|
+
)
|
|
224
|
+
except httpx.HTTPError as exc:
|
|
225
|
+
return _error(502, "STACKBOX::BACKEND_ERROR", str(exc))
|
|
226
|
+
body = resp.body
|
|
227
|
+
if isinstance(body, dict) and body.get("id") == rec.upstream_id:
|
|
228
|
+
body = router.to_client(body, gw_id)
|
|
229
|
+
return _passthrough(resp.status, body)
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# stackbox API: plain nouns under /v1, beside the OpenSandbox routes. They avoid
|
|
233
|
+
# OpenSandbox's names (sandboxes, snapshots, templates, metrics) and never add
|
|
234
|
+
# to /sandboxes/{id}/..., which OpenSandbox keeps extending.
|
|
235
|
+
ext = APIRouter(prefix="/v1")
|
|
236
|
+
|
|
237
|
+
def _provider_or_404(name: str) -> dict[str, Any] | JSONResponse:
|
|
238
|
+
if name not in backends:
|
|
239
|
+
return _error(
|
|
240
|
+
404,
|
|
241
|
+
"STACKBOX::UNKNOWN_BACKEND",
|
|
242
|
+
f"no provider named '{name}'",
|
|
243
|
+
hint=f"configured providers: {', '.join(backends)}",
|
|
244
|
+
)
|
|
245
|
+
return router.provider_view(name)
|
|
246
|
+
|
|
247
|
+
@ext.get("/providers")
|
|
248
|
+
async def list_providers():
|
|
249
|
+
"""Every configured provider: pricing, capacity, health, learned startup,
|
|
250
|
+
prepared images and usage."""
|
|
251
|
+
return {"items": [router.provider_view(n) for n in backends]}
|
|
252
|
+
|
|
253
|
+
@ext.get("/providers/{name}")
|
|
254
|
+
async def get_provider(name: str):
|
|
255
|
+
return _provider_or_404(name)
|
|
256
|
+
|
|
257
|
+
@ext.get("/route")
|
|
258
|
+
async def route(
|
|
259
|
+
image: str,
|
|
260
|
+
cpu: float | None = None,
|
|
261
|
+
memory_gib: float | None = None,
|
|
262
|
+
strategy: str | None = None,
|
|
263
|
+
providers: str | None = None,
|
|
264
|
+
max_price: float | None = None,
|
|
265
|
+
):
|
|
266
|
+
"""Where a sandbox for this image would go right now, and why, for every
|
|
267
|
+
provider (dry run). Takes the same preferences as a create."""
|
|
268
|
+
try:
|
|
269
|
+
strat, provs, price = parse_preferences(
|
|
270
|
+
{
|
|
271
|
+
k: v
|
|
272
|
+
for k, v in {
|
|
273
|
+
"x-stackbox-strategy": strategy,
|
|
274
|
+
"x-stackbox-providers": providers,
|
|
275
|
+
"x-stackbox-max-price": None
|
|
276
|
+
if max_price is None
|
|
277
|
+
else str(max_price),
|
|
278
|
+
}.items()
|
|
279
|
+
if v
|
|
280
|
+
},
|
|
281
|
+
{},
|
|
282
|
+
)
|
|
283
|
+
req = PlacementRequest(image, cpu, memory_gib, strat, provs, price)
|
|
284
|
+
return {
|
|
285
|
+
"image": image,
|
|
286
|
+
"strategy": strat or cfg.routing.strategy,
|
|
287
|
+
"items": router.explain(req),
|
|
288
|
+
}
|
|
289
|
+
except PlacementError as exc:
|
|
290
|
+
return _error(exc.status, exc.code, str(exc), exc.details, exc.hint)
|
|
291
|
+
|
|
292
|
+
@ext.get("/placements/{sandbox_id}")
|
|
293
|
+
async def get_placement(sandbox_id: str):
|
|
294
|
+
"""Where a sandbox went, every provisioning attempt, and its cost so far."""
|
|
295
|
+
found = router.placement(sandbox_id)
|
|
296
|
+
if found is None:
|
|
297
|
+
return _error(
|
|
298
|
+
404,
|
|
299
|
+
"STACKBOX::NOT_FOUND",
|
|
300
|
+
f"no sandbox {sandbox_id} in this gateway's ledger",
|
|
301
|
+
hint="ids are the ones POST /v1/sandboxes returned",
|
|
302
|
+
)
|
|
303
|
+
return found
|
|
304
|
+
|
|
305
|
+
@ext.get("/images")
|
|
306
|
+
async def list_images(image: str | None = None):
|
|
307
|
+
"""Prepared images per provider (optionally one image)."""
|
|
308
|
+
return {
|
|
309
|
+
"items": [
|
|
310
|
+
{
|
|
311
|
+
"provider": i["backend"],
|
|
312
|
+
**{k: i[k] for k in ("image", "status", "ref", "error")},
|
|
313
|
+
}
|
|
314
|
+
for i in router.ledger.images()
|
|
315
|
+
if image is None or i["image"] == image
|
|
316
|
+
]
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
@ext.post("/images")
|
|
320
|
+
async def prepare_images(request: Request):
|
|
321
|
+
"""Prepare an image: {"image": "ubuntu:24.04", "providers": ["e2b"]} (providers
|
|
322
|
+
optional, default all). Builds templates/layers, pushes to registries."""
|
|
323
|
+
body = await request.json() if await request.body() else {}
|
|
324
|
+
image = body.get("image")
|
|
325
|
+
if not image:
|
|
326
|
+
return _error(
|
|
327
|
+
400,
|
|
328
|
+
"STACKBOX::BAD_REQUEST",
|
|
329
|
+
"image is required",
|
|
330
|
+
hint='send {"image": "ubuntu:24.04"}',
|
|
331
|
+
)
|
|
332
|
+
names = body.get("providers") or [
|
|
333
|
+
n for n, b in backends.items() if b.kind != "opensandbox"
|
|
334
|
+
]
|
|
335
|
+
unknown = [n for n in names if n not in backends]
|
|
336
|
+
if unknown:
|
|
337
|
+
return _error(
|
|
338
|
+
400,
|
|
339
|
+
"STACKBOX::UNKNOWN_BACKEND",
|
|
340
|
+
f"unknown provider(s): {', '.join(unknown)}",
|
|
341
|
+
hint=f"configured providers: {', '.join(backends)}",
|
|
342
|
+
)
|
|
343
|
+
results = await asyncio.gather(
|
|
344
|
+
*(backends[n].prepare(image) for n in names), return_exceptions=True
|
|
345
|
+
)
|
|
346
|
+
return {
|
|
347
|
+
"items": [
|
|
348
|
+
{"provider": n, "image": image, "ok": False, "error": str(r)}
|
|
349
|
+
if isinstance(r, BaseException)
|
|
350
|
+
else {
|
|
351
|
+
"provider": n,
|
|
352
|
+
"ok": True,
|
|
353
|
+
**{k: v for k, v in r.items() if k != "backend"},
|
|
354
|
+
}
|
|
355
|
+
for n, r in zip(names, results, strict=True)
|
|
356
|
+
]
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
@ext.get("/usage")
|
|
360
|
+
async def usage(
|
|
361
|
+
since: str | None = None, until: str | None = None, group_by: str = "provider"
|
|
362
|
+
):
|
|
363
|
+
"""Sandbox time and estimated cost. since/until: ISO time or relative (30m, 24h, 7d)."""
|
|
364
|
+
if group_by not in ("provider", "sandbox"):
|
|
365
|
+
return _error(
|
|
366
|
+
400,
|
|
367
|
+
"STACKBOX::BAD_REQUEST",
|
|
368
|
+
f"group_by '{group_by}'",
|
|
369
|
+
hint="use provider or sandbox",
|
|
370
|
+
)
|
|
371
|
+
try:
|
|
372
|
+
lo, hi = _when(since), _when(until)
|
|
373
|
+
except ValueError as exc:
|
|
374
|
+
return _error(
|
|
375
|
+
400,
|
|
376
|
+
"STACKBOX::BAD_REQUEST",
|
|
377
|
+
str(exc),
|
|
378
|
+
hint="use an ISO time or 30m, 24h, 7d",
|
|
379
|
+
)
|
|
380
|
+
return router.usage(lo, hi, group_by)
|
|
381
|
+
|
|
382
|
+
@ext.post("/admin/reconcile")
|
|
383
|
+
async def reconcile():
|
|
384
|
+
"""Run cleanup now: resolve unknown creates, expire vanished sandboxes, delete leaks."""
|
|
385
|
+
return await router.reconcile_once()
|
|
386
|
+
|
|
387
|
+
@ext.api_route(
|
|
388
|
+
"/snapshots{rest:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
389
|
+
)
|
|
390
|
+
@ext.api_route(
|
|
391
|
+
"/templates{rest:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
392
|
+
)
|
|
393
|
+
async def unsupported(request: Request, rest: str = ""):
|
|
394
|
+
return _error(
|
|
395
|
+
501,
|
|
396
|
+
"STACKBOX::UNSUPPORTED",
|
|
397
|
+
f"{request.method} {request.url.path} is not supported by stackbox yet",
|
|
398
|
+
hint="stackbox routes sandboxes across providers; snapshots and templates are "
|
|
399
|
+
"provider-specific. Prepare images with POST /v1/images instead of templates",
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
app.include_router(ext)
|
|
403
|
+
app.include_router(api)
|
|
404
|
+
app.include_router(api, prefix="/v1")
|
|
405
|
+
return app
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Backend kinds the router can place sandboxes on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from stackbox import ledger as L
|
|
10
|
+
from stackbox.backends.base import (
|
|
11
|
+
GONE_STATES,
|
|
12
|
+
GW_ID_KEY,
|
|
13
|
+
MANAGED_KEY,
|
|
14
|
+
PIN_KEY,
|
|
15
|
+
ROUTER_KEYS,
|
|
16
|
+
Backend,
|
|
17
|
+
BackendError,
|
|
18
|
+
Created,
|
|
19
|
+
CreateFailure,
|
|
20
|
+
ReconcileError,
|
|
21
|
+
UpstreamResponse,
|
|
22
|
+
)
|
|
23
|
+
from stackbox.backends.opensandbox import OpenSandboxBackend
|
|
24
|
+
from stackbox.config import BackendConfig, GatewaySettings
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"GONE_STATES",
|
|
28
|
+
"GW_ID_KEY",
|
|
29
|
+
"MANAGED_KEY",
|
|
30
|
+
"PIN_KEY",
|
|
31
|
+
"ROUTER_KEYS",
|
|
32
|
+
"Backend",
|
|
33
|
+
"BackendError",
|
|
34
|
+
"CreateFailure",
|
|
35
|
+
"Created",
|
|
36
|
+
"OpenSandboxBackend",
|
|
37
|
+
"ReconcileError",
|
|
38
|
+
"UpstreamResponse",
|
|
39
|
+
"gateway_instance_id",
|
|
40
|
+
"make_backend",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def gateway_instance_id(ledger: L.Ledger) -> str:
|
|
45
|
+
"""Stable id of the gateway that owns this ledger; the value of the managed tag."""
|
|
46
|
+
return ledger.get_or_create_secret(
|
|
47
|
+
"instance_id", lambda: "gw" + secrets.token_hex(6)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def make_backend(
|
|
52
|
+
cfg: BackendConfig,
|
|
53
|
+
settings: GatewaySettings,
|
|
54
|
+
ledger: L.Ledger,
|
|
55
|
+
*,
|
|
56
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
57
|
+
) -> Backend:
|
|
58
|
+
instance_id = gateway_instance_id(ledger)
|
|
59
|
+
if cfg.kind == "opensandbox":
|
|
60
|
+
b: Backend = OpenSandboxBackend(
|
|
61
|
+
cfg,
|
|
62
|
+
create_timeout_s=settings.create_timeout_s,
|
|
63
|
+
request_timeout_s=settings.request_timeout_s,
|
|
64
|
+
transport=transport,
|
|
65
|
+
)
|
|
66
|
+
b.instance_id = instance_id
|
|
67
|
+
return b
|
|
68
|
+
token_key = ledger.get_or_create_secret(
|
|
69
|
+
"execd_token_key", lambda: secrets.token_hex(32)
|
|
70
|
+
)
|
|
71
|
+
if cfg.kind == "e2b":
|
|
72
|
+
from stackbox.backends.e2b import E2BBackend
|
|
73
|
+
|
|
74
|
+
b = E2BBackend(cfg, settings=settings, ledger=ledger, token_key=token_key)
|
|
75
|
+
b.instance_id = instance_id
|
|
76
|
+
return b
|
|
77
|
+
if cfg.kind == "modal":
|
|
78
|
+
from stackbox.backends.modal import ModalBackend
|
|
79
|
+
|
|
80
|
+
b = ModalBackend(cfg, settings=settings, ledger=ledger, token_key=token_key)
|
|
81
|
+
b.instance_id = instance_id
|
|
82
|
+
return b
|
|
83
|
+
if cfg.kind == "cloudflare":
|
|
84
|
+
from stackbox.backends.cloudflare import CloudflareBackend
|
|
85
|
+
|
|
86
|
+
b = CloudflareBackend(
|
|
87
|
+
cfg,
|
|
88
|
+
settings=settings,
|
|
89
|
+
ledger=ledger,
|
|
90
|
+
token_key=token_key,
|
|
91
|
+
transport=transport,
|
|
92
|
+
)
|
|
93
|
+
b.instance_id = instance_id
|
|
94
|
+
return b
|
|
95
|
+
if cfg.kind == "daytona":
|
|
96
|
+
from stackbox.backends.daytona import DaytonaBackend
|
|
97
|
+
|
|
98
|
+
b = DaytonaBackend(cfg, settings=settings, ledger=ledger, token_key=token_key)
|
|
99
|
+
b.instance_id = instance_id
|
|
100
|
+
return b
|
|
101
|
+
if cfg.kind == "vercel":
|
|
102
|
+
from stackbox.backends.vercel import VercelBackend
|
|
103
|
+
|
|
104
|
+
b = VercelBackend(cfg, settings=settings, ledger=ledger, token_key=token_key)
|
|
105
|
+
b.instance_id = instance_id
|
|
106
|
+
return b
|
|
107
|
+
raise ValueError(f"unknown backend kind {cfg.kind}")
|