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,536 @@
|
|
|
1
|
+
"""Shared logic for hosted providers that do not speak the OpenSandbox API themselves.
|
|
2
|
+
|
|
3
|
+
The gateway emulates the OpenSandbox lifecycle on top of the provider SDK and runs
|
|
4
|
+
OpenSandbox's ``execd`` inside every sandbox, so clients get the same execd endpoint
|
|
5
|
+
(``GET /sandboxes/{id}/endpoints/44772`` -> public URL + headers) on every provider.
|
|
6
|
+
|
|
7
|
+
A create here:
|
|
8
|
+
1. resolves the image to something the provider can boot fast (prepared template or
|
|
9
|
+
image layer with execd baked in, see ``prepare``),
|
|
10
|
+
2. creates the provider sandbox tagged with the gateway id,
|
|
11
|
+
3. starts execd with a per-sandbox access token,
|
|
12
|
+
4. waits until execd answers on its public URL.
|
|
13
|
+
Any failure after step 2 deletes the provider sandbox before reporting a clear failure;
|
|
14
|
+
if that delete fails, the sandbox still carries the managed tag and the reconciler
|
|
15
|
+
deletes it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import datetime as dt
|
|
22
|
+
import hashlib
|
|
23
|
+
import hmac
|
|
24
|
+
import logging
|
|
25
|
+
import shlex
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from stackbox import ledger as L
|
|
34
|
+
from stackbox.backends.base import (
|
|
35
|
+
GW_ID_KEY,
|
|
36
|
+
MANAGED_KEY,
|
|
37
|
+
Backend,
|
|
38
|
+
BackendError,
|
|
39
|
+
Created,
|
|
40
|
+
CreateFailure,
|
|
41
|
+
ImageSupport,
|
|
42
|
+
UpstreamResponse,
|
|
43
|
+
error_body,
|
|
44
|
+
)
|
|
45
|
+
from stackbox.config import BackendConfig, GatewaySettings
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from stackbox.ledger import SandboxRecord
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
EXECD_PORT = 44772
|
|
53
|
+
EXECD_PATH = "/opt/opensandbox/execd"
|
|
54
|
+
EXECD_TOKEN_HEADER = "X-EXECD-ACCESS-TOKEN"
|
|
55
|
+
# OpenSandbox's egress sidecar port. The SDK resolves it on every create, but hosted
|
|
56
|
+
# backends run no sidecar, so it gets a placeholder that fails loudly if used.
|
|
57
|
+
EGRESS_PORT = 18080
|
|
58
|
+
DEFAULT_ENTRYPOINT = ["tail", "-f", "/dev/null"]
|
|
59
|
+
_NOOP_ENTRYPOINTS = {"tail -f /dev/null", "sleep infinity", "sleep inf"}
|
|
60
|
+
|
|
61
|
+
# Provider tag/metadata keys. Providers restrict key characters, so the router's
|
|
62
|
+
# namespaced keys are mapped to plain ones.
|
|
63
|
+
PROVIDER_KEYS = {GW_ID_KEY: "stackbox_gw_id", MANAGED_KEY: "stackbox_managed"}
|
|
64
|
+
_FROM_PROVIDER = {v: k for k, v in PROVIDER_KEYS.items()}
|
|
65
|
+
|
|
66
|
+
# A failed preparation is retried automatically only after this long.
|
|
67
|
+
PREPARE_RETRY_S = 600
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_provider_tags(metadata: dict[str, str]) -> dict[str, str]:
|
|
71
|
+
return {PROVIDER_KEYS.get(k, k): v for k, v in metadata.items()}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def from_provider_tags(tags: dict[str, str] | None) -> dict[str, str]:
|
|
75
|
+
return {
|
|
76
|
+
_FROM_PROVIDER[k]: v for k, v in (tags or {}).items() if k in _FROM_PROVIDER
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def image_uri(body: dict[str, Any]) -> str | None:
|
|
81
|
+
img = body.get("image")
|
|
82
|
+
if isinstance(img, dict):
|
|
83
|
+
return img.get("uri")
|
|
84
|
+
return img if isinstance(img, str) else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def iso(ts: float) -> str:
|
|
88
|
+
return (
|
|
89
|
+
dt.datetime.fromtimestamp(ts, dt.UTC)
|
|
90
|
+
.isoformat(timespec="seconds")
|
|
91
|
+
.replace("+00:00", "Z")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_iso(value: str) -> float:
|
|
96
|
+
return dt.datetime.fromisoformat(value).timestamp()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def entrypoint_command(entrypoint: list[str] | None) -> str | None:
|
|
100
|
+
"""Shell command for a client entrypoint, or None when it only keeps the box alive."""
|
|
101
|
+
if not entrypoint or " ".join(entrypoint) in _NOOP_ENTRYPOINTS:
|
|
102
|
+
return None
|
|
103
|
+
return shlex.join(entrypoint)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def execd_command(token: str, graceful_shutdown: str = "50ms") -> list[str]:
|
|
107
|
+
return [
|
|
108
|
+
EXECD_PATH,
|
|
109
|
+
"--port",
|
|
110
|
+
str(EXECD_PORT),
|
|
111
|
+
"--access-token",
|
|
112
|
+
token,
|
|
113
|
+
"--graceful-shutdown-timeout",
|
|
114
|
+
graceful_shutdown,
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Provisioned:
|
|
120
|
+
"""A provider sandbox that exists (and must be deleted if anything later fails)."""
|
|
121
|
+
|
|
122
|
+
upstream_id: str
|
|
123
|
+
host: str # public host for the execd port, no scheme
|
|
124
|
+
headers: dict[str, str] = field(default_factory=dict) # provider auth for that host
|
|
125
|
+
backend_data: dict[str, Any] = field(default_factory=dict)
|
|
126
|
+
handle: Any = None # provider SDK object, for the start step
|
|
127
|
+
scheme: str = "https" # how the gateway reaches the host (clients choose their own)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class PostCreateError(Exception):
|
|
131
|
+
"""The provider sandbox exists, but finishing setup failed."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class HostedBackend(Backend):
|
|
135
|
+
#: what image_support returns for an image that has not been prepared
|
|
136
|
+
unprepared_support: ImageSupport = None
|
|
137
|
+
adopt_ambiguous = False
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
cfg: BackendConfig,
|
|
142
|
+
*,
|
|
143
|
+
settings: GatewaySettings,
|
|
144
|
+
ledger: L.Ledger,
|
|
145
|
+
token_key: str,
|
|
146
|
+
):
|
|
147
|
+
super().__init__(cfg)
|
|
148
|
+
self._settings = settings
|
|
149
|
+
self._ledger = ledger
|
|
150
|
+
self._token_key = token_key.encode()
|
|
151
|
+
self._preparing: dict[str, asyncio.Task[dict[str, Any]]] = {}
|
|
152
|
+
self._http = httpx.AsyncClient(timeout=10)
|
|
153
|
+
self._execd_digest: str | None = None
|
|
154
|
+
|
|
155
|
+
async def aclose(self) -> None:
|
|
156
|
+
for task in self._preparing.values():
|
|
157
|
+
task.cancel()
|
|
158
|
+
await self._http.aclose()
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------- helpers
|
|
161
|
+
def execd_token(self, gw_id: str) -> str:
|
|
162
|
+
"""Per-sandbox execd token, derived so it survives gateway restarts unstored."""
|
|
163
|
+
return hmac.new(self._token_key, gw_id.encode(), hashlib.sha256).hexdigest()[
|
|
164
|
+
:40
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
def execd_digest(self) -> str:
|
|
168
|
+
if self._execd_digest is None:
|
|
169
|
+
h = hashlib.sha256()
|
|
170
|
+
with open(self.execd_file, "rb") as f:
|
|
171
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
172
|
+
h.update(chunk)
|
|
173
|
+
self._execd_digest = h.hexdigest()
|
|
174
|
+
return self._execd_digest
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def execd_file(self) -> Path:
|
|
178
|
+
"""Configured execd_path, else the cached download (fetched on first use)."""
|
|
179
|
+
from stackbox import execd
|
|
180
|
+
|
|
181
|
+
return execd.ensure(self.cfg.execd_path)
|
|
182
|
+
|
|
183
|
+
def requested_resources(
|
|
184
|
+
self, cpu: float | None, memory_gib: float | None
|
|
185
|
+
) -> tuple[float, float]:
|
|
186
|
+
return (
|
|
187
|
+
cpu if cpu is not None else self.cfg.default_cpu,
|
|
188
|
+
memory_gib if memory_gib is not None else self.cfg.default_memory_gib,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def clamp_timeout(self, requested: Any) -> int:
|
|
192
|
+
try:
|
|
193
|
+
seconds = int(requested) if requested is not None else 600
|
|
194
|
+
except (TypeError, ValueError):
|
|
195
|
+
seconds = 600
|
|
196
|
+
return max(60, min(seconds, self.cfg.max_timeout_s))
|
|
197
|
+
|
|
198
|
+
# -------------------------------------------------------- image support
|
|
199
|
+
def image_record(self, image: str) -> L.ImageRecord | None:
|
|
200
|
+
return self._ledger.get_image(self.name, image)
|
|
201
|
+
|
|
202
|
+
def fits(
|
|
203
|
+
self, rec: L.ImageRecord, cpu: float | None, memory_gib: float | None
|
|
204
|
+
) -> bool:
|
|
205
|
+
"""A prepared image that fixes resources (an E2B template) serves only requests
|
|
206
|
+
within them. Images without fixed resources fit any request."""
|
|
207
|
+
d = rec.detail or {}
|
|
208
|
+
if "cpu" not in d:
|
|
209
|
+
return True
|
|
210
|
+
return (cpu or 0) <= d["cpu"] + 1e-9 and (memory_gib or 0) <= d.get(
|
|
211
|
+
"memory_gib", 0
|
|
212
|
+
) + 1e-9
|
|
213
|
+
|
|
214
|
+
def image_support(
|
|
215
|
+
self, image: str | None, cpu: float | None, memory_gib: float | None
|
|
216
|
+
) -> ImageSupport:
|
|
217
|
+
if not image:
|
|
218
|
+
return None
|
|
219
|
+
rec = self.image_record(image)
|
|
220
|
+
if rec is not None and rec.status == L.IMAGE_READY:
|
|
221
|
+
return "ready" if self.fits(rec, cpu, memory_gib) else None
|
|
222
|
+
if self.cfg.auto_prepare:
|
|
223
|
+
self.schedule_prepare(image)
|
|
224
|
+
return self.unprepared_support
|
|
225
|
+
|
|
226
|
+
def schedule_prepare(self, image: str) -> None:
|
|
227
|
+
if image in self._preparing:
|
|
228
|
+
return
|
|
229
|
+
rec = self.image_record(image)
|
|
230
|
+
if (
|
|
231
|
+
rec is not None
|
|
232
|
+
and rec.status == L.IMAGE_FAILED
|
|
233
|
+
and time.time() - rec.updated_at < PREPARE_RETRY_S
|
|
234
|
+
):
|
|
235
|
+
return
|
|
236
|
+
try:
|
|
237
|
+
asyncio.get_running_loop()
|
|
238
|
+
except RuntimeError:
|
|
239
|
+
return
|
|
240
|
+
task = asyncio.create_task(self._prepare_logged(image))
|
|
241
|
+
self._preparing[image] = task
|
|
242
|
+
task.add_done_callback(lambda _t: self._preparing.pop(image, None))
|
|
243
|
+
|
|
244
|
+
async def _prepare_logged(self, image: str) -> dict[str, Any]:
|
|
245
|
+
try:
|
|
246
|
+
return await self.prepare(image)
|
|
247
|
+
except Exception:
|
|
248
|
+
logger.exception("background prepare of %s on %s failed", image, self.name)
|
|
249
|
+
return {}
|
|
250
|
+
|
|
251
|
+
async def prepare(self, image: str) -> dict[str, Any]:
|
|
252
|
+
t0 = time.monotonic()
|
|
253
|
+
self._ledger.set_image(self.name, image, L.IMAGE_BUILDING)
|
|
254
|
+
logger.info("preparing %s on %s", image, self.name)
|
|
255
|
+
try:
|
|
256
|
+
ref, detail = await self.build_image(image)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
self._ledger.set_image(
|
|
259
|
+
self.name,
|
|
260
|
+
image,
|
|
261
|
+
L.IMAGE_FAILED,
|
|
262
|
+
error=f"{type(exc).__name__}: {exc}"[:500],
|
|
263
|
+
)
|
|
264
|
+
raise
|
|
265
|
+
detail = {**detail, "prepare_s": round(time.monotonic() - t0, 1)}
|
|
266
|
+
self._ledger.set_image(self.name, image, L.IMAGE_READY, ref=ref, detail=detail)
|
|
267
|
+
logger.info("prepared %s on %s as %s (%s)", image, self.name, ref, detail)
|
|
268
|
+
return {
|
|
269
|
+
"backend": self.name,
|
|
270
|
+
"image": image,
|
|
271
|
+
"status": L.IMAGE_READY,
|
|
272
|
+
"ref": ref,
|
|
273
|
+
**detail,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------ provider hooks
|
|
277
|
+
async def build_image(self, image: str) -> tuple[str, dict[str, Any]]:
|
|
278
|
+
raise NotImplementedError
|
|
279
|
+
|
|
280
|
+
async def provision(
|
|
281
|
+
self,
|
|
282
|
+
*,
|
|
283
|
+
gw_id: str,
|
|
284
|
+
image: str,
|
|
285
|
+
image_rec: L.ImageRecord | None,
|
|
286
|
+
cpu: float,
|
|
287
|
+
memory_gib: float,
|
|
288
|
+
timeout_s: int,
|
|
289
|
+
env: dict[str, str],
|
|
290
|
+
entrypoint: list[str] | None,
|
|
291
|
+
token: str,
|
|
292
|
+
) -> Provisioned:
|
|
293
|
+
"""Create the provider sandbox. Raises CreateFailure if it may not exist."""
|
|
294
|
+
raise NotImplementedError
|
|
295
|
+
|
|
296
|
+
async def start(
|
|
297
|
+
self,
|
|
298
|
+
prov: Provisioned,
|
|
299
|
+
*,
|
|
300
|
+
token: str,
|
|
301
|
+
env: dict[str, str],
|
|
302
|
+
entrypoint: list[str] | None,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Start execd (and the client entrypoint) if provisioning did not already."""
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
async def provider_state(self, upstream_id: str) -> tuple[str, float | None] | None:
|
|
308
|
+
"""(OpenSandbox state, expires_at) or None if the sandbox is gone. Raises BackendError."""
|
|
309
|
+
raise NotImplementedError
|
|
310
|
+
|
|
311
|
+
async def kill(self, upstream_id: str) -> bool:
|
|
312
|
+
"""Delete; False if it did not exist."""
|
|
313
|
+
raise NotImplementedError
|
|
314
|
+
|
|
315
|
+
def endpoint_for(
|
|
316
|
+
self, rec: SandboxRecord, port: int
|
|
317
|
+
) -> tuple[str, dict[str, str]] | None:
|
|
318
|
+
raise NotImplementedError
|
|
319
|
+
|
|
320
|
+
async def extend(self, rec: SandboxRecord, expires_at: float) -> UpstreamResponse:
|
|
321
|
+
return UpstreamResponse(
|
|
322
|
+
409,
|
|
323
|
+
error_body(
|
|
324
|
+
"STACKBOX::RENEW_UNSUPPORTED",
|
|
325
|
+
f"backend {self.name} ({self.kind}) cannot extend a running sandbox",
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def list_provider(self, tags: dict[str, str]) -> list[dict[str, Any]]:
|
|
330
|
+
"""Provider sandboxes with these tags as OpenSandbox-shaped dicts (router metadata
|
|
331
|
+
mapped back). Raises BackendError."""
|
|
332
|
+
raise NotImplementedError
|
|
333
|
+
|
|
334
|
+
# ------------------------------------------------------------ lifecycle
|
|
335
|
+
async def create(self, body: dict[str, Any], *, gw_id: str) -> Created:
|
|
336
|
+
from stackbox.router import parse_cpu, parse_memory_gib
|
|
337
|
+
|
|
338
|
+
image = image_uri(body)
|
|
339
|
+
if not image:
|
|
340
|
+
raise CreateFailure("client", "image.uri is required", 400)
|
|
341
|
+
limits = body.get("resourceLimits") or {}
|
|
342
|
+
req_cpu = parse_cpu(limits.get("cpu"))
|
|
343
|
+
req_mem = parse_memory_gib(limits.get("memory"))
|
|
344
|
+
support = self.image_support(image, req_cpu, req_mem)
|
|
345
|
+
rec = self.image_record(image)
|
|
346
|
+
cpu, memory_gib = self.requested_resources(req_cpu, req_mem)
|
|
347
|
+
if support is None:
|
|
348
|
+
raise CreateFailure(
|
|
349
|
+
"clear", f"image {image} is not prepared on {self.name}"
|
|
350
|
+
)
|
|
351
|
+
timeout_s = self.clamp_timeout(body.get("timeout"))
|
|
352
|
+
env = {str(k): str(v) for k, v in (body.get("env") or {}).items()}
|
|
353
|
+
entrypoint = body.get("entrypoint")
|
|
354
|
+
token = self.execd_token(gw_id)
|
|
355
|
+
t0 = time.time()
|
|
356
|
+
deadline = time.monotonic() + self._settings.create_timeout_s
|
|
357
|
+
|
|
358
|
+
prov = await self.provision(
|
|
359
|
+
gw_id=gw_id,
|
|
360
|
+
image=image,
|
|
361
|
+
image_rec=rec if support == "ready" else None,
|
|
362
|
+
cpu=cpu,
|
|
363
|
+
memory_gib=memory_gib,
|
|
364
|
+
timeout_s=timeout_s,
|
|
365
|
+
env=env,
|
|
366
|
+
entrypoint=entrypoint,
|
|
367
|
+
token=token,
|
|
368
|
+
)
|
|
369
|
+
try:
|
|
370
|
+
await self.start(prov, token=token, env=env, entrypoint=entrypoint)
|
|
371
|
+
headers = {**prov.headers, EXECD_TOKEN_HEADER: token}
|
|
372
|
+
await self.wait_execd(f"{prov.scheme}://{prov.host}", headers, deadline)
|
|
373
|
+
except Exception as exc:
|
|
374
|
+
await self.discard(prov.upstream_id)
|
|
375
|
+
raise CreateFailure(
|
|
376
|
+
"clear",
|
|
377
|
+
f"sandbox started but execd setup failed: {type(exc).__name__}: {exc}",
|
|
378
|
+
) from exc
|
|
379
|
+
|
|
380
|
+
alloc_cpu, alloc_mem = self.allocated_resources(image, cpu, memory_gib)
|
|
381
|
+
expires_at = t0 + timeout_s
|
|
382
|
+
backend_data = {
|
|
383
|
+
**prov.backend_data,
|
|
384
|
+
"host": prov.host,
|
|
385
|
+
"image": image,
|
|
386
|
+
"entrypoint": entrypoint or DEFAULT_ENTRYPOINT,
|
|
387
|
+
"createdAt": iso(t0),
|
|
388
|
+
"expiresAt": iso(expires_at),
|
|
389
|
+
}
|
|
390
|
+
sandbox = self.shape(
|
|
391
|
+
prov.upstream_id, gw_id, "Running", backend_data=backend_data
|
|
392
|
+
)
|
|
393
|
+
return Created(
|
|
394
|
+
sandbox=sandbox,
|
|
395
|
+
cpu=alloc_cpu,
|
|
396
|
+
memory_gib=alloc_mem,
|
|
397
|
+
backend_data=backend_data,
|
|
398
|
+
cold=support == "cold",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
async def wait_execd(
|
|
402
|
+
self, url: str, headers: dict[str, str], deadline: float
|
|
403
|
+
) -> None:
|
|
404
|
+
last: str = "no response"
|
|
405
|
+
while time.monotonic() < deadline:
|
|
406
|
+
try:
|
|
407
|
+
r = await self._http.get(f"{url}/ping", headers=headers)
|
|
408
|
+
if r.status_code == 200:
|
|
409
|
+
return
|
|
410
|
+
last = f"HTTP {r.status_code}"
|
|
411
|
+
except httpx.HTTPError as exc:
|
|
412
|
+
last = type(exc).__name__
|
|
413
|
+
await asyncio.sleep(0.25)
|
|
414
|
+
raise TimeoutError(f"execd at {url} not ready ({last})")
|
|
415
|
+
|
|
416
|
+
async def discard(self, upstream_id: str) -> None:
|
|
417
|
+
try:
|
|
418
|
+
await self.kill(upstream_id)
|
|
419
|
+
except Exception as exc: # noqa: BLE001 - the reconciler deletes it later
|
|
420
|
+
logger.warning(
|
|
421
|
+
"could not delete %s on %s after a failed create: %s",
|
|
422
|
+
upstream_id,
|
|
423
|
+
self.name,
|
|
424
|
+
exc,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def shape(
|
|
428
|
+
self,
|
|
429
|
+
upstream_id: str,
|
|
430
|
+
gw_id: str | None,
|
|
431
|
+
state: str,
|
|
432
|
+
*,
|
|
433
|
+
backend_data: dict[str, Any] | None = None,
|
|
434
|
+
expires_at: float | None = None,
|
|
435
|
+
) -> dict[str, Any]:
|
|
436
|
+
bd = backend_data or {}
|
|
437
|
+
out: dict[str, Any] = {
|
|
438
|
+
"id": upstream_id,
|
|
439
|
+
"status": {"state": state, "lastTransitionAt": bd.get("createdAt")},
|
|
440
|
+
"entrypoint": bd.get("entrypoint") or DEFAULT_ENTRYPOINT,
|
|
441
|
+
"createdAt": bd.get("createdAt") or iso(time.time()),
|
|
442
|
+
"metadata": {
|
|
443
|
+
MANAGED_KEY: self.instance_id,
|
|
444
|
+
**({GW_ID_KEY: gw_id} if gw_id else {}),
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
if bd.get("image"):
|
|
448
|
+
out["image"] = {"uri": bd["image"]}
|
|
449
|
+
exp = iso(expires_at) if expires_at else bd.get("expiresAt")
|
|
450
|
+
if exp:
|
|
451
|
+
out["expiresAt"] = exp
|
|
452
|
+
return out
|
|
453
|
+
|
|
454
|
+
async def get(self, rec: SandboxRecord) -> UpstreamResponse:
|
|
455
|
+
assert rec.upstream_id is not None
|
|
456
|
+
try:
|
|
457
|
+
found = await self.provider_state(rec.upstream_id)
|
|
458
|
+
except BackendError as exc:
|
|
459
|
+
return UpstreamResponse(
|
|
460
|
+
502, error_body("STACKBOX::BACKEND_ERROR", str(exc))
|
|
461
|
+
)
|
|
462
|
+
if found is None:
|
|
463
|
+
return UpstreamResponse(
|
|
464
|
+
404, error_body("SANDBOX::NOT_FOUND", "sandbox no longer exists")
|
|
465
|
+
)
|
|
466
|
+
state, expires_at = found
|
|
467
|
+
return UpstreamResponse(
|
|
468
|
+
200,
|
|
469
|
+
self.shape(
|
|
470
|
+
rec.upstream_id,
|
|
471
|
+
rec.gw_id,
|
|
472
|
+
state,
|
|
473
|
+
backend_data=rec.backend_data,
|
|
474
|
+
expires_at=expires_at,
|
|
475
|
+
),
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
async def delete(self, upstream_id: str) -> UpstreamResponse:
|
|
479
|
+
try:
|
|
480
|
+
existed = await self.kill(upstream_id)
|
|
481
|
+
except BackendError as exc:
|
|
482
|
+
return UpstreamResponse(
|
|
483
|
+
502, error_body("STACKBOX::BACKEND_ERROR", str(exc))
|
|
484
|
+
)
|
|
485
|
+
return UpstreamResponse(204 if existed else 404, None)
|
|
486
|
+
|
|
487
|
+
async def subresource(
|
|
488
|
+
self,
|
|
489
|
+
rec: SandboxRecord,
|
|
490
|
+
method: str,
|
|
491
|
+
rest: str,
|
|
492
|
+
*,
|
|
493
|
+
params: Any = None,
|
|
494
|
+
json: Any = None,
|
|
495
|
+
) -> UpstreamResponse:
|
|
496
|
+
parts = [p for p in rest.split("/") if p]
|
|
497
|
+
if method == "GET" and len(parts) == 2 and parts[0] == "endpoints":
|
|
498
|
+
try:
|
|
499
|
+
port = int(parts[1])
|
|
500
|
+
except ValueError:
|
|
501
|
+
return UpstreamResponse(400, error_body("STACKBOX::BAD_PORT", parts[1]))
|
|
502
|
+
found = self.endpoint_for(rec, port)
|
|
503
|
+
if found is None and port == EGRESS_PORT:
|
|
504
|
+
found = (f"no-egress-sidecar-on-{self.kind}.invalid", {})
|
|
505
|
+
if found is None:
|
|
506
|
+
return UpstreamResponse(
|
|
507
|
+
404,
|
|
508
|
+
error_body(
|
|
509
|
+
"STACKBOX::PORT_NOT_EXPOSED",
|
|
510
|
+
f"port {port} is not exposed on backend {self.name}",
|
|
511
|
+
),
|
|
512
|
+
)
|
|
513
|
+
host, headers = found
|
|
514
|
+
if port == EXECD_PORT:
|
|
515
|
+
headers = {**headers, EXECD_TOKEN_HEADER: self.execd_token(rec.gw_id)}
|
|
516
|
+
return UpstreamResponse(200, {"endpoint": host, "headers": headers})
|
|
517
|
+
if method == "POST" and parts == ["renew-expiration"]:
|
|
518
|
+
try:
|
|
519
|
+
expires_at = parse_iso(str((json or {})["expiresAt"]))
|
|
520
|
+
except (KeyError, ValueError) as exc:
|
|
521
|
+
return UpstreamResponse(
|
|
522
|
+
400, error_body("STACKBOX::BAD_REQUEST", f"expiresAt: {exc}")
|
|
523
|
+
)
|
|
524
|
+
return await self.extend(rec, expires_at)
|
|
525
|
+
return UpstreamResponse(
|
|
526
|
+
501,
|
|
527
|
+
error_body(
|
|
528
|
+
"STACKBOX::UNSUPPORTED",
|
|
529
|
+
f"{method} /sandboxes/{{id}}/{rest} is not supported on {self.kind} backends",
|
|
530
|
+
),
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
async def list_sandboxes(
|
|
534
|
+
self, metadata: dict[str, str] | None = None
|
|
535
|
+
) -> list[dict[str, Any]]:
|
|
536
|
+
return await self.list_provider(to_provider_tags(metadata or {}))
|