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
79 changes: 79 additions & 0 deletions docs/daytona.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Daytona Linux VM sandboxes

Create a Daytona host through the existing API:

```json
{"provider": "daytona"}
```

The `image` field selects a prepared Daytona Linux VM snapshot name or ID.
The provider rejects container and Windows snapshots. CPU, memory, and disk
come from the snapshot. Per-host `instance_type` and `disk_gb` are not supported.

## Prepare the snapshot

Build and push the supplied image to a registry that Daytona can read:

```bash
docker build --platform linux/amd64 -t registry.example.com/team/drukbox-daytona:1 images/daytona
docker push registry.example.com/team/drukbox-daytona:1
```

Replace the example registry and image name. In Daytona, create a snapshot
from that image and select the **Linux VM** sandbox class. Wait for the
snapshot to become active in the target region. Use its name or ID as
`DAYTONA_DEFAULT_IMAGE`.

Daytona Linux VM snapshots require an existing registry image. Daytona's
Dockerfile builder does not build this snapshot class. The supplied image
includes Tailscale, Bash, jq, Git, GitHub CLI, sudo, and CA tools. For a custom
image, keep these tools and passwordless root access. Never snapshot a VM
that has joined Tailscale into a reusable image.

The account must have Linux VM capacity in the selected region. Daytona's
VPN guide requires Tier 3 or higher. Check those account requirements before
provisioning. Image builds, stored snapshots, and VMs use provider resources.

## Configure Drukbox

| Variable | Value |
| --- | --- |
| `DAYTONA_API_KEY` | Required API key; can use `/run/secrets/DAYTONA_API_KEY` |
| `DAYTONA_DEFAULT_IMAGE` | Required active Linux VM snapshot name or ID |
| `DAYTONA_TARGET` | Required target region with Linux VM capacity |
| `DAYTONA_API_TIMEOUT` | API request timeout in seconds; default `150` |
| `DAYTONA_LIFECYCLE_TIMEOUT_SECONDS` | Start or deletion polling budget; default `180` |
| `DAYTONA_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | SSH readiness budget; default `120` |
| `TAILSCALE_ENABLED` | Required: `true` |

Configure the tailnet and Tailscale credentials as described in
[Networking](networking.md). Hosts use Tailscale SSH as `root`; they have no
public SSH address. The adapter does not use Daytona's expiring SSH tokens.

Drukbox disables Daytona auto-stop, auto-pause, auto-delete, and wall-clock
TTL. It checks those settings before bootstrap. If an account policy forces
a TTL, provisioning fails and deletes the VM. Drukbox owns the lease; keep
the janitor running. Pause, resume, and template builds through Drukbox are
not supported by this adapter.

Bootstrap persists the environment and installs the secrets proxy CA. When
systemd is absent, Tailscale uses userspace networking. This permits inbound
SSH but adds no kernel routes for applications. The secrets proxy must be
reachable through the VM's normal network. HTTP previews stay private.

Drukbox tags the VM with `SERVICE_LABEL`, checks ownership before deletion,
and waits for destruction. If provider deletion fails, Drukbox keeps the
host record for a retry. Do not rename VMs, change their ownership label,
or change `SERVICE_LABEL` while hosts exist.

`/doctor` checks API authentication and the default snapshot class and state.
It does not provision a VM or prove Tailscale connectivity. Live provisioning
and SSH require a separate test with a Daytona account and configured tailnet.

## Sources

- [Daytona VM sandboxes](https://www.daytona.io/docs/en/sandboxes/)
- [Daytona snapshots](https://www.daytona.io/docs/en/snapshots/)
- [Daytona Tailscale support](https://www.daytona.io/docs/en/vpn-connections/)
- [Daytona REST API](https://www.daytona.io/docs/openapi.json)
- [Daytona command API](https://www.daytona.io/docs/toolbox-openapi.json)
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 |
| --- | --- | --- |
| `daytona` | Daytona Linux VMs | Remote |
| `exe` | exe.dev VMs | Remote |
| `aws` | EC2 instances | Remote |
| `hetzner` | Hetzner Cloud VMs | Remote |
Expand All @@ -110,8 +111,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 @@ -686,7 +687,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 @@ -708,3 +709,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.

## Daytona

See [Daytona setup](daytona.md) for image preparation, all provider settings,
Tailscale SSH, and lifecycle limits.
8 changes: 8 additions & 0 deletions images/daytona/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM tailscale/tailscale:v1.102.5 AS tailscale
FROM ubuntu:24.04

RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates sudo jq git gh openssh-server \
&& rm -rf /var/lib/apt/lists/*

COPY --from=tailscale /usr/local/bin/tailscale /usr/local/bin/tailscaled /usr/local/bin/
35 changes: 16 additions & 19 deletions src/hosts/scripts/sandbox_bootstrap.sh
Original file line number Diff line number Diff line change
@@ -1,19 +1,4 @@
#!/usr/bin/env bash
# Sandbox host first-boot bootstrap. Delivered to the VM at create time via the
# VM provider's setup-script mechanism (exe.dev's --setup-script today).
#
# The VM brings itself onto the tailnet and exits. Drukbox observes the new
# device by polling Tailscale's API from outside; no callback into drukbox
# is made from this script, and drukbox is never reachable from the box.
#
# Required env:
# TAILSCALE_AUTHKEY
# TAILSCALE_HOSTNAME
#
# Optional env:
# TAILSCALE_ADVERTISE_TAGS (default: tag:sandbox)
# TAILSCALE_LOGIN_SERVER (default: unset)

set -euo pipefail

state_dir=/var/lib/sandbox
Expand All @@ -35,9 +20,6 @@ run_privileged() {

run_privileged install -d -m 755 -o "$(id -u)" -g "$(id -g)" "$state_dir"

# Defensive against re-runs: --setup-script is documented as run-once, and the
# legacy in-image systemd unit (if present on transitional images) also gates on
# this flag. Either path converges on the same end state.
if [[ -f "$done_path" ]]; then
exit 0
fi
Expand All @@ -54,7 +36,22 @@ require_var TAILSCALE_HOSTNAME

advertise_tags="${TAILSCALE_ADVERTISE_TAGS:-tag:sandbox}"

run_privileged systemctl enable --now tailscaled.service
if [[ -d /run/systemd/system ]]; then
run_privileged systemctl enable --now tailscaled.service
else
run_privileged install -d -m 755 /var/run/tailscale
run_privileged sh -c 'nohup tailscaled --tun=userspace-networking --state=mem: </dev/null >/var/log/tailscaled.log 2>&1 &'
for attempt in {1..60}; do
if [[ -S /var/run/tailscale/tailscaled.sock ]]; then
break
fi
sleep 0.5
done
if [[ ! -S /var/run/tailscale/tailscaled.sock ]]; then
echo "Tailscale did not create its control socket." >&2
exit 1
fi
fi

tailscale_running() {
tailscale status --json 2>/dev/null | jq -e '.BackendState == "Running"' >/dev/null
Expand Down
71 changes: 71 additions & 0 deletions src/hosts/tests/test_userspace_bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import asyncio
import os
import sys
from collections.abc import Iterator
from pathlib import Path
from tempfile import TemporaryDirectory

import pytest

from hosts.service import _SANDBOX_BOOTSTRAP_SCRIPT


@pytest.fixture
def tmp_path() -> Iterator[Path]:
with TemporaryDirectory(prefix="dbx-", dir="/tmp") as directory:
yield Path(directory)


@pytest.mark.parametrize("systemd", [False, True])
async def test_bootstrap_starts_tailscale_and_enables_ssh(tmp_path: Path, systemd: bool):
commands = tmp_path / "bin"
commands.mkdir()
log = tmp_path / "commands.log"
socket_path = tmp_path / "var/run/tailscale/tailscaled.sock"
scripts = {
"sudo": '#!/bin/sh\nshift\nexec "$@"\n',
"systemctl": f'#!/bin/sh\necho "systemctl $*" >> {log}\n',
"tailscale": f'#!/bin/sh\necho "tailscale $*" >> {log}\n',
"jq": "#!/bin/sh\ncat >/dev/null\nexit 1\n",
"tailscaled": (
f"#!{sys.executable}\nimport socket, sys\n"
f"with open({str(log)!r}, 'a') as log:\n"
" log.write('tailscaled ' + ' '.join(sys.argv[1:]) + '\\n')\n"
f"connection = socket.socket(socket.AF_UNIX)\nconnection.bind({str(socket_path)!r})\n"
),
}
for name, contents in scripts.items():
path = commands / name
path.write_text(contents)
path.chmod(0o755)
(tmp_path / "var/log").mkdir(parents=True)
if systemd:
(tmp_path / "run/systemd/system").mkdir(parents=True)
script = _SANDBOX_BOOTSTRAP_SCRIPT.replace("/var/", f"{tmp_path}/var/").replace(
"[[ -d /run/systemd/system ]]", f"[[ -d {tmp_path}/run/systemd/system ]]"
)
process = await asyncio.create_subprocess_exec(
"bash",
"-c",
script,
env={
**os.environ,
"PATH": f"{commands}:{os.environ['PATH']}",
"TAILSCALE_AUTHKEY": "test-key",
"TAILSCALE_HOSTNAME": "sb-test",
},
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10)
assert process.returncode == 0, (stdout, stderr)
operations = log.read_text()
assert "--ssh" in operations
assert "--hostname=sb-test" in operations
assert (tmp_path / "var/lib/sandbox/bootstrap.done").exists()
if systemd:
assert "systemctl enable --now tailscaled.service" in operations
assert "--tun=userspace-networking" not in operations
else:
assert "--tun=userspace-networking --state=mem:" in operations
assert "systemctl" not in operations
1 change: 1 addition & 0 deletions src/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import providers.aws
import providers.daytona
import providers.docker
import providers.docker_sbx
import providers.exe
Expand Down
4 changes: 4 additions & 0 deletions src/providers/daytona/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from providers.daytona.provider import DaytonaProvider
from providers.registry import register_vm_provider

register_vm_provider(DaytonaProvider)
Loading
Loading