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,190 @@
1
+ """Modal backend: gVisor sandboxes from any registry image.
2
+
3
+ Modal can boot an unprepared image (it builds the image layer with execd at create
4
+ time, which is slow the first time), so unprepared images are served "cold".
5
+ Preparing builds that layer ahead of time; creates then reference it by id.
6
+
7
+ Requests are in vCPU (as in OpenSandbox/Kubernetes); Modal's ``cpu`` is physical
8
+ cores, so the adapter passes vCPU / 2. Configure ``cost_per_vcpu_hour`` as half of
9
+ Modal's per-core price.
10
+
11
+ Sandboxes are named ``stackbox-<gw_id>`` inside the configured Modal app, so a create the
12
+ gateway lost track of can be found by name or tag. Modal fixes a sandbox's lifetime
13
+ at create; renewing beyond it is refused.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import shlex
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from stackbox import ledger as L
23
+ from stackbox.backends.base import BackendError, CreateFailure, UpstreamResponse
24
+ from stackbox.backends.hosted import (
25
+ EXECD_PATH,
26
+ EXECD_PORT,
27
+ HostedBackend,
28
+ Provisioned,
29
+ entrypoint_command,
30
+ execd_command,
31
+ from_provider_tags,
32
+ parse_iso,
33
+ )
34
+
35
+ if TYPE_CHECKING:
36
+ from stackbox.ledger import SandboxRecord
37
+
38
+
39
+ class ModalBackend(HostedBackend):
40
+ kind = "modal"
41
+ unprepared_support = "cold"
42
+
43
+ def __init__(self, *args: Any, **kwargs: Any):
44
+ super().__init__(*args, **kwargs)
45
+ self._app: Any = None
46
+ self._app_lock = asyncio.Lock()
47
+
48
+ async def app(self) -> Any:
49
+ import modal
50
+
51
+ async with self._app_lock:
52
+ if self._app is None:
53
+ self._app = await modal.App.lookup.aio(
54
+ self.cfg.modal_app, create_if_missing=True
55
+ )
56
+ return self._app
57
+
58
+ def fresh_image(self, image: str) -> Any:
59
+ import modal
60
+
61
+ return modal.Image.from_registry(image).add_local_file(
62
+ str(self.execd_file), EXECD_PATH, copy=True
63
+ )
64
+
65
+ async def build_image(self, image: str) -> tuple[str, dict[str, Any]]:
66
+ img = self.fresh_image(image)
67
+ await img.build.aio(await self.app())
68
+ return img.object_id, {}
69
+
70
+ async def provision(
71
+ self,
72
+ *,
73
+ gw_id: str,
74
+ image: str,
75
+ image_rec: L.ImageRecord | None,
76
+ cpu: float,
77
+ memory_gib: float,
78
+ timeout_s: int,
79
+ env: dict[str, str],
80
+ entrypoint: list[str] | None,
81
+ token: str,
82
+ ) -> Provisioned:
83
+ import modal
84
+ from modal import exception as mex
85
+
86
+ img = (
87
+ modal.Image.from_id(image_rec.ref)
88
+ if image_rec is not None and image_rec.ref
89
+ else self.fresh_image(image)
90
+ )
91
+ execd_args = execd_command(token, self.cfg.execd_graceful_shutdown)
92
+ execd = shlex.join(execd_args)
93
+ user_cmd = entrypoint_command(entrypoint)
94
+ args = ["sh", "-c", f"{execd} & exec {user_cmd}"] if user_cmd else execd_args
95
+ try:
96
+ sb = await modal.Sandbox.create.aio(
97
+ *args,
98
+ app=await self.app(),
99
+ image=img,
100
+ name=f"stackbox-{gw_id}",
101
+ tags={"stackbox_gw_id": gw_id, "stackbox_managed": self.instance_id},
102
+ env=env or None,
103
+ timeout=timeout_s,
104
+ cpu=max(0.125, cpu / 2), # Modal counts physical cores (2 vCPU)
105
+ memory=max(128, int(memory_gib * 1024)),
106
+ encrypted_ports=[EXECD_PORT],
107
+ region=self.cfg.modal_region,
108
+ )
109
+ except mex.ResourceExhaustedError as exc:
110
+ raise CreateFailure("clear", f"Modal at capacity: {exc}", 429) from exc
111
+ except (
112
+ mex.AuthError,
113
+ mex.NotFoundError,
114
+ mex.ImageBuildError,
115
+ mex.InvalidError,
116
+ ) as exc:
117
+ raise CreateFailure("clear", f"{type(exc).__name__}: {exc}") from exc
118
+ except Exception as exc: # connection drops, timeouts, AlreadyExists: may exist
119
+ raise CreateFailure("ambiguous", f"{type(exc).__name__}: {exc}") from exc
120
+ try:
121
+ tunnels = await sb.tunnels.aio(timeout=60)
122
+ host = tunnels[EXECD_PORT].url.split("://", 1)[-1].rstrip("/")
123
+ except Exception as exc:
124
+ await self.discard(sb.object_id)
125
+ raise CreateFailure(
126
+ "clear", f"sandbox created but no tunnel: {type(exc).__name__}: {exc}"
127
+ ) from exc
128
+ return Provisioned(upstream_id=sb.object_id, host=host, handle=sb)
129
+
130
+ async def provider_state(self, upstream_id: str) -> tuple[str, float | None] | None:
131
+ import modal
132
+ from modal import exception as mex
133
+
134
+ try:
135
+ sb = await modal.Sandbox.from_id.aio(upstream_id)
136
+ rc = await sb.poll.aio()
137
+ except mex.NotFoundError:
138
+ return None
139
+ except Exception as exc:
140
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
141
+ return ("Running", None) if rc is None else None
142
+
143
+ async def kill(self, upstream_id: str) -> bool:
144
+ import modal
145
+ from modal import exception as mex
146
+
147
+ try:
148
+ sb = await modal.Sandbox.from_id.aio(upstream_id)
149
+ if await sb.poll.aio() is not None:
150
+ return False
151
+ await sb.terminate.aio()
152
+ return True
153
+ except mex.NotFoundError:
154
+ return False
155
+ except Exception as exc:
156
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
157
+
158
+ def endpoint_for(
159
+ self, rec: SandboxRecord, port: int
160
+ ) -> tuple[str, dict[str, str]] | None:
161
+ host = (rec.backend_data or {}).get("host")
162
+ return (host, {}) if port == EXECD_PORT and host else None
163
+
164
+ async def extend(self, rec: SandboxRecord, expires_at: float) -> UpstreamResponse:
165
+ current = (rec.backend_data or {}).get("expiresAt")
166
+ if current and expires_at <= parse_iso(current) + 1:
167
+ return UpstreamResponse(200, {"expiresAt": current})
168
+ return await super().extend(rec, expires_at)
169
+
170
+ async def list_provider(self, tags: dict[str, str]) -> list[dict[str, Any]]:
171
+ import modal
172
+
173
+ out: list[dict[str, Any]] = []
174
+ try:
175
+ app = await self.app()
176
+ async for sb in modal.Sandbox.list.aio(
177
+ app_id=app.app_id, tags=tags or None
178
+ ):
179
+ meta = from_provider_tags(await sb.get_tags.aio())
180
+ out.append(
181
+ {
182
+ "id": sb.object_id,
183
+ "status": {"state": "Running"},
184
+ "createdAt": None,
185
+ "metadata": meta,
186
+ }
187
+ )
188
+ except Exception as exc:
189
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
190
+ return out
@@ -0,0 +1,148 @@
1
+ """Backend that is itself an OpenSandbox-compatible lifecycle API (a server we proxy to)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import httpx
8
+
9
+ from stackbox.backends.base import (
10
+ Backend,
11
+ BackendError,
12
+ Created,
13
+ CreateFailure,
14
+ UpstreamResponse,
15
+ )
16
+ from stackbox.config import BackendConfig
17
+
18
+ if TYPE_CHECKING:
19
+ from stackbox.ledger import SandboxRecord
20
+
21
+ # Requests that never reached the server: nothing can have been created.
22
+ _NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
23
+ _CLIENT_STATUSES = {400, 401, 403, 404, 405, 409, 413, 415, 422}
24
+
25
+
26
+ def _json(resp: httpx.Response) -> Any:
27
+ try:
28
+ return resp.json()
29
+ except ValueError:
30
+ return resp.text or None
31
+
32
+
33
+ class OpenSandboxBackend(Backend):
34
+ kind = "opensandbox"
35
+
36
+ def __init__(
37
+ self,
38
+ cfg: BackendConfig,
39
+ *,
40
+ create_timeout_s: float,
41
+ request_timeout_s: float,
42
+ transport: httpx.AsyncBaseTransport | None = None,
43
+ ):
44
+ super().__init__(cfg)
45
+ assert cfg.url is not None
46
+ headers = {"OPEN-SANDBOX-API-KEY": cfg.api_key} if cfg.api_key else {}
47
+ self._create_timeout = create_timeout_s
48
+ self._client = httpx.AsyncClient(
49
+ base_url=cfg.url.rstrip("/").removesuffix("/v1") + "/v1",
50
+ headers=headers,
51
+ timeout=request_timeout_s,
52
+ transport=transport,
53
+ )
54
+
55
+ async def aclose(self) -> None:
56
+ await self._client.aclose()
57
+
58
+ async def create(self, body: dict[str, Any], *, gw_id: str) -> Created:
59
+ try:
60
+ resp = await self._client.post(
61
+ "/sandboxes", json=body, timeout=self._create_timeout
62
+ )
63
+ except _NOT_SENT as exc:
64
+ raise CreateFailure("clear", f"{type(exc).__name__}: {exc}") from exc
65
+ except httpx.HTTPError as exc:
66
+ # Sent (at least partly) but no usable response: it may exist upstream.
67
+ raise CreateFailure("ambiguous", f"{type(exc).__name__}: {exc}") from exc
68
+ if 200 <= resp.status_code < 300:
69
+ data = _json(resp)
70
+ if not isinstance(data, dict) or "id" not in data:
71
+ raise CreateFailure(
72
+ "ambiguous",
73
+ "create returned 2xx without a sandbox id",
74
+ resp.status_code,
75
+ data,
76
+ )
77
+ return Created(sandbox=data)
78
+ if resp.status_code == 429:
79
+ raise CreateFailure(
80
+ "clear", "backend rate limited or at capacity", 429, _json(resp)
81
+ )
82
+ if resp.status_code in _CLIENT_STATUSES:
83
+ raise CreateFailure(
84
+ "client",
85
+ f"rejected with {resp.status_code}",
86
+ resp.status_code,
87
+ _json(resp),
88
+ )
89
+ # 5xx and anything unexpected: the server may have created a container before failing.
90
+ raise CreateFailure(
91
+ "ambiguous",
92
+ f"upstream error {resp.status_code}",
93
+ resp.status_code,
94
+ _json(resp),
95
+ )
96
+
97
+ async def list_sandboxes(
98
+ self, metadata: dict[str, str] | None = None
99
+ ) -> list[dict[str, Any]]:
100
+ """All sandboxes matching the metadata filter (follows pagination)."""
101
+ items: list[dict[str, Any]] = []
102
+ page = 1
103
+ try:
104
+ while True:
105
+ params: dict[str, Any] = {"page": page, "pageSize": 100}
106
+ if metadata:
107
+ params["metadata"] = "&".join(
108
+ f"{k}={v}" for k, v in metadata.items()
109
+ )
110
+ resp = await self._client.get("/sandboxes", params=params)
111
+ resp.raise_for_status()
112
+ data = resp.json()
113
+ items.extend(data.get("items") or [])
114
+ if not (data.get("pagination") or {}).get("hasNextPage"):
115
+ return items
116
+ page += 1
117
+ except httpx.HTTPError as exc:
118
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
119
+
120
+ async def request(
121
+ self,
122
+ method: str,
123
+ path: str,
124
+ *,
125
+ params: Any = None,
126
+ json: Any = None,
127
+ ) -> UpstreamResponse:
128
+ resp = await self._client.request(method, path, params=params, json=json)
129
+ return UpstreamResponse(resp.status_code, _json(resp), dict(resp.headers))
130
+
131
+ async def get(self, rec: SandboxRecord) -> UpstreamResponse:
132
+ return await self.request("GET", f"/sandboxes/{rec.upstream_id}")
133
+
134
+ async def delete(self, upstream_id: str) -> UpstreamResponse:
135
+ return await self.request("DELETE", f"/sandboxes/{upstream_id}")
136
+
137
+ async def subresource(
138
+ self,
139
+ rec: SandboxRecord,
140
+ method: str,
141
+ rest: str,
142
+ *,
143
+ params: Any = None,
144
+ json: Any = None,
145
+ ) -> UpstreamResponse:
146
+ return await self.request(
147
+ method, f"/sandboxes/{rec.upstream_id}/{rest}", params=params, json=json
148
+ )
@@ -0,0 +1,309 @@
1
+ """Vercel Sandbox backend: Firecracker microVMs on Vercel.
2
+
3
+ Vercel boots images from Vercel Container Registry (VCR) only: ``ubuntu:24.04`` by
4
+ name returns "Image not found". Preparing an image builds it plus execd and pushes it
5
+ to VCR (needs a local Docker daemon), unless ``vercel_images`` already maps it to a VCR
6
+ reference; then it checks the image boots with execd in it. Creates boot it directly (measured 0.4-1.2 s, about the
7
+ same as booting a snapshot, so no snapshot step).
8
+
9
+ Sandboxes are named ``stackbox-<gw_id>``; that name is the upstream id, so a create the
10
+ gateway lost track of is found by name. Port 44772 is exposed as a public route
11
+ (execd's token protects it). Credentials come from the Vercel SDK's usual sources:
12
+ ``VERCEL_TOKEN`` + ``VERCEL_TEAM_ID`` + ``VERCEL_PROJECT_ID``, or an OIDC token.
13
+
14
+ Vercel bills CPU only while it is active; set ``cpu_billed_fraction`` to model that.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import math
21
+ import shlex
22
+ import time
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ import httpx
26
+
27
+ from stackbox import ledger as L
28
+ from stackbox.backends.base import (
29
+ BackendError,
30
+ CreateFailure,
31
+ ReconcileError,
32
+ UpstreamResponse,
33
+ )
34
+ from stackbox.backends.hosted import (
35
+ EXECD_PATH,
36
+ EXECD_PORT,
37
+ HostedBackend,
38
+ Provisioned,
39
+ entrypoint_command,
40
+ execd_command,
41
+ from_provider_tags,
42
+ iso,
43
+ )
44
+
45
+ if TYPE_CHECKING:
46
+ from stackbox.ledger import SandboxRecord
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ _LIVE = {"pending", "running", "snapshotting"}
51
+
52
+
53
+ def _status(sb: Any) -> str:
54
+ return str(getattr(sb.status, "value", sb.status)).lower()
55
+
56
+
57
+ def _route_host(sb: Any, port: int) -> str | None:
58
+ for r in sb.routes or ():
59
+ if r.port == port:
60
+ return r.url.split("://", 1)[-1].rstrip("/")
61
+ return None
62
+
63
+
64
+ class VercelBackend(HostedBackend):
65
+ kind = "vercel"
66
+ unprepared_support = (
67
+ None # Vercel boots VCR images only; prepare maps and checks one
68
+ )
69
+
70
+ def vcpus(self, cpu: float) -> int:
71
+ """Vercel allows 1 or an even number of vCPUs."""
72
+ v = max(1, math.ceil(cpu))
73
+ return v if v == 1 else v + (v % 2)
74
+
75
+ def allocated_resources(
76
+ self, image: str | None, cpu: float | None, memory_gib: float | None
77
+ ) -> tuple[float, float]:
78
+ req_cpu, _ = self.requested_resources(cpu, memory_gib)
79
+ v = self.vcpus(req_cpu)
80
+ return float(v), 2.0 * v # 2 GB of memory per vCPU
81
+
82
+ async def build_image(self, image: str) -> tuple[str, dict[str, Any]]:
83
+ """Check that Vercel boots the image (via its VCR reference) with execd in it."""
84
+ import vercel.sandbox as vs
85
+
86
+ ref = self.cfg.vercel_images.get(image)
87
+ pushed = False
88
+ if ref is None:
89
+ ref, pushed = await self._push_to_vcr(image), True
90
+ try:
91
+ sb = await vs.create_sandbox(
92
+ image=ref,
93
+ execution_time_limit=300,
94
+ resources=vs.SandboxResources(vcpus=1),
95
+ tags={"stackbox_prepare": "1"},
96
+ )
97
+ except vs.SandboxApiError as exc:
98
+ if "not found" in str(exc).lower():
99
+ raise RuntimeError(
100
+ f"Vercel cannot boot {ref}: it boots images from Vercel Container "
101
+ "Registry only. Run `stackbox prepare {image}` on a machine with "
102
+ "Docker, or map a VCR reference in vercel_images."
103
+ ) from exc
104
+ raise
105
+ try:
106
+ result = await sb.run_command(
107
+ "sh", ["-c", f"test -x {EXECD_PATH} && id -u"], capture_output=True
108
+ )
109
+ code = getattr(result, "returncode", getattr(result, "exit_code", 0))
110
+ if code not in (0, None):
111
+ raise RuntimeError(
112
+ f"{ref} has no {EXECD_PATH}; build it with scripts/vercel_push_image.py"
113
+ )
114
+ finally:
115
+ try:
116
+ await sb.stop()
117
+ await sb.destroy()
118
+ except Exception as exc: # noqa: BLE001 - only a probe
119
+ logger.warning("could not destroy Vercel prepare sandbox: %s", exc)
120
+ return ref, {"vcr_ref": ref, "pushed": pushed}
121
+
122
+ async def _push_to_vcr(self, image: str) -> str:
123
+ """Vercel boots VCR images only: build image + execd and push it (needs Docker)."""
124
+ import asyncio
125
+
126
+ from stackbox import vcr
127
+
128
+ found = await asyncio.to_thread(vcr.existing, image, self.execd_file)
129
+ if found:
130
+ return found # pushed before (same image + execd): no Docker needed
131
+ if not await asyncio.to_thread(vcr.docker_available):
132
+ raise RuntimeError(
133
+ f"Vercel boots images from Vercel Container Registry only, and {image} is "
134
+ "not there yet. Pushing it needs a running Docker daemon: start Docker and "
135
+ "rerun `stackbox prepare`, or push from another machine with "
136
+ "scripts/vercel_push_image.py and set vercel_images in the config."
137
+ )
138
+ logger.info("pushing %s + execd to Vercel Container Registry", image)
139
+ return await asyncio.to_thread(vcr.push, image, self.execd_file)
140
+
141
+ async def provision(
142
+ self,
143
+ *,
144
+ gw_id: str,
145
+ image: str,
146
+ image_rec: L.ImageRecord | None,
147
+ cpu: float,
148
+ memory_gib: float,
149
+ timeout_s: int,
150
+ env: dict[str, str],
151
+ entrypoint: list[str] | None,
152
+ token: str,
153
+ ) -> Provisioned:
154
+ import vercel.sandbox as vs
155
+
156
+ if image_rec is None or not image_rec.ref:
157
+ raise CreateFailure("clear", f"no Vercel image prepared for {image}")
158
+ name = f"stackbox-{gw_id}"
159
+ try:
160
+ sb = await vs.create_sandbox(
161
+ name=name,
162
+ image=image_rec.ref,
163
+ ports=[EXECD_PORT],
164
+ execution_time_limit=timeout_s,
165
+ resources=vs.SandboxResources(vcpus=self.vcpus(cpu)),
166
+ env=env or None,
167
+ tags={"stackbox_gw_id": gw_id, "stackbox_managed": self.instance_id},
168
+ )
169
+ except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
170
+ raise CreateFailure("clear", f"{type(exc).__name__}: {exc}") from exc
171
+ except vs.SandboxApiError as exc:
172
+ status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
173
+ if status in (400, 401, 403, 404, 422, 429):
174
+ if status == 404:
175
+ self._ledger.set_image(
176
+ self.name,
177
+ image,
178
+ L.IMAGE_FAILED,
179
+ error=f"image missing: {exc}"[:500],
180
+ )
181
+ raise CreateFailure(
182
+ "clear", f"Vercel rejected create: {exc}", status
183
+ ) from exc
184
+ raise CreateFailure(
185
+ "ambiguous", f"{type(exc).__name__}: {exc}", status
186
+ ) from exc
187
+ except vs.SandboxTerminalStateError as exc:
188
+ # Reached a terminal failure state: it exists but is unusable.
189
+ await self.discard(name)
190
+ raise CreateFailure("clear", f"sandbox failed to start: {exc}") from exc
191
+ except Exception as exc:
192
+ raise CreateFailure("ambiguous", f"{type(exc).__name__}: {exc}") from exc
193
+ host = _route_host(sb, EXECD_PORT)
194
+ if host is None:
195
+ await self.discard(name)
196
+ raise CreateFailure("clear", "sandbox has no route for the execd port")
197
+ return Provisioned(upstream_id=name, host=host, handle=sb)
198
+
199
+ async def start(
200
+ self,
201
+ prov: Provisioned,
202
+ *,
203
+ token: str,
204
+ env: dict[str, str],
205
+ entrypoint: list[str] | None,
206
+ ) -> None:
207
+ sb = prov.handle
208
+ execd = shlex.join(execd_command(token, self.cfg.execd_graceful_shutdown))
209
+ cmd = f"nohup {execd} > /tmp/execd.log 2>&1 &"
210
+ user_cmd = entrypoint_command(entrypoint)
211
+ if user_cmd:
212
+ cmd += f" nohup sh -c {shlex.quote(user_cmd)} > /tmp/entrypoint.log 2>&1 &"
213
+ # VCR images built by scripts/vercel_push_image.py run as root, and a plain base
214
+ # image may have no sudo, so execd starts as the image's user.
215
+ await sb.run_command("sh", ["-c", cmd], env=env or None)
216
+
217
+ async def _get(self, name: str) -> Any | None:
218
+ import vercel.sandbox as vs
219
+
220
+ try:
221
+ return await vs.get_sandbox(name=name)
222
+ except vs.SandboxApiError as exc:
223
+ if (
224
+ getattr(exc, "status_code", None) or getattr(exc, "status", None)
225
+ ) == 404:
226
+ return None
227
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
228
+ except Exception as exc:
229
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
230
+
231
+ async def provider_state(self, upstream_id: str) -> tuple[str, float | None] | None:
232
+ sb = await self._get(upstream_id)
233
+ if sb is None or _status(sb) not in _LIVE:
234
+ return None
235
+ return "Running", None
236
+
237
+ async def kill(self, upstream_id: str) -> bool:
238
+ sb = await self._get(upstream_id)
239
+ if sb is None:
240
+ return False
241
+ live = _status(sb) in _LIVE
242
+ try:
243
+ if live:
244
+ await sb.stop()
245
+ await sb.destroy()
246
+ except Exception as exc:
247
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
248
+ return live
249
+
250
+ def endpoint_for(
251
+ self, rec: SandboxRecord, port: int
252
+ ) -> tuple[str, dict[str, str]] | None:
253
+ host = (rec.backend_data or {}).get("host")
254
+ return (host, {}) if port == EXECD_PORT and host else None
255
+
256
+ async def extend(self, rec: SandboxRecord, expires_at: float) -> UpstreamResponse:
257
+ sb = await self._get(rec.upstream_id or "")
258
+ if sb is None:
259
+ return UpstreamResponse(
260
+ 404, {"code": "SANDBOX::NOT_FOUND", "message": "gone"}
261
+ )
262
+ extra = max(0, int(expires_at - time.time()))
263
+ try:
264
+ await sb.extend_execution_time_limit(extra)
265
+ except Exception as exc: # noqa: BLE001 - reported to the client
266
+ return UpstreamResponse(
267
+ 502, {"code": "STACKBOX::BACKEND_ERROR", "message": str(exc)}
268
+ )
269
+ return UpstreamResponse(200, {"expiresAt": iso(expires_at)})
270
+
271
+ async def find_by_gw_id(self, gw_id: str) -> dict[str, Any] | None:
272
+ try:
273
+ sb = await self._get(f"stackbox-{gw_id}")
274
+ except BackendError as exc:
275
+ raise ReconcileError(f"{self.name}: {exc}") from exc
276
+ if sb is None or _status(sb) not in _LIVE:
277
+ return None
278
+ return self._as_dict(sb)
279
+
280
+ def _as_dict(self, sb: Any) -> dict[str, Any]:
281
+ created = getattr(sb, "created_at", None)
282
+ return {
283
+ "id": sb.name,
284
+ "status": {"state": "Running"},
285
+ "createdAt": iso(created.timestamp())
286
+ if hasattr(created, "timestamp")
287
+ else None,
288
+ "metadata": from_provider_tags(dict(sb.tags or {})),
289
+ }
290
+
291
+ async def list_provider(self, tags: dict[str, str]) -> list[dict[str, Any]]:
292
+ import vercel.sandbox as vs
293
+
294
+ # The API filters on one tag; filter the rest here.
295
+ first = next(iter(tags.items()), None)
296
+ query = vs.SandboxQueryByCreatedAt(
297
+ tag=vs.TagFilter(key=first[0], value=first[1]) if first else None
298
+ )
299
+ out: list[dict[str, Any]] = []
300
+ try:
301
+ async for sb in vs.query_sandboxes(query=query):
302
+ if _status(sb) not in _LIVE:
303
+ continue
304
+ st = dict(sb.tags or {})
305
+ if all(st.get(k) == v for k, v in tags.items()):
306
+ out.append(self._as_dict(sb))
307
+ except Exception as exc:
308
+ raise BackendError(f"{type(exc).__name__}: {exc}") from exc
309
+ return out