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/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))