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
stackbox/ledger.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Durable record of every sandbox the gateway placed and every provisioning attempt.
|
|
2
|
+
|
|
3
|
+
Intent is written *before* an upstream create is sent, so a crash or an ambiguous
|
|
4
|
+
timeout never leaves a billable sandbox the gateway has no record of.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
# Sandbox states
|
|
18
|
+
PROVISIONING = "provisioning" # create sent (or about to be); outcome not yet known
|
|
19
|
+
UNKNOWN = (
|
|
20
|
+
"unknown" # create outcome ambiguous; must be reconciled before reuse of the slot
|
|
21
|
+
)
|
|
22
|
+
ACTIVE = "active"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
DELETED = "deleted"
|
|
25
|
+
EXPIRED = "expired" # upstream no longer has it (timeout or external deletion)
|
|
26
|
+
|
|
27
|
+
HOLDING_STATES = (PROVISIONING, UNKNOWN, ACTIVE)
|
|
28
|
+
|
|
29
|
+
# Attempt outcomes
|
|
30
|
+
OK = "ok"
|
|
31
|
+
FAILED_CLEAR = "failed" # upstream definitely did not create a sandbox
|
|
32
|
+
AMBIGUOUS = "ambiguous" # may or may not exist upstream
|
|
33
|
+
ADOPTED = "adopted" # ambiguous attempt later found to exist upstream
|
|
34
|
+
ABSENT = "absent" # ambiguous attempt later confirmed absent
|
|
35
|
+
SATURATED = (
|
|
36
|
+
"saturated" # backend refused for quota/capacity; nothing created, not a fault
|
|
37
|
+
)
|
|
38
|
+
DISCARDED = (
|
|
39
|
+
"discarded" # ambiguous attempt found upstream but deleted instead of adopted
|
|
40
|
+
)
|
|
41
|
+
CLIENT_ERROR = "client_error" # request rejected as invalid; not retried elsewhere
|
|
42
|
+
|
|
43
|
+
_SCHEMA = """
|
|
44
|
+
CREATE TABLE IF NOT EXISTS sandboxes (
|
|
45
|
+
gw_id TEXT PRIMARY KEY,
|
|
46
|
+
backend TEXT,
|
|
47
|
+
upstream_id TEXT,
|
|
48
|
+
state TEXT NOT NULL,
|
|
49
|
+
requested_at REAL NOT NULL,
|
|
50
|
+
ready_at REAL,
|
|
51
|
+
ended_at REAL,
|
|
52
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
last_error TEXT,
|
|
54
|
+
cpu REAL,
|
|
55
|
+
memory_gib REAL,
|
|
56
|
+
pinned_backend TEXT,
|
|
57
|
+
client_metadata TEXT,
|
|
58
|
+
image TEXT,
|
|
59
|
+
backend_data TEXT
|
|
60
|
+
);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS sandboxes_backend_state ON sandboxes(backend, state);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS sandboxes_upstream ON sandboxes(backend, upstream_id);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS attempts (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
gw_id TEXT NOT NULL,
|
|
66
|
+
backend TEXT NOT NULL,
|
|
67
|
+
started_at REAL NOT NULL,
|
|
68
|
+
finished_at REAL,
|
|
69
|
+
outcome TEXT,
|
|
70
|
+
http_status INTEGER,
|
|
71
|
+
error TEXT
|
|
72
|
+
);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS attempts_gw ON attempts(gw_id);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS images (
|
|
75
|
+
backend TEXT NOT NULL,
|
|
76
|
+
image TEXT NOT NULL,
|
|
77
|
+
status TEXT NOT NULL,
|
|
78
|
+
ref TEXT,
|
|
79
|
+
error TEXT,
|
|
80
|
+
detail TEXT,
|
|
81
|
+
updated_at REAL NOT NULL,
|
|
82
|
+
PRIMARY KEY (backend, image)
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
85
|
+
key TEXT PRIMARY KEY,
|
|
86
|
+
value TEXT NOT NULL
|
|
87
|
+
);
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# Columns added after the first release; applied to existing databases on open.
|
|
91
|
+
_MIGRATIONS = [
|
|
92
|
+
"ALTER TABLE sandboxes ADD COLUMN image TEXT",
|
|
93
|
+
"ALTER TABLE sandboxes ADD COLUMN backend_data TEXT",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# Image preparation states
|
|
97
|
+
IMAGE_BUILDING = "building"
|
|
98
|
+
IMAGE_READY = "ready"
|
|
99
|
+
IMAGE_FAILED = "failed"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class SandboxRecord:
|
|
104
|
+
gw_id: str
|
|
105
|
+
backend: str | None
|
|
106
|
+
upstream_id: str | None
|
|
107
|
+
state: str
|
|
108
|
+
requested_at: float
|
|
109
|
+
ready_at: float | None
|
|
110
|
+
ended_at: float | None
|
|
111
|
+
attempts: int
|
|
112
|
+
last_error: str | None
|
|
113
|
+
cpu: float | None
|
|
114
|
+
memory_gib: float | None
|
|
115
|
+
pinned_backend: str | None
|
|
116
|
+
client_metadata: dict[str, str]
|
|
117
|
+
image: str | None = None
|
|
118
|
+
backend_data: dict[str, Any] | None = None
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def from_row(cls, row: sqlite3.Row) -> SandboxRecord:
|
|
122
|
+
d = dict(row)
|
|
123
|
+
d["client_metadata"] = json.loads(d["client_metadata"] or "{}")
|
|
124
|
+
d["backend_data"] = (
|
|
125
|
+
json.loads(d["backend_data"]) if d.get("backend_data") else None
|
|
126
|
+
)
|
|
127
|
+
return cls(**d)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ImageRecord:
|
|
132
|
+
backend: str
|
|
133
|
+
image: str
|
|
134
|
+
status: str
|
|
135
|
+
ref: str | None
|
|
136
|
+
error: str | None
|
|
137
|
+
detail: dict[str, Any] | None
|
|
138
|
+
updated_at: float
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Ledger:
|
|
142
|
+
def __init__(self, path: str):
|
|
143
|
+
if path != ":memory:":
|
|
144
|
+
Path(path).expanduser().parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
path = str(Path(path).expanduser())
|
|
146
|
+
self._conn = sqlite3.connect(
|
|
147
|
+
path, check_same_thread=False, isolation_level=None
|
|
148
|
+
)
|
|
149
|
+
self._conn.row_factory = sqlite3.Row
|
|
150
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
151
|
+
self._conn.executescript(_SCHEMA)
|
|
152
|
+
for stmt in _MIGRATIONS:
|
|
153
|
+
try:
|
|
154
|
+
self._conn.execute(stmt)
|
|
155
|
+
except sqlite3.OperationalError:
|
|
156
|
+
pass # column already exists
|
|
157
|
+
self._lock = threading.Lock()
|
|
158
|
+
|
|
159
|
+
def _exec(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Cursor:
|
|
160
|
+
with self._lock:
|
|
161
|
+
return self._conn.execute(sql, params)
|
|
162
|
+
|
|
163
|
+
# --- sandboxes -------------------------------------------------------
|
|
164
|
+
def create_intent(
|
|
165
|
+
self,
|
|
166
|
+
gw_id: str,
|
|
167
|
+
*,
|
|
168
|
+
cpu: float | None,
|
|
169
|
+
memory_gib: float | None,
|
|
170
|
+
pinned_backend: str | None,
|
|
171
|
+
client_metadata: dict[str, str],
|
|
172
|
+
image: str | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
self._exec(
|
|
175
|
+
"INSERT INTO sandboxes (gw_id, state, requested_at, cpu, memory_gib, pinned_backend,"
|
|
176
|
+
" client_metadata, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
177
|
+
(
|
|
178
|
+
gw_id,
|
|
179
|
+
PROVISIONING,
|
|
180
|
+
time.time(),
|
|
181
|
+
cpu,
|
|
182
|
+
memory_gib,
|
|
183
|
+
pinned_backend,
|
|
184
|
+
json.dumps(client_metadata),
|
|
185
|
+
image,
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def claim_backend(self, gw_id: str, backend: str, max_active: int) -> bool:
|
|
190
|
+
"""Atomically assign gw_id to backend if the backend has a free slot."""
|
|
191
|
+
with self._lock:
|
|
192
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
193
|
+
try:
|
|
194
|
+
(held,) = self._conn.execute(
|
|
195
|
+
f"SELECT COUNT(*) FROM sandboxes WHERE backend = ? AND state IN ({','.join('?' * len(HOLDING_STATES))})",
|
|
196
|
+
(backend, *HOLDING_STATES),
|
|
197
|
+
).fetchone()
|
|
198
|
+
if held >= max_active:
|
|
199
|
+
self._conn.execute("ROLLBACK")
|
|
200
|
+
return False
|
|
201
|
+
self._conn.execute(
|
|
202
|
+
"UPDATE sandboxes SET backend = ?, state = ?, attempts = attempts + 1 WHERE gw_id = ?",
|
|
203
|
+
(backend, PROVISIONING, gw_id),
|
|
204
|
+
)
|
|
205
|
+
self._conn.execute("COMMIT")
|
|
206
|
+
return True
|
|
207
|
+
except BaseException:
|
|
208
|
+
self._conn.execute("ROLLBACK")
|
|
209
|
+
raise
|
|
210
|
+
|
|
211
|
+
def release_backend(self, gw_id: str, error: str | None) -> None:
|
|
212
|
+
"""Provisioning on the current backend definitely failed; free the slot for retry elsewhere."""
|
|
213
|
+
self._exec(
|
|
214
|
+
"UPDATE sandboxes SET backend = NULL, state = ?, last_error = ? WHERE gw_id = ?",
|
|
215
|
+
(PROVISIONING, error, gw_id),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def mark_active(
|
|
219
|
+
self,
|
|
220
|
+
gw_id: str,
|
|
221
|
+
upstream_id: str,
|
|
222
|
+
*,
|
|
223
|
+
cpu: float | None = None,
|
|
224
|
+
memory_gib: float | None = None,
|
|
225
|
+
backend_data: dict[str, Any] | None = None,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Record the sandbox as running. cpu/memory, when given, are what the backend
|
|
228
|
+
actually allocated (they replace the requested values for cost accounting)."""
|
|
229
|
+
self._exec(
|
|
230
|
+
"UPDATE sandboxes SET upstream_id = ?, state = ?, ready_at = ?,"
|
|
231
|
+
" cpu = COALESCE(?, cpu), memory_gib = COALESCE(?, memory_gib),"
|
|
232
|
+
" backend_data = COALESCE(?, backend_data) WHERE gw_id = ?",
|
|
233
|
+
(
|
|
234
|
+
upstream_id,
|
|
235
|
+
ACTIVE,
|
|
236
|
+
time.time(),
|
|
237
|
+
cpu,
|
|
238
|
+
memory_gib,
|
|
239
|
+
json.dumps(backend_data) if backend_data is not None else None,
|
|
240
|
+
gw_id,
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def set_state(self, gw_id: str, state: str, error: str | None = None) -> None:
|
|
245
|
+
ended = time.time() if state in (FAILED, DELETED, EXPIRED) else None
|
|
246
|
+
self._exec(
|
|
247
|
+
"UPDATE sandboxes SET state = ?, last_error = COALESCE(?, last_error),"
|
|
248
|
+
" ended_at = COALESCE(?, ended_at) WHERE gw_id = ?",
|
|
249
|
+
(state, error, ended, gw_id),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def get(self, gw_id: str) -> SandboxRecord | None:
|
|
253
|
+
row = self._exec("SELECT * FROM sandboxes WHERE gw_id = ?", (gw_id,)).fetchone()
|
|
254
|
+
return SandboxRecord.from_row(row) if row else None
|
|
255
|
+
|
|
256
|
+
def by_upstream(self, backend: str, upstream_id: str) -> SandboxRecord | None:
|
|
257
|
+
row = self._exec(
|
|
258
|
+
"SELECT * FROM sandboxes WHERE backend = ? AND upstream_id = ?",
|
|
259
|
+
(backend, upstream_id),
|
|
260
|
+
).fetchone()
|
|
261
|
+
return SandboxRecord.from_row(row) if row else None
|
|
262
|
+
|
|
263
|
+
def in_states(self, *states: str) -> list[SandboxRecord]:
|
|
264
|
+
rows = self._exec(
|
|
265
|
+
f"SELECT * FROM sandboxes WHERE state IN ({','.join('?' * len(states))})",
|
|
266
|
+
states,
|
|
267
|
+
).fetchall()
|
|
268
|
+
return [SandboxRecord.from_row(r) for r in rows]
|
|
269
|
+
|
|
270
|
+
def held_count(self, backend: str) -> int:
|
|
271
|
+
(n,) = self._exec(
|
|
272
|
+
f"SELECT COUNT(*) FROM sandboxes WHERE backend = ? AND state IN ({','.join('?' * len(HOLDING_STATES))})",
|
|
273
|
+
(backend, *HOLDING_STATES),
|
|
274
|
+
).fetchone()
|
|
275
|
+
return n
|
|
276
|
+
|
|
277
|
+
def active_count(self, backend: str) -> int:
|
|
278
|
+
(n,) = self._exec(
|
|
279
|
+
"SELECT COUNT(*) FROM sandboxes WHERE backend = ? AND state = ?",
|
|
280
|
+
(backend, ACTIVE),
|
|
281
|
+
).fetchone()
|
|
282
|
+
return n
|
|
283
|
+
|
|
284
|
+
def all(self) -> list[SandboxRecord]:
|
|
285
|
+
return [
|
|
286
|
+
SandboxRecord.from_row(r)
|
|
287
|
+
for r in self._exec("SELECT * FROM sandboxes").fetchall()
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
# --- attempts --------------------------------------------------------
|
|
291
|
+
def start_attempt(self, gw_id: str, backend: str) -> int:
|
|
292
|
+
cur = self._exec(
|
|
293
|
+
"INSERT INTO attempts (gw_id, backend, started_at) VALUES (?, ?, ?)",
|
|
294
|
+
(gw_id, backend, time.time()),
|
|
295
|
+
)
|
|
296
|
+
return int(cur.lastrowid or 0)
|
|
297
|
+
|
|
298
|
+
def finish_attempt(
|
|
299
|
+
self,
|
|
300
|
+
attempt_id: int,
|
|
301
|
+
outcome: str,
|
|
302
|
+
http_status: int | None = None,
|
|
303
|
+
error: str | None = None,
|
|
304
|
+
) -> None:
|
|
305
|
+
self._exec(
|
|
306
|
+
"UPDATE attempts SET finished_at = ?, outcome = ?, http_status = ?, error = ? WHERE id = ?",
|
|
307
|
+
(time.time(), outcome, http_status, error, attempt_id),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def resolve_ambiguous_attempt(self, gw_id: str, backend: str, outcome: str) -> None:
|
|
311
|
+
self._exec(
|
|
312
|
+
"UPDATE attempts SET outcome = ? WHERE gw_id = ? AND backend = ? AND outcome = ?",
|
|
313
|
+
(outcome, gw_id, backend, AMBIGUOUS),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def attempts(self) -> list[dict[str, Any]]:
|
|
317
|
+
return [
|
|
318
|
+
dict(r) for r in self._exec("SELECT * FROM attempts ORDER BY id").fetchall()
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
def startup_samples(self) -> list[tuple[str, str | None, float]]:
|
|
322
|
+
"""(backend, image, seconds) for every successful create, oldest first."""
|
|
323
|
+
rows = self._exec(
|
|
324
|
+
"SELECT a.backend, s.image, a.finished_at - a.started_at FROM attempts a"
|
|
325
|
+
" JOIN sandboxes s ON s.gw_id = a.gw_id"
|
|
326
|
+
" WHERE a.outcome = ? AND a.finished_at IS NOT NULL ORDER BY a.finished_at",
|
|
327
|
+
(OK,),
|
|
328
|
+
).fetchall()
|
|
329
|
+
return [(r[0], r[1], float(r[2])) for r in rows]
|
|
330
|
+
|
|
331
|
+
# --- prepared images ---------------------------------------------------
|
|
332
|
+
def set_image(
|
|
333
|
+
self,
|
|
334
|
+
backend: str,
|
|
335
|
+
image: str,
|
|
336
|
+
status: str,
|
|
337
|
+
*,
|
|
338
|
+
ref: str | None = None,
|
|
339
|
+
error: str | None = None,
|
|
340
|
+
detail: dict[str, Any] | None = None,
|
|
341
|
+
) -> None:
|
|
342
|
+
self._exec(
|
|
343
|
+
"INSERT INTO images (backend, image, status, ref, error, detail, updated_at)"
|
|
344
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (backend, image) DO UPDATE SET"
|
|
345
|
+
" status = excluded.status, ref = COALESCE(excluded.ref, images.ref),"
|
|
346
|
+
" error = excluded.error, detail = COALESCE(excluded.detail, images.detail),"
|
|
347
|
+
" updated_at = excluded.updated_at",
|
|
348
|
+
(
|
|
349
|
+
backend,
|
|
350
|
+
image,
|
|
351
|
+
status,
|
|
352
|
+
ref,
|
|
353
|
+
error,
|
|
354
|
+
json.dumps(detail) if detail is not None else None,
|
|
355
|
+
time.time(),
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def get_image(self, backend: str, image: str) -> ImageRecord | None:
|
|
360
|
+
row = self._exec(
|
|
361
|
+
"SELECT * FROM images WHERE backend = ? AND image = ?", (backend, image)
|
|
362
|
+
).fetchone()
|
|
363
|
+
if row is None:
|
|
364
|
+
return None
|
|
365
|
+
d = dict(row)
|
|
366
|
+
d["detail"] = json.loads(d["detail"]) if d["detail"] else None
|
|
367
|
+
return ImageRecord(**d)
|
|
368
|
+
|
|
369
|
+
def images(self) -> list[dict[str, Any]]:
|
|
370
|
+
return [dict(r) for r in self._exec("SELECT * FROM images").fetchall()]
|
|
371
|
+
|
|
372
|
+
# --- key/value ---------------------------------------------------------
|
|
373
|
+
def get_or_create_secret(self, key: str, factory: Any) -> str:
|
|
374
|
+
with self._lock:
|
|
375
|
+
row = self._conn.execute(
|
|
376
|
+
"SELECT value FROM kv WHERE key = ?", (key,)
|
|
377
|
+
).fetchone()
|
|
378
|
+
if row:
|
|
379
|
+
return str(row[0])
|
|
380
|
+
value = factory()
|
|
381
|
+
self._conn.execute(
|
|
382
|
+
"INSERT OR IGNORE INTO kv (key, value) VALUES (?, ?)", (key, value)
|
|
383
|
+
)
|
|
384
|
+
(value,) = self._conn.execute(
|
|
385
|
+
"SELECT value FROM kv WHERE key = ?", (key,)
|
|
386
|
+
).fetchone()
|
|
387
|
+
return str(value)
|