Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ It owns:
- Template records, async template builds, and their provider images
- Inline provisioning in `POST /hosts`
- Provider VM creation and deletion (exe.dev, AWS, Hetzner, Exoscale, Boat,
Cloudflare, Daytona, Vercel, local Docker, Docker Sandboxes)
Cloudflare, Daytona, Vercel, E2B, local Docker, Docker Sandboxes)
- Tailscale auth key creation, device discovery, and cleanup
- SSH host key scanning and `known_hosts` material
- An SSH gateway for hosts of gateway providers (`python -m gateway.server`)
Expand Down
1 change: 1 addition & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ account token returns `503`. See [API](api.md#service-accounts).
| `exoscale` | Exoscale VMs | Remote |
| `cloudflare` | Containers ([Cloudflare setup](cloudflare.md)) | Remote |
| `vercel` | microVMs ([Vercel setup](vercel.md)) | Remote |
| `e2b` | microVMs ([E2B setup](e2b.md)) | Remote |
| `docker` | Containers ([Local sandboxes with Docker](#local-sandboxes-with-docker)) | Local, no external account |
| `docker-sbx` | microVMs ([Local microVMs with Docker Sandboxes](#local-microvms-with-docker-sandboxes)) | Local |
| `boat` | Sandboxes ([Boat configuration](boat.md)) | Cloud |
Expand Down
46 changes: 46 additions & 0 deletions docs/e2b.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# E2B sandboxes

`POST /hosts` with `{"provider": "e2b"}` starts an E2B sandbox, joins it to
Tailscale, and returns `internal_ssh_host`. The sandbox has no public SSH
address, so this provider requires `TAILSCALE_ENABLED=true`.

## Prepare the template

E2B boots sandboxes from templates in your E2B account. Build the drukbox
template:

```bash
E2B_API_KEY=YOUR-API-KEY uv run --extra e2b images/e2b/build.py --name drukbox
```

A custom template needs the steps in `images/e2b/build.py`.

## Configure

Give drukbox an API key and the Tailscale settings from
[Networking](networking.md). The tailnet SSH policy must let the API and
callers reach the sandbox tag as `root`.

```dotenv
E2B_API_KEY=YOUR-API-KEY
E2B_DEFAULT_IMAGE=drukbox
TAILSCALE_ENABLED=true
```

The key can also come from `/run/secrets/E2B_API_KEY`.

| Variable | Default | Purpose |
| --- | --- | --- |
| `E2B_MAX_LIFETIME_SECONDS` | `3600` | Time until E2B kills a sandbox |
| `E2B_API_TIMEOUT` | `150` | API request timeout, in seconds |
| `E2B_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `120` | SSH readiness timeout, in seconds |

## Limits

- E2B kills a sandbox after `E2B_MAX_LIFETIME_SECONDS`: at most 1 hour or 24
hours, by E2B plan. No lease ends after that time: drukbox cuts a later or
permanent `expires_at` to it, at create and at renewal.
- The template sets CPU, memory, and disk. `instance_type`, `disk_gb`, and
template builds are not supported.
- A killed sandbox loses its files. Keep the janitor running: it deletes the
sandbox and its Tailscale device when the lease ends.
47 changes: 47 additions & 0 deletions images/e2b/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import argparse

from e2b import Template, default_build_logger

if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Build the Drukbox E2B template")
parser.add_argument("--name", default="drukbox")
arguments = parser.parse_args()
template = (
Template()
.from_ubuntu_image("24.04")
.apt_install(
[
"bash",
"ca-certificates",
"curl",
"sudo",
"jq",
"git",
"gh",
"openssh-client",
"iptables",
"iproute2",
]
)
# E2B's base adds openssh-server. Tailscale SSH would serve its host
# keys, made at build time, from every sandbox.
.run_cmd("DEBIAN_FRONTEND=noninteractive apt-get purge -y openssh-server", user="root")
# E2B boots and snapshots the template, so tailscaled is disabled in the
# step that installs it: each sandbox's bootstrap starts a fresh one.
.run_cmd(
[
"curl -fsSL https://tailscale.com/install.sh -o /tmp/install-tailscale.sh",
"sh /tmp/install-tailscale.sh",
"rm /tmp/install-tailscale.sh",
"systemctl disable tailscaled",
],
user="root",
)
)
Template.build(
template,
arguments.name,
cpu_count=2,
memory_mb=1024,
on_build_logs=default_build_logger(),
)
9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ dependencies = [
aws = [
"aioboto3>=13",
]
e2b = [
"e2b>=2.52.0,<3",
]

[project.urls]
Homepage = "https://github.com/czpython/drukbox"
Expand All @@ -81,10 +84,10 @@ Issues = "https://github.com/czpython/drukbox/issues"

[dependency-groups]
dev = [
# aioboto3 is the optional `aws` extra, but the AWS tests import it at
# collection time. Keep it in the dev group so a bare `uv sync` runs the
# full suite and pyright without needing --all-extras.
# The optional extras: their tests import them at collection time, so a
# bare `uv sync` needs them to run the full suite and pyright.
"aioboto3>=13",
"e2b>=2.52.0,<3",
"pyright>=1.1",
"pytest>=8.0",
"pytest-asyncio>=0.25",
Expand Down
1 change: 1 addition & 0 deletions src/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import providers.daytona
import providers.docker
import providers.docker_sbx
import providers.e2b
import providers.exe
import providers.exoscale
import providers.hetzner
Expand Down
9 changes: 9 additions & 0 deletions src/providers/e2b/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
try:
import e2b # noqa: F401
except ImportError:
pass
else:
from providers.e2b.provider import E2BProvider
from providers.registry import register_vm_provider

register_vm_provider(E2BProvider)
93 changes: 93 additions & 0 deletions src/providers/e2b/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import shlex
from collections.abc import Iterator
from contextlib import contextmanager

import httpx
from e2b import AsyncSandbox, SandboxQuery
from e2b import exceptions as errors

from providers.exceptions import (
ProviderAuthError,
ProviderCommandError,
ProviderNotFoundError,
ProviderTransportError,
)

from .settings import E2BSettings


class E2BAPI:
def __init__(self, settings: E2BSettings) -> None:
self.settings = settings

async def create_sandbox(self, name: str, *, image: str, label: str) -> AsyncSandbox:
with self.request():
return await AsyncSandbox.create(
template=image,
timeout=self.settings.max_lifetime_seconds,
metadata={"name": name, "managed-by": label},
lifecycle={"on_timeout": "kill"},
network={"allow_public_traffic": False},
api_key=self.settings.api_key,
request_timeout=self.settings.api_timeout,
# A retried create can leave a second sandbox behind.
retries=0,
)

async def bootstrap(self, sandbox: AsyncSandbox, script: str) -> None:
# E2B's errors can quote the command and its output, and the command
# carries the Tailscale auth key.
try:
with self.request():
await sandbox.commands.run(
# eth0 has only a link-local address, which tailscaled reads
# as no network: `tailscale up` would wait forever.
"ip link set dummy0 up && ip addr replace 10.255.255.1/32 dev dummy0"
f" && bash -e -c {shlex.quote(script)}",
user="root",
timeout=120,
request_timeout=self.settings.api_timeout,
)
except ProviderCommandError:
raise ProviderCommandError("E2B sandbox bootstrap failed") from None

async def delete_sandbox(self, name: str, *, label: str) -> None:
with self.request():
sandboxes = await AsyncSandbox.list(
query=SandboxQuery(metadata={"name": name, "managed-by": label}),
api_key=self.settings.api_key,
request_timeout=self.settings.api_timeout,
).next_items()
if not sandboxes:
raise ProviderNotFoundError(f"E2B sandbox {name!r} was not found")
for sandbox in sandboxes:
await AsyncSandbox.kill(
sandbox.sandbox_id,
api_key=self.settings.api_key,
request_timeout=self.settings.api_timeout,
)

async def diagnose(self) -> str:
with self.request():
await AsyncSandbox.list(
limit=1, api_key=self.settings.api_key, request_timeout=self.settings.api_timeout
).next_items()
return "E2B API authentication succeeded"

@contextmanager
def request(self) -> Iterator[None]:
try:
yield
except errors.AuthenticationException:
raise ProviderAuthError("E2B API authentication failed") from None
except errors.NotFoundException:
raise ProviderNotFoundError("E2B resource was not found") from None
except (
errors.TimeoutException,
errors.RateLimitException,
errors.ServiceBusyException,
httpx.RequestError,
):
raise ProviderTransportError("E2B request failed") from None
except errors.SandboxException as exc:
raise ProviderCommandError(f"E2B rejected the request: {exc}") from None
68 changes: 68 additions & 0 deletions src/providers/e2b/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from datetime import timedelta
from typing import ClassVar, Self

from core.settings import get_settings
from providers import environment
from providers.base import VMCreateResult, VMProvider
from providers.exceptions import (
ProviderAuthError,
ProviderCommandError,
ProviderNotFoundError,
)

from .api import E2BAPI
from .settings import E2BSettings


class E2BProvider(VMProvider):
name: ClassVar[str] = "e2b"
diagnose_hint: ClassVar[str] = "check_e2b_and_tailscale_settings"

def __init__(self, api: E2BAPI, settings: E2BSettings, *, service_label: str) -> None:
self.api = api
self.settings = settings
self.service_label = service_label
self.max_lifetime = timedelta(seconds=settings.max_lifetime_seconds)

@classmethod
def from_settings(cls) -> Self:
settings = E2BSettings() # pyright: ignore[reportCallIssue]
return cls(E2BAPI(settings), settings, service_label=get_settings().service_label)

@property
def default_image(self) -> str:
return self.settings.default_image

@property
def bootstrap_ssh_timeout_seconds(self) -> float:
return self.settings.bootstrap_ssh_timeout_seconds

async def create_vm(
self,
*,
name: str,
image: str,
env: dict[str, str] | None = None,
setup_script: str | None = None,
instance_type: str | None = None,
disk_gb: int | None = None,
) -> VMCreateResult:
if not setup_script:
raise ProviderCommandError("E2B requires TAILSCALE_ENABLED=true")
try:
sandbox = await self.api.create_sandbox(name, image=image, label=self.service_label)
await self.api.bootstrap(sandbox, environment.get_cloud_init(setup_script, env))
except (ProviderAuthError, ProviderNotFoundError) as exc:
raise ProviderCommandError(str(exc)) from exc
return VMCreateResult(provider_id=sandbox.sandbox_id, name=name, ssh_username="root")

async def delete_vm(self, name: str) -> None:
await self.api.delete_sandbox(name, label=self.service_label)

async def diagnose(self) -> str:
if not get_settings().tailscale_enabled:
raise ProviderCommandError("E2B requires TAILSCALE_ENABLED=true")
return await self.api.diagnose()

async def aclose(self) -> None:
"""The E2B SDK owns its HTTP clients."""
20 changes: 20 additions & 0 deletions src/providers/e2b/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

from core.settings import get_secrets_dir


class E2BSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="E2B_",
extra="ignore",
hide_input_in_errors=True,
secrets_dir=get_secrets_dir(),
)

api_key: str
default_image: str
max_lifetime_seconds: int = Field(default=3600, gt=0)
api_timeout: float = Field(default=150, gt=0)
bootstrap_ssh_timeout_seconds: float = Field(default=120, gt=0)
Empty file.
Loading
Loading