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,181 @@
1
+ """What the router needs from a backend, and the errors backends raise.
2
+
3
+ Create failures are classified so the router can decide whether retrying elsewhere
4
+ is safe:
5
+
6
+ * ``clear`` no sandbox exists on the backend (or one was created and we already
7
+ cleaned it up) -> safe to try elsewhere
8
+ * ``ambiguous`` the request may have been processed -> reconcile before retrying
9
+ * ``client`` the request itself is invalid -> return to caller, no retry
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING, Any, Literal
16
+
17
+ from stackbox.config import BackendConfig
18
+
19
+ if TYPE_CHECKING:
20
+ from stackbox.ledger import SandboxRecord
21
+
22
+ GW_ID_KEY = "stackbox.dev/gw-id"
23
+ # Value: the owning gateway's instance id (one per ledger). The reconciler only ever
24
+ # deletes sandboxes carrying its own id, so gateways sharing a provider account (or a
25
+ # test gateway next to a production one) never delete each other's sandboxes.
26
+ MANAGED_KEY = "stackbox.dev/managed"
27
+ PIN_KEY = "stackbox.dev/backend"
28
+ # Per-request routing preferences as metadata (Harbor can set metadata, not headers).
29
+ STRATEGY_KEY = "stackbox.dev/strategy"
30
+ PROVIDERS_KEY = (
31
+ "stackbox.dev/providers" # allow-list in order: "e2b.modal" or "e2b,modal"
32
+ )
33
+ MAX_PRICE_KEY = "stackbox.dev/max-price" # USD per sandbox-hour
34
+ ROUTER_KEYS = (
35
+ GW_ID_KEY,
36
+ MANAGED_KEY,
37
+ PIN_KEY,
38
+ STRATEGY_KEY,
39
+ PROVIDERS_KEY,
40
+ MAX_PRICE_KEY,
41
+ )
42
+
43
+ GONE_STATES = {"Terminated", "Failed", "Stopping"}
44
+
45
+ FailureKind = Literal["clear", "ambiguous", "client"]
46
+
47
+ # What a backend can do with an image right now.
48
+ # ready: prepared (or the backend pulls images itself and has no preparation step)
49
+ # cold: can serve it, but the first start includes an image build
50
+ # None: cannot serve it until it is prepared
51
+ ImageSupport = Literal["ready", "cold"] | None
52
+
53
+
54
+ @dataclass
55
+ class UpstreamResponse:
56
+ status: int
57
+ body: Any
58
+ headers: dict[str, str] = field(default_factory=dict)
59
+
60
+
61
+ @dataclass
62
+ class Created:
63
+ """A sandbox a backend created and reports as running."""
64
+
65
+ sandbox: dict[str, Any] # OpenSandbox Sandbox shape, upstream id, router metadata
66
+ cpu: float | None = None # what was actually allocated, if the backend knows
67
+ memory_gib: float | None = None
68
+ backend_data: dict[str, Any] | None = None # persisted; e.g. endpoint details
69
+ cold: bool = False # the start included an image build
70
+
71
+
72
+ class CreateFailure(Exception):
73
+ def __init__(
74
+ self,
75
+ kind: FailureKind,
76
+ message: str,
77
+ status: int | None = None,
78
+ body: Any = None,
79
+ ):
80
+ super().__init__(message)
81
+ self.kind = kind
82
+ self.status = status
83
+ self.body = body
84
+
85
+
86
+ class BackendError(Exception):
87
+ """A non-create call to a backend failed (network, auth, 5xx)."""
88
+
89
+
90
+ class ReconcileError(BackendError):
91
+ """The backend could not be queried, so an ambiguous create stays unresolved."""
92
+
93
+
94
+ def error_body(code: str, message: str) -> dict[str, str]:
95
+ return {"code": code, "message": message}
96
+
97
+
98
+ class Backend:
99
+ """Interface every backend kind implements. Upstream ids never leave the router."""
100
+
101
+ kind: str = "abstract"
102
+ #: After an ambiguous create, a sandbox found upstream may be handed to the client.
103
+ #: Hosted backends set this False: a found sandbox may not have finished execd
104
+ #: setup, so it is deleted and the create retried instead.
105
+ adopt_ambiguous: bool = True
106
+
107
+ def __init__(self, cfg: BackendConfig, instance_id: str = "true"):
108
+ self.cfg = cfg
109
+ self.instance_id = instance_id
110
+
111
+ @property
112
+ def name(self) -> str:
113
+ return self.cfg.name
114
+
115
+ async def aclose(self) -> None:
116
+ return None
117
+
118
+ # --- placement ---------------------------------------------------------
119
+ def image_support(
120
+ self, image: str | None, cpu: float | None, memory_gib: float | None
121
+ ) -> ImageSupport:
122
+ return "ready"
123
+
124
+ def allocated_resources(
125
+ self, image: str | None, cpu: float | None, memory_gib: float | None
126
+ ) -> tuple[float, float]:
127
+ """(vCPU, GiB) a create with these requests would be billed for."""
128
+ return (
129
+ cpu if cpu is not None else self.cfg.default_cpu,
130
+ memory_gib if memory_gib is not None else self.cfg.default_memory_gib,
131
+ )
132
+
133
+ def price_per_hour(
134
+ self, image: str | None, cpu: float | None, memory_gib: float | None
135
+ ) -> float:
136
+ vcpu, gib = self.allocated_resources(image, cpu, memory_gib)
137
+ return (
138
+ vcpu * self.cfg.cost_per_vcpu_hour * self.cfg.cpu_billed_fraction
139
+ + gib * self.cfg.cost_per_gib_hour
140
+ )
141
+
142
+ async def prepare(self, image: str) -> dict[str, Any]:
143
+ """Make the image fast to start on this backend. Returns a summary."""
144
+ return {"backend": self.name, "image": image, "status": "ready", "ref": None}
145
+
146
+ # --- lifecycle ---------------------------------------------------------
147
+ async def create(self, body: dict[str, Any], *, gw_id: str) -> Created:
148
+ raise NotImplementedError
149
+
150
+ async def get(self, rec: SandboxRecord) -> UpstreamResponse:
151
+ raise NotImplementedError
152
+
153
+ async def delete(self, upstream_id: str) -> UpstreamResponse:
154
+ raise NotImplementedError
155
+
156
+ async def subresource(
157
+ self,
158
+ rec: SandboxRecord,
159
+ method: str,
160
+ rest: str,
161
+ *,
162
+ params: Any = None,
163
+ json: Any = None,
164
+ ) -> UpstreamResponse:
165
+ raise NotImplementedError
166
+
167
+ async def list_sandboxes(
168
+ self, metadata: dict[str, str] | None = None
169
+ ) -> list[dict[str, Any]]:
170
+ """Sandboxes matching the metadata filter, in OpenSandbox shape. Raises BackendError."""
171
+ raise NotImplementedError
172
+
173
+ async def find_by_gw_id(self, gw_id: str) -> dict[str, Any] | None:
174
+ try:
175
+ found = await self.list_sandboxes({GW_ID_KEY: gw_id})
176
+ except BackendError as exc:
177
+ raise ReconcileError(f"{self.name}: {exc}") from exc
178
+ live = [
179
+ s for s in found if (s.get("status") or {}).get("state") not in GONE_STATES
180
+ ]
181
+ return live[0] if live else None
@@ -0,0 +1,241 @@
1
+ """Cloudflare backend: Cloudflare Containers, through a bridge Worker you deploy.
2
+
3
+ Cloudflare has no hosted "create a sandbox" API: sandboxes run inside your own Worker.
4
+ `bridges/cloudflare/` is that Worker. It runs the task image plus execd on Cloudflare
5
+ Containers (the layer Cloudflare's Sandbox SDK is built on), exposes a small control
6
+ API to the gateway, and proxies execd traffic at ``<bridge>/x/<id>/...``, so no
7
+ wildcard custom domain is needed.
8
+
9
+ Config: ``url`` is the bridge (e.g. https://stackbox-bridge.<account>.workers.dev),
10
+ ``api_key`` its BRIDGE_TOKEN. Images are fixed when the bridge is deployed (one container
11
+ class per image), so "preparing" an image only checks the bridge serves it; vCPU and
12
+ memory come from the class's instance type. Cloudflare bills CPU by active use only:
13
+ model that with ``cpu_billed_fraction``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from typing import TYPE_CHECKING, Any
20
+ from urllib.parse import urlsplit
21
+
22
+ import httpx
23
+
24
+ from stackbox import ledger as L
25
+ from stackbox.backends.base import (
26
+ BackendError,
27
+ CreateFailure,
28
+ ReconcileError,
29
+ UpstreamResponse,
30
+ )
31
+ from stackbox.backends.hosted import (
32
+ EXECD_PORT,
33
+ HostedBackend,
34
+ Provisioned,
35
+ from_provider_tags,
36
+ iso,
37
+ parse_iso,
38
+ )
39
+ from stackbox.config import BackendConfig, GatewaySettings
40
+
41
+ if TYPE_CHECKING:
42
+ from stackbox.ledger import SandboxRecord
43
+
44
+ _NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
45
+
46
+
47
+ class CloudflareBackend(HostedBackend):
48
+ kind = "cloudflare"
49
+ unprepared_support = None # images are baked into the bridge deployment
50
+
51
+ def __init__(
52
+ self,
53
+ cfg: BackendConfig,
54
+ *,
55
+ settings: GatewaySettings,
56
+ ledger: L.Ledger,
57
+ token_key: str,
58
+ transport: httpx.AsyncBaseTransport | None = None,
59
+ ):
60
+ super().__init__(cfg, settings=settings, ledger=ledger, token_key=token_key)
61
+ if not cfg.url:
62
+ raise ValueError(
63
+ f"backend {cfg.name}: kind=cloudflare requires url (the bridge)"
64
+ )
65
+ parts = urlsplit(cfg.url.rstrip("/"))
66
+ self.scheme = parts.scheme or "https"
67
+ self.bridge_host = parts.netloc + parts.path # host[/prefix], no scheme
68
+ headers = {"authorization": f"Bearer {cfg.api_key}"} if cfg.api_key else {}
69
+ self._bridge = httpx.AsyncClient(
70
+ base_url=cfg.url.rstrip("/"),
71
+ headers=headers,
72
+ timeout=settings.request_timeout_s,
73
+ transport=transport,
74
+ )
75
+ self._images: tuple[float, dict[str, Any]] | None = None
76
+
77
+ async def aclose(self) -> None:
78
+ await self._bridge.aclose()
79
+ await super().aclose()
80
+
81
+ def sandbox_id(self, gw_id: str) -> str:
82
+ return f"stackbox-{gw_id}"
83
+
84
+ async def bridge_images(self) -> dict[str, Any]:
85
+ if self._images is None or time.monotonic() - self._images[0] > 60:
86
+ r = await self._bridge.get("/v1/images")
87
+ r.raise_for_status()
88
+ self._images = (time.monotonic(), r.json())
89
+ return self._images[1]
90
+
91
+ def allocated_resources(
92
+ self, image: str | None, cpu: float | None, memory_gib: float | None
93
+ ) -> tuple[float, float]:
94
+ rec = self.image_record(image) if image else None
95
+ if rec is not None and rec.detail and "cpu" in rec.detail:
96
+ return float(rec.detail["cpu"]), float(rec.detail["memory_gib"])
97
+ return self.requested_resources(cpu, memory_gib)
98
+
99
+ async def build_image(self, image: str) -> tuple[str, dict[str, Any]]:
100
+ images = await self.bridge_images()
101
+ info = images.get(image)
102
+ if info is None:
103
+ raise RuntimeError(
104
+ f"the bridge does not serve {image}; add a container class for it "
105
+ "(bridges/cloudflare/README.md) and redeploy"
106
+ )
107
+ return str(info["binding"]), {
108
+ "cpu": float(info["vcpu"]),
109
+ "memory_gib": float(info["memory_gib"]),
110
+ }
111
+
112
+ async def provision(
113
+ self,
114
+ *,
115
+ gw_id: str,
116
+ image: str,
117
+ image_rec: L.ImageRecord | None,
118
+ cpu: float,
119
+ memory_gib: float,
120
+ timeout_s: int,
121
+ env: dict[str, str],
122
+ entrypoint: list[str] | None,
123
+ token: str,
124
+ ) -> Provisioned:
125
+ sid = self.sandbox_id(gw_id)
126
+ body = {
127
+ "id": sid,
128
+ "image": image,
129
+ "env": env,
130
+ "token": token,
131
+ "ttl_s": timeout_s,
132
+ "labels": {"stackbox_gw_id": gw_id, "stackbox_managed": self.instance_id},
133
+ }
134
+ try:
135
+ r = await self._bridge.post(
136
+ "/v1/sandboxes", json=body, timeout=self._settings.create_timeout_s
137
+ )
138
+ except _NOT_SENT as exc:
139
+ raise CreateFailure("clear", f"{type(exc).__name__}: {exc}") from exc
140
+ except httpx.HTTPError as exc:
141
+ raise CreateFailure("ambiguous", f"{type(exc).__name__}: {exc}") from exc
142
+ if r.status_code == 201:
143
+ return Provisioned(
144
+ upstream_id=sid,
145
+ host=f"{self.bridge_host}/x/{sid}",
146
+ backend_data={"bridge_path": f"/x/{sid}"},
147
+ scheme=self.scheme,
148
+ )
149
+ detail = r.text[:300]
150
+ if r.status_code == 429:
151
+ raise CreateFailure("clear", f"Cloudflare at capacity: {detail}", 429)
152
+ if r.status_code == 404:
153
+ self._ledger.set_image(
154
+ self.name, image, L.IMAGE_FAILED, error=f"bridge: {detail}"[:500]
155
+ )
156
+ raise CreateFailure("clear", f"bridge does not serve {image}", 404)
157
+ if r.status_code in (400, 401, 403):
158
+ raise CreateFailure(
159
+ "clear", f"bridge rejected create: {detail}", r.status_code
160
+ )
161
+ if r.status_code == 502:
162
+ # The bridge reports a failed start after destroying the container.
163
+ raise CreateFailure("clear", f"container failed to start: {detail}", 502)
164
+ # 409 (id exists), other 5xx: it may exist; the router reconciles by id.
165
+ raise CreateFailure(
166
+ "ambiguous", f"bridge {r.status_code}: {detail}", r.status_code
167
+ )
168
+
169
+ async def provider_state(self, upstream_id: str) -> tuple[str, float | None] | None:
170
+ try:
171
+ r = await self._bridge.get(f"/v1/sandboxes/{upstream_id}")
172
+ except httpx.HTTPError as exc:
173
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
174
+ if r.status_code == 404:
175
+ return None
176
+ if r.status_code != 200:
177
+ raise BackendError(f"bridge {r.status_code}: {r.text[:200]}")
178
+ exp = r.json().get("expires_at")
179
+ return "Running", parse_iso(exp) if exp else None
180
+
181
+ async def kill(self, upstream_id: str) -> bool:
182
+ try:
183
+ r = await self._bridge.delete(f"/v1/sandboxes/{upstream_id}")
184
+ except httpx.HTTPError as exc:
185
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
186
+ if r.status_code == 404:
187
+ return False
188
+ if r.status_code not in (200, 204):
189
+ raise BackendError(f"bridge {r.status_code}: {r.text[:200]}")
190
+ return True
191
+
192
+ def endpoint_for(
193
+ self, rec: SandboxRecord, port: int
194
+ ) -> tuple[str, dict[str, str]] | None:
195
+ if port != EXECD_PORT or not rec.upstream_id:
196
+ return None
197
+ return f"{self.bridge_host}/x/{rec.upstream_id}", {}
198
+
199
+ async def extend(self, rec: SandboxRecord, expires_at: float) -> UpstreamResponse:
200
+ try:
201
+ r = await self._bridge.post(
202
+ f"/v1/sandboxes/{rec.upstream_id}/renew-expiration",
203
+ json={"expires_at": iso(expires_at)},
204
+ )
205
+ except httpx.HTTPError as exc:
206
+ return UpstreamResponse(
207
+ 502, {"code": "STACKBOX::BACKEND_ERROR", "message": str(exc)}
208
+ )
209
+ if r.status_code != 200:
210
+ return UpstreamResponse(r.status_code, r.json() if r.content else None)
211
+ return UpstreamResponse(200, {"expiresAt": r.json()["expires_at"]})
212
+
213
+ def _as_dict(self, item: dict[str, Any]) -> dict[str, Any]:
214
+ return {
215
+ "id": item["id"],
216
+ "status": {"state": "Running"},
217
+ "createdAt": item.get("created_at"),
218
+ "metadata": from_provider_tags(item.get("labels") or {}),
219
+ }
220
+
221
+ async def find_by_gw_id(self, gw_id: str) -> dict[str, Any] | None:
222
+ sid = self.sandbox_id(gw_id)
223
+ try:
224
+ r = await self._bridge.get(f"/v1/sandboxes/{sid}")
225
+ except httpx.HTTPError as exc:
226
+ raise ReconcileError(f"{self.name}: {type(exc).__name__}: {exc}") from exc
227
+ if r.status_code == 404:
228
+ return None
229
+ if r.status_code != 200:
230
+ raise ReconcileError(f"{self.name}: bridge {r.status_code}")
231
+ return self._as_dict(r.json())
232
+
233
+ async def list_provider(self, tags: dict[str, str]) -> list[dict[str, Any]]:
234
+ try:
235
+ r = await self._bridge.get(
236
+ "/v1/sandboxes", params={f"label.{k}": v for k, v in tags.items()}
237
+ )
238
+ r.raise_for_status()
239
+ except httpx.HTTPError as exc:
240
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
241
+ return [self._as_dict(i) for i in r.json().get("items", [])]