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
|
@@ -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
|