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 ADDED
@@ -0,0 +1,9 @@
1
+ """OpenSandbox-compatible gateway that routes sandboxes across providers."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ def main() -> None:
7
+ from stackbox.cli import main as cli_main
8
+
9
+ cli_main()
stackbox/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from stackbox import main
2
+
3
+ main()
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}")