Skip to content
Open
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
12 changes: 9 additions & 3 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ account token returns `503`. See [API](api.md#service-accounts).

| Provider | Sandboxes | Where |
| --- | --- | --- |
| `e2b` | E2B VMs | Remote |
| `exe` | exe.dev VMs | Remote |
| `aws` | EC2 instances | Remote |
| `hetzner` | Hetzner Cloud VMs | Remote |
Expand All @@ -111,8 +112,8 @@ all provider extras.
## Local sandboxes with Docker

The `docker` provider runs each sandbox as a local container with sshd,
so you can try drukbox with no cloud account or API token. The published
image includes the Docker CLI. Set `DEFAULT_HOST_PROVIDER=docker`,
so you can try drukbox with no cloud account or API token.
Set `DEFAULT_HOST_PROVIDER=docker`,
`TAILSCALE_ENABLED=false`, and `UVICORN_HOST=127.0.0.1` in `drukbox.env`,
then run:

Expand Down Expand Up @@ -687,7 +688,7 @@ Docker provider:
| `DOCKER_SSH_USERNAME` | `root` | In-container user callers SSH as. The entrypoint seeds its `authorized_keys`; a derived image adds the user. |
| `DOCKER_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `30.0` | ssh-keyscan retry budget for a fresh container. |

The published image includes the Docker CLI. Mount the local daemon socket
Mount the local daemon socket
with its supplemental group on Linux, or use `DOCKER_HOST` for a remote or
rootless daemon. Drukbox mints a per-VM ed25519 key and publishes sshd on a
random port at `DOCKER_SSH_HOST`. See
Expand All @@ -709,3 +710,8 @@ The published image does not contain the `sbx` CLI. Mount the binary and
the sbx directories of the host, as
[Local microVMs with Docker Sandboxes](#local-microvms-with-docker-sandboxes)
shows.

## E2B

See [E2B setup](e2b.md) for image preparation, all provider settings,
Tailscale SSH, and lifecycle limits.
78 changes: 78 additions & 0 deletions docs/e2b.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# E2B sandboxes

Create an E2B host through the existing API:

```json
{"provider": "e2b"}
```

The `image` field selects an E2B template name or ID. The provider requires
Tailscale SSH. It does not return a public SSH address or a private key.
CPU, memory, and disk are set by the template and E2B account limits.
Per-host `instance_type` and `disk_gb` are not supported.

## Prepare the template

Install the optional provider and build its Ubuntu template:

```bash
uv sync --extra e2b
uv run images/e2b/build.py --name drukbox
```

Set `E2B_API_KEY` in the build process environment. The build creates a
template in that E2B account and uses E2B build resources.
It installs Tailscale, Bash, jq, Git, GitHub CLI, and CA tools.
It does not join the template to a tailnet.

Use the returned template name or ID as `E2B_DEFAULT_IMAGE`.
For a custom template, keep these tools and root command access. Never
snapshot a host that has joined Tailscale into a reusable template.

## Configure Drukbox

| Variable | Value |
| --- | --- |
| `E2B_API_KEY` | Required account API key; can use `/run/secrets/E2B_API_KEY` |
| `E2B_DEFAULT_IMAGE` | Required prepared template name or ID |
| `E2B_SESSION_TIMEOUT_SECONDS` | VM lifetime; default `3600`, range `600`–`86400` |
| `E2B_API_TIMEOUT` | API request timeout in seconds; default `150` |
| `E2B_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | SSH readiness budget; default `120` |
| `TAILSCALE_ENABLED` | Required: `true` |

Configure the Tailscale credentials and tailnet as described in
[Networking](networking.md). The service image includes the E2B extra.

E2B permits up to one continuous hour on Base and 24 hours on Pro.
Set the lifetime within the account limit. Drukbox stores a lease deadline
60 seconds before this lifetime ends. Default leases and pool leases stay
within that deadline. Permanent leases and explicit leases beyond it fail.
Renewal cannot move the stored deadline. Changing the setting affects new
hosts only.

Drukbox explicitly disables automatic pause and resume. E2B kills the VM
when its timeout ends. Drukbox deletes it earlier when its lease ends or a
caller deletes it. Keep the janitor running for DB and Tailscale cleanup.

Bootstrap writes the host environment, installs the secrets proxy CA, and
joins Tailscale. Without systemd, Tailscale uses userspace networking. This
permits inbound Tailscale SSH but adds no kernel routes for applications.
The secrets proxy must be reachable through the VM's normal network.
Public E2B ingress is disabled.

Deletion finds all running and paused VMs with the host name and
`SERVICE_LABEL` in their metadata. Do not change those metadata fields or
`SERVICE_LABEL` while hosts exist. A failed bootstrap triggers cleanup;
failed cleanup leaves the host record for the janitor or a deletion retry.
The adapter does not retry create requests after an uncertain response.

`/doctor` checks API authentication. It does not provision a VM or prove
Tailscale connectivity. Live provisioning and SSH require a separate test
with an E2B account and a configured tailnet.

## Sources

- [E2B lifecycle and runtime limits](https://docs.e2b.dev/sandbox)
- [E2B template images](https://docs.e2b.dev/template/base-image)
- [E2B SSH transport](https://docs.e2b.dev/sandbox/ssh-access)
- [E2B API reference](https://docs.e2b.dev/api-reference/sandboxes/create-sandbox-v2)
27 changes: 27 additions & 0 deletions images/e2b/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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-server"]
)
.run_cmd(
"curl -fsSL https://tailscale.com/install.sh -o /tmp/install-tailscale.sh"
" && sh /tmp/install-tailscale.sh && rm /tmp/install-tailscale.sh",
user="root",
)
)
Template.build(
template,
arguments.name,
cpu_count=2,
memory_mb=1024,
on_build_logs=default_build_logger(),
)
4 changes: 4 additions & 0 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 @@ -85,6 +88,7 @@ dev = [
# collection time. Keep it in the dev group so a bare `uv sync` runs the
# full suite and pyright without needing --all-extras.
"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
@@ -1,6 +1,7 @@
import providers.aws
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)
110 changes: 110 additions & 0 deletions src/providers/e2b/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import shlex
from collections.abc import Iterator
from contextlib import contextmanager

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

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

from .settings import E2BSettings


@contextmanager
def e2b_request() -> 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.InvalidArgumentException, CommandExitException):
raise ProviderCommandError("E2B rejected the request or bootstrap command") from None
except errors.SandboxException as exc:
if exc.status_code == 403:
raise ProviderAuthError("E2B API authentication failed") from None
if exc.status_code == 404:
raise ProviderNotFoundError("E2B resource was not found") from None
if exc.status_code and 400 <= exc.status_code < 500 and exc.status_code != 429:
raise ProviderCommandError("E2B API rejected the request") from None
raise ProviderTransportError("E2B request failed") from None
except (
errors.ServiceBusyException,
httpx.RequestError,
OSError,
ValueError,
KeyError,
TypeError,
):
raise ProviderTransportError("E2B request failed") from None


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

async def create_sandbox(self, name: str, *, image: str, label: str) -> AsyncSandbox:
with e2b_request():
return await AsyncSandbox.create(
template=image,
timeout=self.settings.session_timeout_seconds,
metadata={"name": name, "managed-by": label},
lifecycle={"on_timeout": "kill", "auto_resume": False},
network={"allow_public_traffic": False},
api_key=self.settings.api_key,
request_timeout=self.settings.api_timeout,
retries=0,
)

async def bootstrap(self, sandbox: AsyncSandbox, script: str) -> None:
with e2b_request():
await sandbox.commands.run(
f"bash -e -c {shlex.quote(script)}",
user="root",
timeout=120,
request_timeout=self.settings.api_timeout,
)

async def delete_sandbox(self, name: str, *, label: str) -> None:
metadata = {"name": name, "managed-by": label}

with e2b_request():
pages = AsyncSandbox.list(
query=SandboxQuery(
metadata=metadata, state=[SandboxState.RUNNING, SandboxState.PAUSED]
),
api_key=self.settings.api_key,
request_timeout=self.settings.api_timeout,
retries=0,
)
sandbox_ids: list[str] = []
while pages.has_next:
for sandbox in await pages.next_items():
if all(sandbox.metadata.get(key) == value for key, value in metadata.items()):
sandbox_ids.append(sandbox.sandbox_id)
if not sandbox_ids:
raise ProviderNotFoundError(f"E2B sandbox {name!r} was not found")
for sandbox_id in sandbox_ids:
await AsyncSandbox.kill(
sandbox_id,
api_key=self.settings.api_key,
request_timeout=self.settings.api_timeout,
retries=0,
)

async def diagnose(self) -> str:
with e2b_request():
pages = AsyncSandbox.list(
limit=1,
api_key=self.settings.api_key,
request_timeout=self.settings.api_timeout,
retries=0,
)
await pages.next_items()
return "E2B sandbox API authentication succeeded"
89 changes: 89 additions & 0 deletions src/providers/e2b/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import asyncio
import contextlib
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,
ProviderError,
ProviderNotFoundError,
ProviderTransportError,
)

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.session_timeout_seconds - 60)

@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:
script = environment.get_cloud_init(setup_script, env)
except ValueError as exc:
raise ProviderCommandError("Invalid E2B environment") from exc
try:
sandbox = await self.api.create_sandbox(name, image=image, label=self.service_label)
except (ProviderAuthError, ProviderNotFoundError) as exc:
raise ProviderCommandError("E2B API key or template was rejected") from exc
except (ProviderTransportError, asyncio.CancelledError):
with contextlib.suppress(ProviderError):
await self.delete_vm(name)
raise
try:
await self.api.bootstrap(sandbox, script)
except asyncio.CancelledError:
with contextlib.suppress(ProviderError):
await self.delete_vm(name)
raise
except ProviderError as exc:
with contextlib.suppress(ProviderError):
await self.delete_vm(name)
raise ProviderTransportError("E2B sandbox bootstrap failed") 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 shared HTTP clients."""
Loading
Loading