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/config.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Gateway configuration, loaded from TOML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, model_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GatewaySettings(BaseModel):
|
|
14
|
+
api_key: str | None = Field(
|
|
15
|
+
default=None,
|
|
16
|
+
description="Key clients must send in OPEN-SANDBOX-API-KEY. None disables auth (local dev only).",
|
|
17
|
+
)
|
|
18
|
+
db_path: str = "stackbox.db"
|
|
19
|
+
queue_timeout_s: float = Field(
|
|
20
|
+
default=60.0,
|
|
21
|
+
description="How long a create waits for capacity on some backend before returning 503.",
|
|
22
|
+
)
|
|
23
|
+
create_timeout_s: float = Field(
|
|
24
|
+
default=120.0,
|
|
25
|
+
description="Upstream create timeout. For hosted backends this covers boot and execd readiness.",
|
|
26
|
+
)
|
|
27
|
+
request_timeout_s: float = Field(
|
|
28
|
+
default=30.0, description="Timeout for other upstream calls."
|
|
29
|
+
)
|
|
30
|
+
max_create_attempts: int = Field(
|
|
31
|
+
default=3,
|
|
32
|
+
ge=1,
|
|
33
|
+
description="Provisioning attempts per create across all backends before returning 503.",
|
|
34
|
+
)
|
|
35
|
+
breaker_failures: int = Field(
|
|
36
|
+
default=3,
|
|
37
|
+
description="Consecutive provisioning failures that open a backend's circuit breaker.",
|
|
38
|
+
)
|
|
39
|
+
breaker_cooldown_s: float = 30.0
|
|
40
|
+
reconcile_interval_s: float = 30.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
Strategy = Literal["priority", "cost", "latency", "balanced"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RoutingSettings(BaseModel):
|
|
47
|
+
strategy: Strategy = Field(
|
|
48
|
+
default="priority",
|
|
49
|
+
description=(
|
|
50
|
+
"How to order backends that can serve a create. priority: fixed priority. "
|
|
51
|
+
"cost: cheapest hourly price for the requested resources. latency: lowest "
|
|
52
|
+
"expected startup for this image. balanced: weighted mix of cost and latency."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
cost_weight: float = Field(default=1.0, ge=0)
|
|
56
|
+
latency_weight: float = Field(default=1.0, ge=0)
|
|
57
|
+
ewma_alpha: float = Field(
|
|
58
|
+
default=0.3,
|
|
59
|
+
gt=0,
|
|
60
|
+
le=1,
|
|
61
|
+
description="Weight of the newest startup observation in the per (backend, image) estimate.",
|
|
62
|
+
)
|
|
63
|
+
explore_samples: int = Field(
|
|
64
|
+
default=3,
|
|
65
|
+
ge=0,
|
|
66
|
+
description=(
|
|
67
|
+
"latency/balanced: a ready backend with fewer warm startup samples than this for an "
|
|
68
|
+
"image is ranked just ahead of the fastest measured one, so every backend gets "
|
|
69
|
+
"measured instead of losing forever to a prior. 0 disables exploration."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BackendConfig(BaseModel):
|
|
75
|
+
name: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,40}$")
|
|
76
|
+
kind: Literal["opensandbox", "e2b", "modal", "vercel", "daytona", "cloudflare"] = (
|
|
77
|
+
"opensandbox"
|
|
78
|
+
)
|
|
79
|
+
url: str | None = Field(
|
|
80
|
+
default=None,
|
|
81
|
+
description=(
|
|
82
|
+
"opensandbox: base URL of an OpenSandbox-compatible lifecycle API. "
|
|
83
|
+
"cloudflare: base URL of the deployed bridge Worker (bridges/cloudflare)."
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
api_key: str | None = Field(
|
|
87
|
+
default=None,
|
|
88
|
+
description=(
|
|
89
|
+
"opensandbox: upstream OPEN-SANDBOX-API-KEY. e2b: API key (defaults to $E2B_API_KEY). "
|
|
90
|
+
"modal: unused (the Modal SDK reads ~/.modal.toml or MODAL_TOKEN_ID/SECRET)."
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
max_active: int = Field(
|
|
94
|
+
default=10,
|
|
95
|
+
ge=1,
|
|
96
|
+
description="Sandboxes this backend may hold at once (account quota or owned capacity).",
|
|
97
|
+
)
|
|
98
|
+
priority: int = Field(
|
|
99
|
+
default=100,
|
|
100
|
+
description="Lower is preferred. The order under strategy=priority; a tie-break otherwise.",
|
|
101
|
+
)
|
|
102
|
+
cost_per_vcpu_hour: float = 0.0
|
|
103
|
+
cost_per_gib_hour: float = 0.0
|
|
104
|
+
cpu_billed_fraction: float = Field(
|
|
105
|
+
default=1.0,
|
|
106
|
+
ge=0,
|
|
107
|
+
le=1,
|
|
108
|
+
description=(
|
|
109
|
+
"Share of provisioned vCPU time the provider bills. 1.0 for providers that bill "
|
|
110
|
+
"allocated CPU; lower for ones that bill active CPU only (Vercel), set to the "
|
|
111
|
+
"workload's expected CPU utilization."
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
startup_prior_s: float = Field(
|
|
115
|
+
default=5.0,
|
|
116
|
+
description="Expected startup for a prepared image before any observation.",
|
|
117
|
+
)
|
|
118
|
+
cold_start_prior_s: float = Field(
|
|
119
|
+
default=30.0,
|
|
120
|
+
description="Expected startup for an image this backend has not prepared yet.",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Hosted backends (e2b, modal)
|
|
124
|
+
execd_path: str | None = Field(
|
|
125
|
+
default=None,
|
|
126
|
+
description="Local path to the static OpenSandbox execd binary baked into provider images.",
|
|
127
|
+
)
|
|
128
|
+
max_timeout_s: int = Field(
|
|
129
|
+
default=3600,
|
|
130
|
+
description="Longest sandbox lifetime the provider account allows; requests are clamped.",
|
|
131
|
+
)
|
|
132
|
+
default_cpu: float = Field(
|
|
133
|
+
default=1.0, description="vCPUs when the request sets none."
|
|
134
|
+
)
|
|
135
|
+
default_memory_gib: float = Field(
|
|
136
|
+
default=2.0, description="Memory when the request sets none."
|
|
137
|
+
)
|
|
138
|
+
execd_graceful_shutdown: str = Field(
|
|
139
|
+
default="50ms",
|
|
140
|
+
description=(
|
|
141
|
+
"execd --graceful-shutdown-timeout: how long execd holds each command's response "
|
|
142
|
+
"stream open after the command ends. execd v1.1.0 defaults to 1s, which exists so "
|
|
143
|
+
"OpenSandbox's egress sidecar can sync; hosted backends run no sidecar, and the "
|
|
144
|
+
"default adds a full second to every command."
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
auto_prepare: bool = Field(
|
|
148
|
+
default=True,
|
|
149
|
+
description="Prepare (build) an image in the background the first time a create asks for it.",
|
|
150
|
+
)
|
|
151
|
+
modal_app: str = "stackbox"
|
|
152
|
+
vercel_images: dict[str, str] = Field(
|
|
153
|
+
default_factory=dict,
|
|
154
|
+
description=(
|
|
155
|
+
"Image -> Vercel Container Registry reference to boot when preparing, for images "
|
|
156
|
+
"Vercel cannot resolve by name (e.g. ones mirrored into VCR)."
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
vercel_snapshot_expiration_s: int | None = Field(
|
|
160
|
+
default=None,
|
|
161
|
+
description="Lifetime of prepared Vercel snapshots; None = Vercel's default.",
|
|
162
|
+
)
|
|
163
|
+
modal_region: str | None = None
|
|
164
|
+
|
|
165
|
+
@model_validator(mode="after")
|
|
166
|
+
def _check_kind(self) -> BackendConfig:
|
|
167
|
+
if self.kind == "opensandbox" and not self.url:
|
|
168
|
+
raise ValueError(f"backend {self.name}: kind=opensandbox requires url")
|
|
169
|
+
if self.kind == "cloudflare" and not self.url:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"backend {self.name}: kind=cloudflare requires url (the bridge)"
|
|
172
|
+
)
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class RouterConfig(BaseModel):
|
|
177
|
+
gateway: GatewaySettings = Field(default_factory=GatewaySettings)
|
|
178
|
+
routing: RoutingSettings = Field(default_factory=RoutingSettings)
|
|
179
|
+
backends: list[BackendConfig]
|
|
180
|
+
|
|
181
|
+
def backend(self, name: str) -> BackendConfig:
|
|
182
|
+
for b in self.backends:
|
|
183
|
+
if b.name == name:
|
|
184
|
+
return b
|
|
185
|
+
raise KeyError(name)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ConfigError(ValueError):
|
|
189
|
+
"""A config problem, worded for the person fixing it."""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _expand_env(value: object) -> object:
|
|
193
|
+
""" "env:NAME" strings are read from the environment, so secrets stay out of config files.
|
|
194
|
+
"env:A|B" uses the first of A, B that is set."""
|
|
195
|
+
if isinstance(value, str) and value.startswith("env:"):
|
|
196
|
+
names = value[4:].split("|")
|
|
197
|
+
for name in names:
|
|
198
|
+
if os.environ.get(name):
|
|
199
|
+
return os.environ[name]
|
|
200
|
+
raise ConfigError(
|
|
201
|
+
f"config refers to environment variable {' or '.join(names)}, which is not set"
|
|
202
|
+
)
|
|
203
|
+
if isinstance(value, dict):
|
|
204
|
+
return {k: _expand_env(v) for k, v in value.items()}
|
|
205
|
+
if isinstance(value, list):
|
|
206
|
+
return [_expand_env(v) for v in value]
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
CONFIG_ENV = "STACKBOX_CONFIG"
|
|
211
|
+
CONFIG_FILE = "stackbox.toml"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def default_db_path() -> str:
|
|
215
|
+
base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state")
|
|
216
|
+
return str(Path(base) / "stackbox" / "stackbox.db")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _with_catalog_defaults(backend: dict) -> dict:
|
|
220
|
+
"""Fill in what the provider catalog knows (prices, limits, sizes) for unset fields."""
|
|
221
|
+
from stackbox.catalog import defaults_for
|
|
222
|
+
|
|
223
|
+
kind = backend.get("kind", "opensandbox")
|
|
224
|
+
merged = {**defaults_for(kind), **backend}
|
|
225
|
+
merged.setdefault("name", kind)
|
|
226
|
+
return merged
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def build_config(data: dict, base_dir: Path | None = None) -> RouterConfig:
|
|
230
|
+
data = dict(data)
|
|
231
|
+
data["backends"] = [_with_catalog_defaults(b) for b in data.get("backends") or []]
|
|
232
|
+
data = _expand_env(data) # type: ignore[assignment]
|
|
233
|
+
cfg = RouterConfig.model_validate(data)
|
|
234
|
+
names = [b.name for b in cfg.backends]
|
|
235
|
+
dupes = sorted({n for n in names if names.count(n) > 1})
|
|
236
|
+
if dupes:
|
|
237
|
+
raise ConfigError(f"backend names must be unique; repeated: {', '.join(dupes)}")
|
|
238
|
+
if not cfg.backends:
|
|
239
|
+
raise ConfigError(
|
|
240
|
+
"no backends configured. Add a [[backends]] block, or set a provider's "
|
|
241
|
+
"credentials (see `stackbox providers`)."
|
|
242
|
+
)
|
|
243
|
+
for b in cfg.backends:
|
|
244
|
+
if b.execd_path and base_dir and not Path(b.execd_path).is_absolute():
|
|
245
|
+
b.execd_path = str((base_dir / b.execd_path).resolve())
|
|
246
|
+
return cfg
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def load_config(path: str | Path) -> RouterConfig:
|
|
250
|
+
path = Path(path)
|
|
251
|
+
if not path.is_file():
|
|
252
|
+
raise ConfigError(f"config file {path} not found")
|
|
253
|
+
try:
|
|
254
|
+
data = tomllib.loads(path.read_text())
|
|
255
|
+
except tomllib.TOMLDecodeError as exc:
|
|
256
|
+
raise ConfigError(f"{path}: invalid TOML: {exc}") from exc
|
|
257
|
+
data.setdefault("gateway", {})
|
|
258
|
+
return build_config(data, base_dir=path.parent)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def auto_config() -> RouterConfig:
|
|
262
|
+
"""A config built from whichever providers have credentials in the environment."""
|
|
263
|
+
from stackbox.catalog import detected
|
|
264
|
+
|
|
265
|
+
found = detected()
|
|
266
|
+
if not found:
|
|
267
|
+
raise ConfigError(
|
|
268
|
+
"no provider credentials found in the environment and no config file. "
|
|
269
|
+
"Set one provider's credentials (e.g. export E2B_API_KEY=...), or run "
|
|
270
|
+
"`stackbox init`. See `stackbox providers`."
|
|
271
|
+
)
|
|
272
|
+
return build_config(
|
|
273
|
+
{
|
|
274
|
+
"gateway": {
|
|
275
|
+
"db_path": default_db_path(),
|
|
276
|
+
"api_key": os.environ.get("STACKBOX_API_KEY") or None,
|
|
277
|
+
},
|
|
278
|
+
"routing": {"strategy": "balanced"},
|
|
279
|
+
"backends": [{"kind": p.kind} for p in found],
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def resolve_config(path: str | Path | None = None) -> tuple[RouterConfig, str]:
|
|
285
|
+
"""Explicit path, then $STACKBOX_CONFIG, then ./stackbox.toml, then
|
|
286
|
+
auto-detection. Returns the config and where it came from."""
|
|
287
|
+
if path:
|
|
288
|
+
return load_config(path), str(path)
|
|
289
|
+
if os.environ.get(CONFIG_ENV):
|
|
290
|
+
env = os.environ[CONFIG_ENV]
|
|
291
|
+
return load_config(env), f"{env} (from ${CONFIG_ENV})"
|
|
292
|
+
if Path(CONFIG_FILE).is_file():
|
|
293
|
+
return load_config(CONFIG_FILE), f"./{CONFIG_FILE}"
|
|
294
|
+
return auto_config(), "auto-detected from environment credentials"
|
stackbox/execd.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Get OpenSandbox's static execd binary without Docker.
|
|
2
|
+
|
|
3
|
+
Hosted backends run execd inside every sandbox. Instead of asking users to copy it out
|
|
4
|
+
of an image by hand, it is downloaded once from the public ``opensandbox/execd`` image
|
|
5
|
+
(Docker Hub registry API, anonymous) and cached under ``$XDG_CACHE_HOME/stackbox``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
EXECD_IMAGE = "opensandbox/execd"
|
|
20
|
+
EXECD_TAG = "v1.1.0"
|
|
21
|
+
_REGISTRY = "https://registry-1.docker.io"
|
|
22
|
+
_AUTH = "https://auth.docker.io/token"
|
|
23
|
+
_ACCEPT = (
|
|
24
|
+
"application/vnd.oci.image.index.v1+json, "
|
|
25
|
+
"application/vnd.docker.distribution.manifest.list.v2+json, "
|
|
26
|
+
"application/vnd.oci.image.manifest.v1+json, "
|
|
27
|
+
"application/vnd.docker.distribution.manifest.v2+json"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ExecdError(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cache_dir() -> Path:
|
|
36
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
37
|
+
return Path(base) / "stackbox"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cached_path(tag: str = EXECD_TAG) -> Path:
|
|
41
|
+
return cache_dir() / f"execd-{tag}-linux-amd64"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_execd(member: tarfile.TarInfo) -> bool:
|
|
45
|
+
name = member.name.lstrip("./")
|
|
46
|
+
return member.isfile() and (name == "execd" or name.endswith("/execd"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def download(dest: Path, tag: str = EXECD_TAG, *, timeout: float = 300) -> Path:
|
|
50
|
+
"""Fetch execd (linux/amd64) from the registry into ``dest``."""
|
|
51
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as c:
|
|
52
|
+
try:
|
|
53
|
+
token = c.get(
|
|
54
|
+
_AUTH,
|
|
55
|
+
params={
|
|
56
|
+
"service": "registry.docker.io",
|
|
57
|
+
"scope": f"repository:{EXECD_IMAGE}:pull",
|
|
58
|
+
},
|
|
59
|
+
).json()["token"]
|
|
60
|
+
h = {"Authorization": f"Bearer {token}", "Accept": _ACCEPT}
|
|
61
|
+
m = c.get(f"{_REGISTRY}/v2/{EXECD_IMAGE}/manifests/{tag}", headers=h)
|
|
62
|
+
m.raise_for_status()
|
|
63
|
+
manifest = m.json()
|
|
64
|
+
if "manifests" in manifest: # multi-arch index
|
|
65
|
+
amd64 = next(
|
|
66
|
+
x
|
|
67
|
+
for x in manifest["manifests"]
|
|
68
|
+
if x.get("platform", {}).get("architecture") == "amd64"
|
|
69
|
+
and x.get("platform", {}).get("os") == "linux"
|
|
70
|
+
)
|
|
71
|
+
m = c.get(
|
|
72
|
+
f"{_REGISTRY}/v2/{EXECD_IMAGE}/manifests/{amd64['digest']}",
|
|
73
|
+
headers=h,
|
|
74
|
+
)
|
|
75
|
+
m.raise_for_status()
|
|
76
|
+
manifest = m.json()
|
|
77
|
+
# The top-most layer that contains execd wins.
|
|
78
|
+
for layer in reversed(manifest["layers"]):
|
|
79
|
+
blob = c.get(
|
|
80
|
+
f"{_REGISTRY}/v2/{EXECD_IMAGE}/blobs/{layer['digest']}", headers=h
|
|
81
|
+
)
|
|
82
|
+
blob.raise_for_status()
|
|
83
|
+
with tarfile.open(fileobj=io.BytesIO(blob.content), mode="r:*") as tar:
|
|
84
|
+
member = next((m for m in tar.getmembers() if _is_execd(m)), None)
|
|
85
|
+
if member is None:
|
|
86
|
+
continue
|
|
87
|
+
data = tar.extractfile(member).read() # type: ignore[union-attr]
|
|
88
|
+
break
|
|
89
|
+
else:
|
|
90
|
+
raise ExecdError(f"no execd binary found in {EXECD_IMAGE}:{tag}")
|
|
91
|
+
except (httpx.HTTPError, KeyError, StopIteration) as exc:
|
|
92
|
+
raise ExecdError(
|
|
93
|
+
f"could not download execd from {EXECD_IMAGE}:{tag}: {exc}. "
|
|
94
|
+
"Set execd_path in the config to a local copy instead."
|
|
95
|
+
) from exc
|
|
96
|
+
if not data.startswith(b"\x7fELF"):
|
|
97
|
+
raise ExecdError("downloaded execd is not an ELF binary")
|
|
98
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
with tempfile.NamedTemporaryFile(dir=dest.parent, delete=False) as tmp:
|
|
100
|
+
tmp.write(data)
|
|
101
|
+
os.chmod(tmp.name, 0o755)
|
|
102
|
+
os.replace(tmp.name, dest)
|
|
103
|
+
return dest
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ensure(path: str | None = None) -> Path:
|
|
107
|
+
"""The execd to use: an explicit path, else the cached download (fetched on first use)."""
|
|
108
|
+
if path:
|
|
109
|
+
p = Path(path).expanduser()
|
|
110
|
+
if not p.is_file():
|
|
111
|
+
raise ExecdError(f"execd_path {p} does not exist")
|
|
112
|
+
return p
|
|
113
|
+
p = cached_path()
|
|
114
|
+
if not p.is_file():
|
|
115
|
+
download(p)
|
|
116
|
+
if not p.stat().st_mode & stat.S_IXUSR:
|
|
117
|
+
os.chmod(p, 0o755)
|
|
118
|
+
return p
|