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/cli.py
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
"""The `stackbox` command line.
|
|
2
|
+
|
|
3
|
+
stackbox providers what is supported, what you have set up, prices
|
|
4
|
+
stackbox init write stackbox.toml for the providers you have
|
|
5
|
+
stackbox doctor check every provider end to end, with fixes
|
|
6
|
+
stackbox prepare IMAGE make IMAGE fast to start everywhere
|
|
7
|
+
stackbox serve run the gateway
|
|
8
|
+
stackbox route IMAGE where a sandbox would go right now, and why
|
|
9
|
+
stackbox images prepared images per provider
|
|
10
|
+
stackbox sandboxes what the running server holds
|
|
11
|
+
stackbox placement ID where one sandbox went, its attempts and cost
|
|
12
|
+
stackbox usage sandbox time and estimated cost per provider
|
|
13
|
+
|
|
14
|
+
Config: -c FILE, else $STACKBOX_CONFIG, else ./stackbox.toml, else providers
|
|
15
|
+
are auto-detected from credentials in the environment.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from stackbox import catalog
|
|
31
|
+
from stackbox.config import (
|
|
32
|
+
CONFIG_FILE,
|
|
33
|
+
ConfigError,
|
|
34
|
+
RouterConfig,
|
|
35
|
+
resolve_config,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
DEFAULT_PORT = 8090
|
|
39
|
+
DEFAULT_IMAGE = "ubuntu:24.04"
|
|
40
|
+
OK, FAIL, WARN = "✓", "✗", "!"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _version() -> str:
|
|
44
|
+
from stackbox import __version__
|
|
45
|
+
|
|
46
|
+
return __version__
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _print_json(data: Any) -> None:
|
|
50
|
+
print(json.dumps(data, indent=2, default=str))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _table(rows: list[list[str]], headers: list[str]) -> str:
|
|
54
|
+
widths = [
|
|
55
|
+
max(len(str(x)) for x in col) for col in zip(headers, *rows, strict=False)
|
|
56
|
+
]
|
|
57
|
+
line = lambda r: " ".join(str(x).ljust(w) for x, w in zip(r, widths, strict=False))
|
|
58
|
+
return "\n".join([line(headers), *(line(r) for r in rows)])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load(args: argparse.Namespace) -> tuple[RouterConfig, str]:
|
|
62
|
+
return resolve_config(getattr(args, "config", None))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _price(p: catalog.Provider) -> str:
|
|
66
|
+
return f"${p.price_1vcpu_2gib():.3f}/h"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------ providers
|
|
70
|
+
def cmd_providers(args: argparse.Namespace) -> int:
|
|
71
|
+
configured: set[str] = set()
|
|
72
|
+
try:
|
|
73
|
+
cfg, _ = _load(args)
|
|
74
|
+
configured = {b.kind for b in cfg.backends}
|
|
75
|
+
except ConfigError:
|
|
76
|
+
pass
|
|
77
|
+
rows, out = [], []
|
|
78
|
+
for p in catalog.PROVIDERS.values():
|
|
79
|
+
status = (
|
|
80
|
+
"in use"
|
|
81
|
+
if p.kind in configured
|
|
82
|
+
else ("credentials found, not in config" if p.detected() else "not set up")
|
|
83
|
+
)
|
|
84
|
+
if not p.installed():
|
|
85
|
+
status += f" (install extra: {p.extra})"
|
|
86
|
+
life = p.defaults.get("max_timeout_s", 0)
|
|
87
|
+
rows.append(
|
|
88
|
+
[
|
|
89
|
+
p.kind,
|
|
90
|
+
p.label,
|
|
91
|
+
status,
|
|
92
|
+
_price(p),
|
|
93
|
+
f"{life // 3600} h" if life >= 3600 else f"{life // 60} min",
|
|
94
|
+
p.missing_credentials(),
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
out.append(
|
|
98
|
+
{
|
|
99
|
+
"kind": p.kind,
|
|
100
|
+
"label": p.label,
|
|
101
|
+
"status": status,
|
|
102
|
+
"usd_per_hour_1vcpu_2gib": round(p.price_1vcpu_2gib(), 4),
|
|
103
|
+
"max_lifetime_s": life,
|
|
104
|
+
"credentials": [list(g) for g in p.credentials],
|
|
105
|
+
"extra": p.extra,
|
|
106
|
+
"signup": p.signup,
|
|
107
|
+
"pricing": p.pricing,
|
|
108
|
+
"notes": p.notes,
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
live = _try_get(args, "/v1/providers")
|
|
112
|
+
if live is not None:
|
|
113
|
+
by_kind = {p["kind"]: p for p in live["items"]}
|
|
114
|
+
for row, item in zip(rows, out, strict=True):
|
|
115
|
+
lp = by_kind.get(item["kind"])
|
|
116
|
+
if lp:
|
|
117
|
+
cap = lp["capacity"]
|
|
118
|
+
item["live"] = lp
|
|
119
|
+
row[2] = (
|
|
120
|
+
f"{lp['status']}, {cap['held']}/{cap['limit']} held, {lp['placements']} placed"
|
|
121
|
+
)
|
|
122
|
+
if args.json:
|
|
123
|
+
_print_json(out)
|
|
124
|
+
return 0
|
|
125
|
+
print(
|
|
126
|
+
_table(
|
|
127
|
+
rows,
|
|
128
|
+
["KIND", "PROVIDER", "STATUS", "LIST PRICE*", "MAX LIFE", "CREDENTIALS"],
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
if live is not None:
|
|
132
|
+
print(f"\nLive status from {_server(args)[0]}.")
|
|
133
|
+
print(
|
|
134
|
+
"\n* 1 vCPU + 2 GiB per hour, list price; `stackbox providers --json` for links."
|
|
135
|
+
)
|
|
136
|
+
if not any(p.detected() for p in catalog.PROVIDERS.values()) and not configured:
|
|
137
|
+
print("\nNo provider is set up yet. For example:")
|
|
138
|
+
print(" export E2B_API_KEY=... # https://e2b.dev/dashboard?tab=keys")
|
|
139
|
+
print(" stackbox doctor")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------- init
|
|
144
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
145
|
+
path = Path(args.output)
|
|
146
|
+
if path.exists() and not args.force:
|
|
147
|
+
print(f"{path} already exists; use --force to overwrite.", file=sys.stderr)
|
|
148
|
+
return 1
|
|
149
|
+
found = catalog.detected()
|
|
150
|
+
lines = [
|
|
151
|
+
"# stackbox config. Every field is optional except kind; unset fields come",
|
|
152
|
+
"# from the built-in provider catalog (list prices, plan limits, sizes).",
|
|
153
|
+
"# Docs: README.md. Check it with: stackbox doctor",
|
|
154
|
+
"",
|
|
155
|
+
"[gateway]",
|
|
156
|
+
"# Clients send this in the OPEN-SANDBOX-API-KEY header. Required unless you only",
|
|
157
|
+
"# listen on 127.0.0.1.",
|
|
158
|
+
'# api_key = "env:STACKBOX_API_KEY"',
|
|
159
|
+
"",
|
|
160
|
+
"[routing]",
|
|
161
|
+
'strategy = "balanced" # priority | cost | latency | balanced',
|
|
162
|
+
"",
|
|
163
|
+
]
|
|
164
|
+
for p in catalog.PROVIDERS.values():
|
|
165
|
+
on = p in found
|
|
166
|
+
prefix = "" if on else "# "
|
|
167
|
+
lines.append(
|
|
168
|
+
f"# {p.label}: {_price(p)} for 1 vCPU + 2 GiB. {p.notes}."
|
|
169
|
+
+ ("" if on else f" Needs {p.missing_credentials()}.")
|
|
170
|
+
)
|
|
171
|
+
lines.append(f"{prefix}[[backends]]")
|
|
172
|
+
lines.append(f'{prefix}kind = "{p.kind}"')
|
|
173
|
+
lines.append("")
|
|
174
|
+
path.write_text("\n".join(lines))
|
|
175
|
+
print(
|
|
176
|
+
f"Wrote {path} with {len(found)} provider(s): {', '.join(p.kind for p in found) or 'none yet'}."
|
|
177
|
+
)
|
|
178
|
+
if not found:
|
|
179
|
+
print(
|
|
180
|
+
"Set a provider's credentials and uncomment its block (see `stackbox providers`)."
|
|
181
|
+
)
|
|
182
|
+
print("\nNext:")
|
|
183
|
+
print(" stackbox doctor # check everything works")
|
|
184
|
+
print(f" stackbox prepare {DEFAULT_IMAGE} # make your image fast to start")
|
|
185
|
+
print(" stackbox serve")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# -------------------------------------------------------------------- doctor
|
|
190
|
+
async def _doctor(cfg: RouterConfig, image: str | None) -> list[dict[str, Any]]:
|
|
191
|
+
from stackbox import execd as execd_mod
|
|
192
|
+
from stackbox import ledger as L
|
|
193
|
+
from stackbox.backends import MANAGED_KEY, make_backend
|
|
194
|
+
from stackbox.backends.hosted import HostedBackend
|
|
195
|
+
|
|
196
|
+
ledger = L.Ledger(cfg.gateway.db_path)
|
|
197
|
+
results: list[dict[str, Any]] = []
|
|
198
|
+
|
|
199
|
+
def check(
|
|
200
|
+
backend: str, name: str, ok: bool | None, detail: str, fix: str = ""
|
|
201
|
+
) -> None:
|
|
202
|
+
results.append(
|
|
203
|
+
{"backend": backend, "check": name, "ok": ok, "detail": detail, "fix": fix}
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
execd_checked: str | None = None
|
|
207
|
+
for bcfg in cfg.backends:
|
|
208
|
+
p = catalog.PROVIDERS.get(bcfg.kind)
|
|
209
|
+
if p is not None and not p.installed():
|
|
210
|
+
check(
|
|
211
|
+
bcfg.name,
|
|
212
|
+
"SDK installed",
|
|
213
|
+
False,
|
|
214
|
+
f"python module {p.module} not found",
|
|
215
|
+
f"uv sync --extra {p.extra} (or: pip install 'stackbox[{p.extra}]')",
|
|
216
|
+
)
|
|
217
|
+
continue
|
|
218
|
+
if (
|
|
219
|
+
p is not None
|
|
220
|
+
and p.credentials
|
|
221
|
+
and not p.detected()
|
|
222
|
+
and bcfg.kind != "cloudflare"
|
|
223
|
+
):
|
|
224
|
+
check(
|
|
225
|
+
bcfg.name,
|
|
226
|
+
"credentials",
|
|
227
|
+
False,
|
|
228
|
+
f"{p.missing_credentials()} not set",
|
|
229
|
+
f"export {p.missing_credentials().split(' + ')[0]}=... ({p.signup})",
|
|
230
|
+
)
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
backend = make_backend(bcfg, cfg.gateway, ledger)
|
|
234
|
+
except Exception as exc: # noqa: BLE001 - reported
|
|
235
|
+
check(bcfg.name, "config", False, str(exc))
|
|
236
|
+
continue
|
|
237
|
+
t0 = time.monotonic()
|
|
238
|
+
try:
|
|
239
|
+
live = await asyncio.wait_for(
|
|
240
|
+
backend.list_sandboxes({MANAGED_KEY: backend.instance_id}), 30
|
|
241
|
+
)
|
|
242
|
+
check(
|
|
243
|
+
bcfg.name,
|
|
244
|
+
"API reachable",
|
|
245
|
+
True,
|
|
246
|
+
f"{len(live)} sandbox(es) of this gateway running; {1000 * (time.monotonic() - t0):.0f} ms",
|
|
247
|
+
)
|
|
248
|
+
except Exception as exc: # noqa: BLE001 - reported
|
|
249
|
+
check(
|
|
250
|
+
bcfg.name,
|
|
251
|
+
"API reachable",
|
|
252
|
+
False,
|
|
253
|
+
f"{type(exc).__name__}: {str(exc)[:200]}",
|
|
254
|
+
"check the credentials and network access to the provider",
|
|
255
|
+
)
|
|
256
|
+
await backend.aclose()
|
|
257
|
+
continue
|
|
258
|
+
if isinstance(backend, HostedBackend) and bcfg.kind != "cloudflare":
|
|
259
|
+
key = bcfg.execd_path or "cached"
|
|
260
|
+
if execd_checked != key:
|
|
261
|
+
try:
|
|
262
|
+
path = execd_mod.ensure(bcfg.execd_path)
|
|
263
|
+
check(
|
|
264
|
+
bcfg.name,
|
|
265
|
+
"execd",
|
|
266
|
+
True,
|
|
267
|
+
f"{path} ({path.stat().st_size / 1e6:.1f} MB)",
|
|
268
|
+
)
|
|
269
|
+
execd_checked = key
|
|
270
|
+
except Exception as exc: # noqa: BLE001 - reported
|
|
271
|
+
check(
|
|
272
|
+
bcfg.name,
|
|
273
|
+
"execd",
|
|
274
|
+
False,
|
|
275
|
+
str(exc)[:200],
|
|
276
|
+
"set execd_path to a local copy of the opensandbox/execd binary",
|
|
277
|
+
)
|
|
278
|
+
if image:
|
|
279
|
+
support = backend.image_support(image, None, None)
|
|
280
|
+
for task in getattr(backend, "_preparing", {}).values():
|
|
281
|
+
task.cancel() # doctor only reports; it does not start builds
|
|
282
|
+
rec = ledger.get_image(bcfg.name, image)
|
|
283
|
+
if support == "ready":
|
|
284
|
+
check(bcfg.name, f"image {image}", True, "prepared")
|
|
285
|
+
elif support == "cold":
|
|
286
|
+
check(
|
|
287
|
+
bcfg.name,
|
|
288
|
+
f"image {image}",
|
|
289
|
+
None,
|
|
290
|
+
"not prepared; the first sandbox will include an image build",
|
|
291
|
+
f"stackbox prepare {image}",
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
why = (
|
|
295
|
+
f" (last attempt failed: {rec.error})" if rec and rec.error else ""
|
|
296
|
+
)
|
|
297
|
+
check(
|
|
298
|
+
bcfg.name,
|
|
299
|
+
f"image {image}",
|
|
300
|
+
False,
|
|
301
|
+
f"not prepared; this provider can't run it until it is{why}",
|
|
302
|
+
f"stackbox prepare {image} --provider {bcfg.name}",
|
|
303
|
+
)
|
|
304
|
+
await backend.aclose()
|
|
305
|
+
return results
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
309
|
+
try:
|
|
310
|
+
cfg, source = _load(args)
|
|
311
|
+
except ConfigError as exc:
|
|
312
|
+
print(f"{FAIL} config: {exc}", file=sys.stderr)
|
|
313
|
+
return 2
|
|
314
|
+
results = asyncio.run(_doctor(cfg, args.image))
|
|
315
|
+
if args.json:
|
|
316
|
+
_print_json({"config": source, "checks": results})
|
|
317
|
+
else:
|
|
318
|
+
print(f"config: {source}\n")
|
|
319
|
+
last = None
|
|
320
|
+
for r in results:
|
|
321
|
+
if r["backend"] != last:
|
|
322
|
+
if last is not None:
|
|
323
|
+
print()
|
|
324
|
+
print(r["backend"])
|
|
325
|
+
last = r["backend"]
|
|
326
|
+
mark = OK if r["ok"] else (WARN if r["ok"] is None else FAIL)
|
|
327
|
+
print(f" {mark} {r['check']}: {r['detail']}")
|
|
328
|
+
if r["fix"] and not r["ok"]:
|
|
329
|
+
print(f" fix: {r['fix']}")
|
|
330
|
+
unset = [
|
|
331
|
+
p
|
|
332
|
+
for p in catalog.PROVIDERS.values()
|
|
333
|
+
if p.kind not in {b.kind for b in cfg.backends}
|
|
334
|
+
]
|
|
335
|
+
if unset:
|
|
336
|
+
print(
|
|
337
|
+
"\nNot configured: "
|
|
338
|
+
+ ", ".join(p.kind for p in unset)
|
|
339
|
+
+ " (see `stackbox providers`)"
|
|
340
|
+
)
|
|
341
|
+
failed = [r for r in results if r["ok"] is False]
|
|
342
|
+
usable = {r["backend"] for r in results} - {r["backend"] for r in failed}
|
|
343
|
+
if not args.json:
|
|
344
|
+
print(
|
|
345
|
+
f"\n{len(usable)} of {len(cfg.backends)} provider(s) ready"
|
|
346
|
+
+ ("" if not failed else f"; {len(failed)} problem(s) above")
|
|
347
|
+
)
|
|
348
|
+
fixes = []
|
|
349
|
+
for r in results:
|
|
350
|
+
if r["ok"] is not True and r["fix"]:
|
|
351
|
+
fix = r["fix"].split(" --provider ")[
|
|
352
|
+
0
|
|
353
|
+
] # one prepare covers every provider
|
|
354
|
+
if fix not in fixes:
|
|
355
|
+
fixes.append(fix)
|
|
356
|
+
if fixes:
|
|
357
|
+
print("\nNext:")
|
|
358
|
+
for fix in fixes:
|
|
359
|
+
print(f" {fix}")
|
|
360
|
+
elif not failed:
|
|
361
|
+
print("\nNext: stackbox serve")
|
|
362
|
+
return 0 if not failed else (1 if usable else 2)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ------------------------------------------------------------------- prepare
|
|
366
|
+
def cmd_prepare(args: argparse.Namespace) -> int:
|
|
367
|
+
from stackbox import ledger as L
|
|
368
|
+
from stackbox.backends import make_backend
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
cfg, _ = _load(args)
|
|
372
|
+
except ConfigError as exc:
|
|
373
|
+
print(f"{FAIL} {exc}", file=sys.stderr)
|
|
374
|
+
return 2
|
|
375
|
+
ledger = L.Ledger(cfg.gateway.db_path)
|
|
376
|
+
names = args.provider or [b.name for b in cfg.backends if b.kind != "opensandbox"]
|
|
377
|
+
unknown = [n for n in names if n not in {b.name for b in cfg.backends}]
|
|
378
|
+
if unknown:
|
|
379
|
+
print(f"{FAIL} unknown provider(s): {', '.join(unknown)}", file=sys.stderr)
|
|
380
|
+
return 2
|
|
381
|
+
|
|
382
|
+
async def run() -> list[dict[str, Any]]:
|
|
383
|
+
backends = [make_backend(cfg.backend(n), cfg.gateway, ledger) for n in names]
|
|
384
|
+
jobs = [(b, img) for b in backends for img in args.images]
|
|
385
|
+
if not args.json:
|
|
386
|
+
print(f"Preparing {', '.join(args.images)} on {', '.join(names)} …")
|
|
387
|
+
results = await asyncio.gather(
|
|
388
|
+
*(b.prepare(img) for b, img in jobs), return_exceptions=True
|
|
389
|
+
)
|
|
390
|
+
out = []
|
|
391
|
+
for (b, img), r in zip(jobs, results, strict=True):
|
|
392
|
+
if isinstance(r, BaseException):
|
|
393
|
+
out.append(
|
|
394
|
+
{"backend": b.name, "image": img, "ok": False, "error": f"{r}"}
|
|
395
|
+
)
|
|
396
|
+
else:
|
|
397
|
+
out.append({"ok": True, **r})
|
|
398
|
+
for b in backends:
|
|
399
|
+
await b.aclose()
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
out = asyncio.run(run())
|
|
403
|
+
if args.json:
|
|
404
|
+
_print_json(out)
|
|
405
|
+
else:
|
|
406
|
+
for r in out:
|
|
407
|
+
if r["ok"]:
|
|
408
|
+
took = (
|
|
409
|
+
f" in {r['prepare_s']} s" if r.get("prepare_s") is not None else ""
|
|
410
|
+
)
|
|
411
|
+
print(f" {OK} {r['backend']}: {r['image']} ready{took}")
|
|
412
|
+
else:
|
|
413
|
+
print(f" {FAIL} {r['backend']}: {r['image']}: {r['error']}")
|
|
414
|
+
return 0 if all(r["ok"] for r in out) else 1
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ---------------------------------------------------------------------- rank
|
|
418
|
+
def cmd_route(args: argparse.Namespace) -> int:
|
|
419
|
+
from stackbox import ledger as L
|
|
420
|
+
from stackbox.backends import make_backend
|
|
421
|
+
from stackbox.router import PlacementError, Request, Router, parse_preferences
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
cfg, _ = _load(args)
|
|
425
|
+
except ConfigError as exc:
|
|
426
|
+
print(f"{FAIL} {exc}", file=sys.stderr)
|
|
427
|
+
return 2
|
|
428
|
+
try:
|
|
429
|
+
strategy, providers, max_price = parse_preferences(
|
|
430
|
+
{
|
|
431
|
+
k: v
|
|
432
|
+
for k, v in {
|
|
433
|
+
"x-stackbox-strategy": args.strategy,
|
|
434
|
+
"x-stackbox-providers": args.providers,
|
|
435
|
+
"x-stackbox-max-price": args.max_price,
|
|
436
|
+
}.items()
|
|
437
|
+
if v
|
|
438
|
+
},
|
|
439
|
+
{},
|
|
440
|
+
)
|
|
441
|
+
except PlacementError as exc:
|
|
442
|
+
print(f"{FAIL} {exc} ({exc.hint})", file=sys.stderr)
|
|
443
|
+
return 2
|
|
444
|
+
ledger = L.Ledger(cfg.gateway.db_path)
|
|
445
|
+
|
|
446
|
+
async def run() -> list[dict[str, Any]]:
|
|
447
|
+
backends = {b.name: make_backend(b, cfg.gateway, ledger) for b in cfg.backends}
|
|
448
|
+
for b in backends.values():
|
|
449
|
+
b.cfg.auto_prepare = False # route only looks
|
|
450
|
+
router = Router(cfg, ledger, backends)
|
|
451
|
+
try:
|
|
452
|
+
return router.explain(
|
|
453
|
+
Request(
|
|
454
|
+
args.image, args.cpu, args.memory, strategy, providers, max_price
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
finally:
|
|
458
|
+
for b in backends.values():
|
|
459
|
+
await b.aclose()
|
|
460
|
+
|
|
461
|
+
rows = asyncio.run(run())
|
|
462
|
+
shown = strategy or cfg.routing.strategy
|
|
463
|
+
if args.json:
|
|
464
|
+
_print_json({"image": args.image, "strategy": shown, "items": rows})
|
|
465
|
+
return 0
|
|
466
|
+
print(f"strategy: {shown} image: {args.image}\n")
|
|
467
|
+
table = [
|
|
468
|
+
[
|
|
469
|
+
str(r["rank"]) if r["rank"] else "-",
|
|
470
|
+
r["backend"],
|
|
471
|
+
f"${r['price_per_hour']:.3f}/h"
|
|
472
|
+
if r.get("price_per_hour") is not None
|
|
473
|
+
else "",
|
|
474
|
+
f"{r['expected_startup_s']:.1f} s"
|
|
475
|
+
if r.get("expected_startup_s") is not None
|
|
476
|
+
else "",
|
|
477
|
+
r["status"],
|
|
478
|
+
]
|
|
479
|
+
for r in rows
|
|
480
|
+
]
|
|
481
|
+
print(_table(table, ["#", "PROVIDER", "PRICE", "STARTUP", "STATUS"]))
|
|
482
|
+
return 0
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def cmd_images(args: argparse.Namespace) -> int:
|
|
486
|
+
from stackbox import ledger as L
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
cfg, _ = _load(args)
|
|
490
|
+
except ConfigError as exc:
|
|
491
|
+
print(f"{FAIL} {exc}", file=sys.stderr)
|
|
492
|
+
return 2
|
|
493
|
+
rows = [
|
|
494
|
+
{
|
|
495
|
+
"provider": i["backend"],
|
|
496
|
+
**{k: i[k] for k in ("image", "status", "ref", "error")},
|
|
497
|
+
}
|
|
498
|
+
for i in L.Ledger(cfg.gateway.db_path).images()
|
|
499
|
+
if not args.image or i["image"] == args.image
|
|
500
|
+
]
|
|
501
|
+
if args.json:
|
|
502
|
+
_print_json({"items": rows})
|
|
503
|
+
return 0
|
|
504
|
+
if not rows:
|
|
505
|
+
print(
|
|
506
|
+
f"No prepared images yet. Run: stackbox prepare {args.image or DEFAULT_IMAGE}"
|
|
507
|
+
)
|
|
508
|
+
return 0
|
|
509
|
+
print(
|
|
510
|
+
_table(
|
|
511
|
+
[
|
|
512
|
+
[
|
|
513
|
+
r["image"],
|
|
514
|
+
r["provider"],
|
|
515
|
+
r["status"],
|
|
516
|
+
(r["error"] or r["ref"] or "")[:70],
|
|
517
|
+
]
|
|
518
|
+
for r in sorted(rows, key=lambda r: (r["image"], r["provider"]))
|
|
519
|
+
],
|
|
520
|
+
["IMAGE", "PROVIDER", "STATUS", "REF / ERROR"],
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
return 0
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ------------------------------------------------- talking to a running server
|
|
527
|
+
def _server(args: argparse.Namespace) -> tuple[str, dict[str, str]]:
|
|
528
|
+
url = (
|
|
529
|
+
args.url or os.environ.get("STACKBOX_URL") or f"http://127.0.0.1:{DEFAULT_PORT}"
|
|
530
|
+
).rstrip("/")
|
|
531
|
+
key = os.environ.get("STACKBOX_API_KEY")
|
|
532
|
+
if key is None:
|
|
533
|
+
try:
|
|
534
|
+
key = _load(args)[0].gateway.api_key
|
|
535
|
+
except ConfigError:
|
|
536
|
+
key = None
|
|
537
|
+
return url, ({"OPEN-SANDBOX-API-KEY": key} if key else {})
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _try_get(args: argparse.Namespace, path: str) -> Any | None:
|
|
541
|
+
"""GET from a running server, or None if none is reachable (for optional live info)."""
|
|
542
|
+
import httpx
|
|
543
|
+
|
|
544
|
+
url, headers = _server(args)
|
|
545
|
+
try:
|
|
546
|
+
r = httpx.get(f"{url}{path}", headers=headers, timeout=1.5)
|
|
547
|
+
return r.json() if r.status_code == 200 else None
|
|
548
|
+
except (httpx.HTTPError, ValueError):
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _get(
|
|
553
|
+
args: argparse.Namespace, path: str, params: dict[str, Any] | None = None
|
|
554
|
+
) -> Any:
|
|
555
|
+
import httpx
|
|
556
|
+
|
|
557
|
+
url, headers = _server(args)
|
|
558
|
+
try:
|
|
559
|
+
r = httpx.get(f"{url}{path}", headers=headers, params=params, timeout=10)
|
|
560
|
+
except httpx.HTTPError as exc:
|
|
561
|
+
raise SystemExit(
|
|
562
|
+
f"{FAIL} can't reach the gateway at {url} ({type(exc).__name__}). "
|
|
563
|
+
"Is `stackbox serve` running? Use --url for another address."
|
|
564
|
+
) from exc
|
|
565
|
+
if r.status_code == 401:
|
|
566
|
+
raise SystemExit(f"{FAIL} {url} rejected the API key; set STACKBOX_API_KEY.")
|
|
567
|
+
if r.status_code >= 400:
|
|
568
|
+
try:
|
|
569
|
+
body = r.json()
|
|
570
|
+
raise SystemExit(f"{FAIL} {body.get('message', r.text)}")
|
|
571
|
+
except ValueError:
|
|
572
|
+
r.raise_for_status()
|
|
573
|
+
return r.json()
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _age(iso: str | None) -> str:
|
|
577
|
+
if not iso:
|
|
578
|
+
return ""
|
|
579
|
+
import datetime as dt
|
|
580
|
+
|
|
581
|
+
secs = time.time() - dt.datetime.fromisoformat(iso).timestamp()
|
|
582
|
+
return f"{secs / 60:.0f} min" if secs < 5400 else f"{secs / 3600:.1f} h"
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def cmd_sandboxes(args: argparse.Namespace) -> int:
|
|
586
|
+
rows = [
|
|
587
|
+
r
|
|
588
|
+
for r in _get(args, "/v1/usage", {"group_by": "sandbox"})["sandboxes"]
|
|
589
|
+
if args.all or r["state"] in ("provisioning", "active", "unknown")
|
|
590
|
+
]
|
|
591
|
+
if args.json:
|
|
592
|
+
_print_json({"items": rows})
|
|
593
|
+
return 0
|
|
594
|
+
if not rows:
|
|
595
|
+
print("No sandboxes." + ("" if args.all else " (--all includes ended ones)"))
|
|
596
|
+
return 0
|
|
597
|
+
print(
|
|
598
|
+
_table(
|
|
599
|
+
[
|
|
600
|
+
[
|
|
601
|
+
r["id"],
|
|
602
|
+
r["provider"] or "-",
|
|
603
|
+
r["state"],
|
|
604
|
+
r["image"] or "",
|
|
605
|
+
_age(r["requested_at"]),
|
|
606
|
+
f"${r['est_cost_usd']:.4f}",
|
|
607
|
+
]
|
|
608
|
+
for r in rows
|
|
609
|
+
],
|
|
610
|
+
["ID", "PROVIDER", "STATE", "IMAGE", "AGE", "EST. COST"],
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
return 0
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def cmd_placement(args: argparse.Namespace) -> int:
|
|
617
|
+
p = _get(args, f"/v1/placements/{args.id}")
|
|
618
|
+
if args.json:
|
|
619
|
+
_print_json(p)
|
|
620
|
+
return 0
|
|
621
|
+
print(f"sandbox {p['id']}")
|
|
622
|
+
print(
|
|
623
|
+
f"provider {p['provider'] or '-'} state: {p['state']} image: {p['image']}"
|
|
624
|
+
)
|
|
625
|
+
print(
|
|
626
|
+
f"timing queued {p['queued_s'] or 0:.2f} s, create {p['create_s'] or 0:.2f} s, "
|
|
627
|
+
f"ran {p['seconds']:.0f} s"
|
|
628
|
+
)
|
|
629
|
+
print(f"cost ${p['est_cost_usd']:.6f} (estimate, list price)")
|
|
630
|
+
if p["last_error"]:
|
|
631
|
+
print(f"note {p['last_error']}")
|
|
632
|
+
print("\nattempts:")
|
|
633
|
+
print(
|
|
634
|
+
_table(
|
|
635
|
+
[
|
|
636
|
+
[
|
|
637
|
+
a["provider"],
|
|
638
|
+
a["outcome"] or "in progress",
|
|
639
|
+
f"{a['duration_s']:.2f} s" if a["duration_s"] is not None else "",
|
|
640
|
+
(a["error"] or "")[:80],
|
|
641
|
+
]
|
|
642
|
+
for a in p["attempts"]
|
|
643
|
+
],
|
|
644
|
+
["PROVIDER", "OUTCOME", "TOOK", "ERROR"],
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
return 0
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def cmd_usage(args: argparse.Namespace) -> int:
|
|
651
|
+
u = _get(args, "/v1/usage", {"since": args.since, "group_by": "provider"})
|
|
652
|
+
if args.json:
|
|
653
|
+
_print_json(u)
|
|
654
|
+
return 0
|
|
655
|
+
rows = [
|
|
656
|
+
[
|
|
657
|
+
name,
|
|
658
|
+
str(v["sandboxes"]),
|
|
659
|
+
f"{v['sandbox_hours']:.2f}",
|
|
660
|
+
f"{v['vcpu_hours']:.2f}",
|
|
661
|
+
f"{v['gib_hours']:.2f}",
|
|
662
|
+
f"${v['est_cost_usd']:.4f}",
|
|
663
|
+
]
|
|
664
|
+
for name, v in sorted(u["providers"].items())
|
|
665
|
+
]
|
|
666
|
+
t = u["totals"]
|
|
667
|
+
rows.append(
|
|
668
|
+
[
|
|
669
|
+
"total",
|
|
670
|
+
str(t["sandboxes"]),
|
|
671
|
+
f"{t['sandbox_hours']:.2f}",
|
|
672
|
+
f"{t['vcpu_hours']:.2f}",
|
|
673
|
+
f"{t['gib_hours']:.2f}",
|
|
674
|
+
f"${t['est_cost_usd']:.4f}",
|
|
675
|
+
]
|
|
676
|
+
)
|
|
677
|
+
print(f"since {u['since'] or 'the start of the ledger'}\n")
|
|
678
|
+
print(
|
|
679
|
+
_table(
|
|
680
|
+
rows, ["PROVIDER", "SANDBOXES", "SANDBOX-H", "VCPU-H", "GIB-H", "EST. COST"]
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
print(f"\n{u['note']}.")
|
|
684
|
+
return 0
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# --------------------------------------------------------------------- serve
|
|
688
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
689
|
+
import uvicorn
|
|
690
|
+
|
|
691
|
+
from stackbox.app import create_app
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
cfg, source = _load(args)
|
|
695
|
+
except ConfigError as exc:
|
|
696
|
+
print(f"{FAIL} {exc}", file=sys.stderr)
|
|
697
|
+
return 2
|
|
698
|
+
if args.strategy:
|
|
699
|
+
cfg.routing.strategy = args.strategy
|
|
700
|
+
public = args.host not in ("127.0.0.1", "localhost", "::1")
|
|
701
|
+
if public and not cfg.gateway.api_key:
|
|
702
|
+
print(
|
|
703
|
+
f"{FAIL} refusing to listen on {args.host} without an API key: anyone who can reach "
|
|
704
|
+
"it could create sandboxes on your accounts.\n"
|
|
705
|
+
' Set [gateway] api_key = "env:STACKBOX_API_KEY" in the config, or '
|
|
706
|
+
"export STACKBOX_API_KEY when using auto-detection.",
|
|
707
|
+
file=sys.stderr,
|
|
708
|
+
)
|
|
709
|
+
return 2
|
|
710
|
+
base = f"http://{args.host}:{args.port}"
|
|
711
|
+
print(f"stackbox {_version()} config: {source}")
|
|
712
|
+
for b in cfg.backends:
|
|
713
|
+
p = catalog.PROVIDERS.get(b.kind)
|
|
714
|
+
label = p.label if p else b.kind
|
|
715
|
+
price = b.cost_per_vcpu_hour * b.cpu_billed_fraction + 2 * b.cost_per_gib_hour
|
|
716
|
+
print(
|
|
717
|
+
f" {OK} {b.name:<11} {label:<22} ${price:.3f}/h for 1 vCPU + 2 GiB max {b.max_active} at once"
|
|
718
|
+
)
|
|
719
|
+
print(f" strategy: {cfg.routing.strategy} ledger: {cfg.gateway.db_path}")
|
|
720
|
+
print(
|
|
721
|
+
f"\nListening on {base}"
|
|
722
|
+
+ ("" if cfg.gateway.api_key else " (no API key: local use only)")
|
|
723
|
+
)
|
|
724
|
+
print("Point any OpenSandbox client at it, e.g.:")
|
|
725
|
+
print(f' Python ConnectionConfig(domain="{base}", protocol="https")')
|
|
726
|
+
print(
|
|
727
|
+
f" Harbor environment: {{type: opensandbox, kwargs: {{domain: {base}, protocol: https}}}}\n"
|
|
728
|
+
)
|
|
729
|
+
logging.basicConfig(
|
|
730
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
|
731
|
+
)
|
|
732
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
733
|
+
uvicorn.run(create_app(cfg), host=args.host, port=args.port, log_level="warning")
|
|
734
|
+
return 0
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
# ---------------------------------------------------------------------- main
|
|
738
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
739
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
740
|
+
common.add_argument(
|
|
741
|
+
"-c",
|
|
742
|
+
"--config",
|
|
743
|
+
default=argparse.SUPPRESS,
|
|
744
|
+
help=f"config file (default: $STACKBOX_CONFIG, ./{CONFIG_FILE}, or auto-detect)",
|
|
745
|
+
)
|
|
746
|
+
parser = argparse.ArgumentParser(
|
|
747
|
+
prog="stackbox",
|
|
748
|
+
description="One OpenSandbox-compatible endpoint for E2B, Modal, Daytona, Vercel, "
|
|
749
|
+
"Cloudflare and your own OpenSandbox servers.",
|
|
750
|
+
parents=[common],
|
|
751
|
+
epilog="Start with: stackbox providers, then stackbox doctor.",
|
|
752
|
+
)
|
|
753
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {_version()}")
|
|
754
|
+
# Before subcommands existed, `--host/--port` with no command meant serve.
|
|
755
|
+
parser.add_argument("--host", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
756
|
+
parser.add_argument(
|
|
757
|
+
"--port", type=int, default=argparse.SUPPRESS, help=argparse.SUPPRESS
|
|
758
|
+
)
|
|
759
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
760
|
+
|
|
761
|
+
p = sub.add_parser(
|
|
762
|
+
"providers",
|
|
763
|
+
parents=[common],
|
|
764
|
+
help="supported providers, prices, what you have set up, and live status",
|
|
765
|
+
)
|
|
766
|
+
p.add_argument("--url", help="server to read live status from (if running)")
|
|
767
|
+
p.add_argument("--json", action="store_true")
|
|
768
|
+
p.set_defaults(fn=cmd_providers)
|
|
769
|
+
|
|
770
|
+
p = sub.add_parser(
|
|
771
|
+
"init",
|
|
772
|
+
parents=[common],
|
|
773
|
+
help=f"write ./{CONFIG_FILE} for the providers in your environment",
|
|
774
|
+
)
|
|
775
|
+
p.add_argument("-o", "--output", default=CONFIG_FILE)
|
|
776
|
+
p.add_argument("--force", action="store_true", help="overwrite an existing file")
|
|
777
|
+
p.set_defaults(fn=cmd_init)
|
|
778
|
+
|
|
779
|
+
p = sub.add_parser(
|
|
780
|
+
"doctor",
|
|
781
|
+
parents=[common],
|
|
782
|
+
help="check credentials, connectivity, execd and images per provider",
|
|
783
|
+
)
|
|
784
|
+
p.add_argument(
|
|
785
|
+
"--image",
|
|
786
|
+
default=DEFAULT_IMAGE,
|
|
787
|
+
help=f"image to check (default {DEFAULT_IMAGE})",
|
|
788
|
+
)
|
|
789
|
+
p.add_argument("--json", action="store_true")
|
|
790
|
+
p.set_defaults(fn=cmd_doctor)
|
|
791
|
+
|
|
792
|
+
p = sub.add_parser(
|
|
793
|
+
"prepare", parents=[common], help="make images fast to start on every provider"
|
|
794
|
+
)
|
|
795
|
+
p.add_argument("images", nargs="+", metavar="IMAGE")
|
|
796
|
+
p.add_argument(
|
|
797
|
+
"-p", "--provider", action="append", help="only these providers (repeatable)"
|
|
798
|
+
)
|
|
799
|
+
p.add_argument("--json", action="store_true")
|
|
800
|
+
p.set_defaults(fn=cmd_prepare)
|
|
801
|
+
|
|
802
|
+
p = sub.add_parser("serve", parents=[common], help="run the gateway")
|
|
803
|
+
p.add_argument("--host", default="127.0.0.1")
|
|
804
|
+
p.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
805
|
+
p.add_argument("--strategy", choices=["priority", "cost", "latency", "balanced"])
|
|
806
|
+
p.set_defaults(fn=cmd_serve)
|
|
807
|
+
|
|
808
|
+
p = sub.add_parser(
|
|
809
|
+
"route",
|
|
810
|
+
parents=[common],
|
|
811
|
+
help="where a sandbox for IMAGE would go right now, and why",
|
|
812
|
+
)
|
|
813
|
+
p.add_argument("image", nargs="?", default=DEFAULT_IMAGE)
|
|
814
|
+
p.add_argument("--cpu", type=float, help="vCPUs requested")
|
|
815
|
+
p.add_argument("--memory", type=float, help="GiB requested")
|
|
816
|
+
p.add_argument("--strategy", choices=["priority", "cost", "latency", "balanced"])
|
|
817
|
+
p.add_argument("--providers", help="allow-list, in order: e2b,modal")
|
|
818
|
+
p.add_argument("--max-price", help="USD per sandbox-hour")
|
|
819
|
+
p.add_argument("--json", action="store_true")
|
|
820
|
+
p.set_defaults(fn=cmd_route)
|
|
821
|
+
|
|
822
|
+
p = sub.add_parser("images", parents=[common], help="prepared images per provider")
|
|
823
|
+
p.add_argument("image", nargs="?", help="only this image")
|
|
824
|
+
p.add_argument("--json", action="store_true")
|
|
825
|
+
p.set_defaults(fn=cmd_images)
|
|
826
|
+
|
|
827
|
+
url_help = f"server URL (default $STACKBOX_URL or http://127.0.0.1:{DEFAULT_PORT})"
|
|
828
|
+
p = sub.add_parser(
|
|
829
|
+
"sandboxes", parents=[common], help="sandboxes the running server holds"
|
|
830
|
+
)
|
|
831
|
+
p.add_argument("--all", action="store_true", help="include ended sandboxes")
|
|
832
|
+
p.add_argument("--url", help=url_help)
|
|
833
|
+
p.add_argument("--json", action="store_true")
|
|
834
|
+
p.set_defaults(fn=cmd_sandboxes)
|
|
835
|
+
|
|
836
|
+
p = sub.add_parser(
|
|
837
|
+
"placement",
|
|
838
|
+
parents=[common],
|
|
839
|
+
help="where a sandbox went, every attempt, and its cost",
|
|
840
|
+
)
|
|
841
|
+
p.add_argument("id", help="sandbox id")
|
|
842
|
+
p.add_argument("--url", help=url_help)
|
|
843
|
+
p.add_argument("--json", action="store_true")
|
|
844
|
+
p.set_defaults(fn=cmd_placement)
|
|
845
|
+
|
|
846
|
+
p = sub.add_parser(
|
|
847
|
+
"usage", parents=[common], help="sandbox time and estimated cost per provider"
|
|
848
|
+
)
|
|
849
|
+
p.add_argument(
|
|
850
|
+
"--since", default="24h", help="ISO time or 30m, 24h, 7d (default 24h)"
|
|
851
|
+
)
|
|
852
|
+
p.add_argument("--url", help=url_help)
|
|
853
|
+
p.add_argument("--json", action="store_true")
|
|
854
|
+
p.set_defaults(fn=cmd_usage)
|
|
855
|
+
return parser
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def main(argv: list[str] | None = None) -> None:
|
|
859
|
+
parser = build_parser()
|
|
860
|
+
args = parser.parse_args(argv)
|
|
861
|
+
if args.command is None:
|
|
862
|
+
if (
|
|
863
|
+
getattr(args, "config", None)
|
|
864
|
+
or hasattr(args, "port")
|
|
865
|
+
or hasattr(args, "host")
|
|
866
|
+
):
|
|
867
|
+
args.host = getattr(args, "host", "127.0.0.1")
|
|
868
|
+
args.port = getattr(args, "port", DEFAULT_PORT)
|
|
869
|
+
args.strategy = None
|
|
870
|
+
raise SystemExit(cmd_serve(args))
|
|
871
|
+
parser.print_help()
|
|
872
|
+
raise SystemExit(0)
|
|
873
|
+
raise SystemExit(args.fn(args))
|