diff --git a/docs/daytona.md b/docs/daytona.md new file mode 100644 index 0000000..b3b12e0 --- /dev/null +++ b/docs/daytona.md @@ -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) diff --git a/docs/deploy.md b/docs/deploy.md index e537ca7..6dd56ef 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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 | @@ -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: @@ -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 @@ -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. diff --git a/images/daytona/Dockerfile b/images/daytona/Dockerfile new file mode 100644 index 0000000..0b94880 --- /dev/null +++ b/images/daytona/Dockerfile @@ -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/ diff --git a/src/hosts/scripts/sandbox_bootstrap.sh b/src/hosts/scripts/sandbox_bootstrap.sh index b7aa0bb..05e3fbd 100644 --- a/src/hosts/scripts/sandbox_bootstrap.sh +++ b/src/hosts/scripts/sandbox_bootstrap.sh @@ -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 @@ -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 @@ -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: /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 diff --git a/src/hosts/tests/test_userspace_bootstrap.py b/src/hosts/tests/test_userspace_bootstrap.py new file mode 100644 index 0000000..cc048c9 --- /dev/null +++ b/src/hosts/tests/test_userspace_bootstrap.py @@ -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 diff --git a/src/providers/__init__.py b/src/providers/__init__.py index 4091128..1d7d9b2 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -1,4 +1,5 @@ import providers.aws +import providers.daytona import providers.docker import providers.docker_sbx import providers.exe diff --git a/src/providers/daytona/__init__.py b/src/providers/daytona/__init__.py new file mode 100644 index 0000000..37ab1f2 --- /dev/null +++ b/src/providers/daytona/__init__.py @@ -0,0 +1,4 @@ +from providers.daytona.provider import DaytonaProvider +from providers.registry import register_vm_provider + +register_vm_provider(DaytonaProvider) diff --git a/src/providers/daytona/api.py b/src/providers/daytona/api.py new file mode 100644 index 0000000..c1df64e --- /dev/null +++ b/src/providers/daytona/api.py @@ -0,0 +1,184 @@ +import asyncio +import shlex +from typing import Any +from urllib.parse import quote, urlsplit + +import httpx + +from providers.exceptions import ( + ProviderAuthError, + ProviderCommandError, + ProviderNotFoundError, + ProviderTransportError, +) + +from .settings import DaytonaSettings + + +class DaytonaAPI: + def __init__(self, settings: DaytonaSettings) -> None: + self.settings = settings + self.client = httpx.AsyncClient( + base_url="https://app.daytona.io/api/", + headers={"Authorization": f"Bearer {settings.api_key}"}, + timeout=httpx.Timeout(settings.api_timeout, connect=5), + ) + + async def check_snapshot(self, image: str) -> None: + snapshot = await self.request("GET", f"snapshots/{quote(image, safe='')}") + + if snapshot.get("sandboxClass") != "linux-vm": + raise ProviderCommandError("Daytona requires a Linux VM snapshot") + if snapshot.get("state") != "active": + raise ProviderCommandError("Daytona snapshot is not active") + + async def create_sandbox(self, name: str, *, image: str, label: str) -> None: + await self.request( + "POST", + "sandbox", + json={ + "name": name, + "snapshot": image, + "target": self.settings.target, + "user": "root", + "labels": {"managed-by": label}, + "public": False, + "autoStopInterval": 0, + "autoPauseInterval": 0, + "autoDeleteInterval": -1, + "ttlMinutes": 0, + }, + ) + + async def get_sandbox(self, name: str, *, label: str) -> dict[str, Any]: + sandbox = await self.request("GET", f"sandbox/{quote(name, safe='')}") + labels = sandbox.get("labels") + + if ( + sandbox.get("name") != name + or not isinstance(labels, dict) + or labels.get("managed-by") != label + ): + raise ProviderCommandError("Daytona sandbox does not belong to this deployment") + if not isinstance(sandbox.get("id"), str) or not sandbox["id"]: + raise ProviderTransportError("Invalid Daytona sandbox ID") + return sandbox + + async def wait_for_started(self, name: str, *, label: str) -> dict[str, Any]: + try: + async with asyncio.timeout(self.settings.lifecycle_timeout_seconds): + while True: + sandbox = await self.get_sandbox(name, label=label) + + if sandbox.get("state") == "started": + if ( + sandbox.get("sandboxClass") != "linux-vm" + or sandbox.get("autoStopInterval") != 0 + or sandbox.get("autoPauseInterval") != 0 + or sandbox.get("autoDeleteInterval") != -1 + or sandbox.get("autoDestroyAt") + ): + raise ProviderCommandError( + "Daytona requires a Linux VM with automatic lifecycle disabled" + ) + return sandbox + if sandbox.get("state") not in { + "creating", + "starting", + "pending_build", + "pulling_snapshot", + }: + raise ProviderTransportError("Daytona sandbox did not start") + await asyncio.sleep(1) + except TimeoutError as exc: + raise ProviderTransportError("Daytona sandbox start timed out") from exc + + async def bootstrap(self, sandbox: dict[str, Any], script: str) -> None: + proxy = sandbox.get("toolboxProxyUrl") + + if not isinstance(proxy, str): + raise ProviderTransportError("Missing Daytona toolbox URL") + try: + url = urlsplit(proxy) + port = url.port + except ValueError: + raise ProviderTransportError("Invalid Daytona toolbox URL") from None + if ( + url.scheme != "https" + or not url.hostname + or not url.hostname.endswith(".daytona.io") + or url.username + or url.password + or url.query + or url.fragment + or port not in {None, 443} + ): + raise ProviderTransportError("Invalid Daytona toolbox URL") + result = await self.request( + "POST", + f"{proxy.rstrip('/')}/{quote(sandbox['id'], safe='')}/process/execute", + json={"command": f"sudo -n bash -e -c {shlex.quote(script)}", "timeout": 120}, + ) + + if result.get("exitCode") != 0: + raise ProviderCommandError("Daytona sandbox bootstrap failed") + + async def delete_sandbox(self, name: str, *, label: str) -> None: + sandbox = await self.get_sandbox(name, label=label) + path = f"sandbox/{quote(sandbox['id'], safe='')}" + + if sandbox.get("state") == "destroyed": + return + await self.request("DELETE", path) + try: + async with asyncio.timeout(self.settings.lifecycle_timeout_seconds): + while True: + try: + sandbox = await self.request("GET", path) + except ProviderNotFoundError: + return + if sandbox.get("state") == "destroyed": + return + if sandbox.get("state") == "error": + raise ProviderTransportError("Daytona sandbox deletion failed") + await asyncio.sleep(1) + except TimeoutError as exc: + raise ProviderTransportError("Daytona sandbox deletion timed out") from exc + + async def diagnose(self) -> str: + await self.request("GET", "sandbox", params={"limit": "1"}) + await self.check_snapshot(self.settings.default_image) + return "Daytona API authentication succeeded; Linux VM snapshot is active" + + async def aclose(self) -> None: + await self.client.aclose() + + async def request( + self, + method: str, + path: str, + *, + json: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + ) -> dict[str, Any]: + try: + response = await self.client.request(method, path, json=json, params=params) + except httpx.RequestError as exc: + raise ProviderTransportError("Daytona API transport failed") from exc + if response.status_code in {401, 403}: + raise ProviderAuthError("Daytona API authentication failed") + if response.status_code == 404: + raise ProviderNotFoundError("Daytona resource was not found") + if response.status_code >= 500 or response.status_code == 429: + raise ProviderTransportError(f"Daytona API returned HTTP {response.status_code}") + if not 200 <= response.status_code < 300: + raise ProviderCommandError(f"Daytona API rejected request: HTTP {response.status_code}") + if response.status_code == 204: + return {} + try: + data = response.json() + except ValueError as exc: + raise ProviderTransportError("Invalid Daytona API response") from exc + if not isinstance(data, dict): + raise ProviderTransportError("Invalid Daytona API response") + return data diff --git a/src/providers/daytona/provider.py b/src/providers/daytona/provider.py new file mode 100644 index 0000000..fc979e6 --- /dev/null +++ b/src/providers/daytona/provider.py @@ -0,0 +1,92 @@ +import asyncio +import contextlib +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 DaytonaAPI +from .settings import DaytonaSettings + + +class DaytonaProvider(VMProvider): + name: ClassVar[str] = "daytona" + diagnose_hint: ClassVar[str] = "check_daytona_snapshot_and_tailscale_settings" + + def __init__(self, api: DaytonaAPI, settings: DaytonaSettings, *, service_label: str) -> None: + self.api = api + self.settings = settings + self.service_label = service_label + + @classmethod + def from_settings(cls) -> Self: + settings = DaytonaSettings() # pyright: ignore[reportCallIssue] + return cls(DaytonaAPI(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("Daytona requires TAILSCALE_ENABLED=true") + try: + script = environment.get_cloud_init(setup_script, env) + except ValueError as exc: + raise ProviderCommandError("Invalid Daytona environment") from exc + try: + await self.api.check_snapshot(image) + except (ProviderAuthError, ProviderNotFoundError) as exc: + raise ProviderCommandError("Daytona API key or snapshot was rejected") from exc + try: + await self.api.create_sandbox(name, image=image, label=self.service_label) + except (ProviderAuthError, ProviderNotFoundError) as exc: + raise ProviderCommandError("Daytona API key or snapshot was rejected") from exc + except (ProviderTransportError, asyncio.CancelledError): + with contextlib.suppress(ProviderError): + await self.delete_vm(name) + raise + try: + sandbox = await self.api.wait_for_started(name, label=self.service_label) + 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("Daytona sandbox bootstrap failed") from exc + return VMCreateResult(provider_id=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("Daytona requires TAILSCALE_ENABLED=true") + return await self.api.diagnose() + + async def aclose(self) -> None: + await self.api.aclose() diff --git a/src/providers/daytona/settings.py b/src/providers/daytona/settings.py new file mode 100644 index 0000000..bfca889 --- /dev/null +++ b/src/providers/daytona/settings.py @@ -0,0 +1,21 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from core.settings import get_secrets_dir + + +class DaytonaSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_prefix="DAYTONA_", + extra="ignore", + hide_input_in_errors=True, + secrets_dir=get_secrets_dir(), + ) + + api_key: str + default_image: str + target: str + api_timeout: float = Field(default=150, gt=0) + lifecycle_timeout_seconds: float = Field(default=180, gt=0) + bootstrap_ssh_timeout_seconds: float = Field(default=120, gt=0) diff --git a/src/providers/daytona/tests/__init__.py b/src/providers/daytona/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/providers/daytona/tests/test_provider.py b/src/providers/daytona/tests/test_provider.py new file mode 100644 index 0000000..769e165 --- /dev/null +++ b/src/providers/daytona/tests/test_provider.py @@ -0,0 +1,281 @@ +import asyncio +import json +import traceback +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +import respx + +from providers.daytona.api import DaytonaAPI +from providers.daytona.provider import DaytonaProvider +from providers.daytona.settings import DaytonaSettings +from providers.exceptions import ( + ProviderAuthError, + ProviderCommandError, + ProviderNotFoundError, + ProviderTransportError, +) +from providers.registry import get_provider_names + +BASE = "https://app.daytona.io/api/" +TOOLBOX = "https://proxy.app.daytona.io/toolbox" +COMMAND = TOOLBOX + "/sandbox-1/process/execute" +SNAPSHOT = {"sandboxClass": "linux-vm", "state": "active"} +SANDBOX = { + "id": "sandbox-1", + "name": "sb-test", + "labels": {"managed-by": "drukbox"}, + "state": "started", + "sandboxClass": "linux-vm", + "autoStopInterval": 0, + "autoPauseInterval": 0, + "autoDeleteInterval": -1, + "toolboxProxyUrl": TOOLBOX, +} + + +@pytest.fixture +async def provider() -> AsyncIterator[DaytonaProvider]: + settings = DaytonaSettings(api_key="test-key", default_image="drukbox-vm", target="eu") + provider = DaytonaProvider(DaytonaAPI(settings), settings, service_label="drukbox") + yield provider + await provider.aclose() + + +@respx.mock +async def test_create_uses_linux_vm_snapshot_and_bootstraps_tailnet(provider: DaytonaProvider): + snapshot = respx.get(BASE + "snapshots/custom-vm").respond(200, json=SNAPSHOT) + creation = respx.post(BASE + "sandbox").respond(200, json={**SANDBOX, "state": "creating"}) + respx.get(BASE + "sandbox/sb-test").mock( + side_effect=[ + httpx.Response(200, json={**SANDBOX, "state": "starting"}), + httpx.Response(200, json=SANDBOX), + ] + ) + command = respx.post(COMMAND).respond(200, json={"exitCode": 0, "result": ""}) + with patch("providers.daytona.api.asyncio.sleep", new_callable=AsyncMock): + result = await provider.create_vm( + name="sb-test", + image="custom-vm", + setup_script="true", + env={"FOO": "hello world", "SECRETS_PROXY_CA": "Y2VydA==", "GH_TOKEN": "placeholder"}, + ) + assert snapshot.called + request = creation.calls[0].request + assert request.headers["Authorization"] == "Bearer test-key" + assert json.loads(request.content) == { + "name": "sb-test", + "snapshot": "custom-vm", + "target": "eu", + "user": "root", + "labels": {"managed-by": "drukbox"}, + "public": False, + "autoStopInterval": 0, + "autoPauseInterval": 0, + "autoDeleteInterval": -1, + "ttlMinutes": 0, + } + body = json.loads(command.calls[0].request.content) + assert body["timeout"] == 120 + assert body["command"].startswith("sudo -n bash -e -c ") + assert "/etc/environment" in body["command"] + assert "update-ca-certificates" in body["command"] + assert "gh auth git-credential" in body["command"] + assert result.provider_id == "sandbox-1" + assert result.name == "sb-test" + assert result.ssh_username == "root" + assert not result.ssh_host + + +@respx.mock +async def test_requires_tailscale_before_api_calls(provider: DaytonaProvider): + with pytest.raises(ProviderCommandError, match="TAILSCALE_ENABLED"): + await provider.create_vm(name="sb-test", image="snapshot") + assert not respx.calls + + +@respx.mock +@pytest.mark.parametrize( + "snapshot", + [ + {"sandboxClass": "container", "state": "active"}, + {"sandboxClass": "windows", "state": "active"}, + {"sandboxClass": "linux-vm", "state": "building"}, + ], +) +async def test_rejects_unusable_snapshot_before_creation(provider: DaytonaProvider, snapshot: dict): + respx.get(BASE + "snapshots/snapshot").respond(200, json=snapshot) + with pytest.raises(ProviderCommandError): + await provider.create_vm(name="sb-test", image="snapshot", setup_script="true") + assert len(respx.calls) == 1 + + +@respx.mock +@pytest.mark.parametrize("status", [400, 401, 403, 404, 409]) +async def test_rejected_create_does_not_delete_existing_vm(provider: DaytonaProvider, status: int): + respx.get(BASE + "snapshots/snapshot").respond(200, json=SNAPSHOT) + creation = respx.post(BASE + "sandbox").respond(status, text="sensitive data") + with pytest.raises(ProviderCommandError) as error: + await provider.create_vm(name="sb-test", image="snapshot", setup_script="true") + assert "sensitive data" not in "".join(traceback.format_exception(error.value)) + assert creation.call_count == 1 + assert len(respx.calls) == 2 + + +@respx.mock +async def test_lost_create_response_cleans_up_by_owned_name(provider: DaytonaProvider): + respx.get(BASE + "snapshots/snapshot").respond(200, json=SNAPSHOT) + creation = respx.post(BASE + "sandbox").mock(side_effect=httpx.ReadTimeout("lost")) + respx.get(BASE + "sandbox/sb-test").respond(200, json=SANDBOX) + deletion = respx.delete(BASE + "sandbox/sandbox-1").respond(200, json=SANDBOX) + respx.get(BASE + "sandbox/sandbox-1").respond(404) + with pytest.raises(ProviderTransportError): + await provider.create_vm(name="sb-test", image="snapshot", setup_script="true") + assert creation.call_count == 1 + assert deletion.called + + +@respx.mock +@pytest.mark.parametrize( + "change", + [ + {"sandboxClass": "container"}, + {"autoPauseInterval": 60}, + {"autoStopInterval": 15}, + {"autoDeleteInterval": 0}, + {"autoDestroyAt": "2026-10-05T12:00:00Z"}, + ], +) +async def test_unexpected_vm_policy_is_cleaned_up(provider: DaytonaProvider, change: dict): + respx.get(BASE + "snapshots/snapshot").respond(200, json=SNAPSHOT) + respx.post(BASE + "sandbox").respond(200, json=SANDBOX) + respx.get(BASE + "sandbox/sb-test").respond(200, json={**SANDBOX, **change}) + deletion = respx.delete(BASE + "sandbox/sandbox-1").respond(200, json=SANDBOX) + respx.get(BASE + "sandbox/sandbox-1").respond(404) + with pytest.raises(ProviderTransportError, match="bootstrap failed"): + await provider.create_vm(name="sb-test", image="snapshot", setup_script="true") + assert deletion.called + + +@respx.mock +@pytest.mark.parametrize( + "command_response", + [ + httpx.Response(200, json={"exitCode": 1, "result": "secret output"}), + httpx.Response(200, json={"result": "secret output"}), + httpx.Response(503, text="secret output"), + ], +) +async def test_failed_bootstrap_cleans_up_without_exposing_output( + provider: DaytonaProvider, + command_response: httpx.Response, +): + respx.get(BASE + "snapshots/snapshot").respond(200, json=SNAPSHOT) + respx.post(BASE + "sandbox").respond(200, json=SANDBOX) + respx.get(BASE + "sandbox/sb-test").respond(200, json=SANDBOX) + respx.post(COMMAND).mock(return_value=command_response) + deletion = respx.delete(BASE + "sandbox/sandbox-1").respond(200, json=SANDBOX) + respx.get(BASE + "sandbox/sandbox-1").respond(404) + with pytest.raises(ProviderTransportError) as error: + await provider.create_vm(name="sb-test", image="snapshot", setup_script="true") + assert "secret output" not in "".join(traceback.format_exception(error.value)) + assert deletion.called + + +@respx.mock +async def test_cancelled_bootstrap_cleans_up(provider: DaytonaProvider): + respx.get(BASE + "snapshots/snapshot").respond(200, json=SNAPSHOT) + respx.post(BASE + "sandbox").respond(200, json=SANDBOX) + respx.get(BASE + "sandbox/sb-test").respond(200, json=SANDBOX) + deletion = respx.delete(BASE + "sandbox/sandbox-1").respond(200, json=SANDBOX) + respx.get(BASE + "sandbox/sandbox-1").respond(404) + with ( + patch.object( + provider.api, "bootstrap", new_callable=AsyncMock, side_effect=asyncio.CancelledError() + ), + pytest.raises(asyncio.CancelledError), + ): + await provider.create_vm(name="sb-test", image="snapshot", setup_script="true") + assert deletion.called + + +@respx.mock +async def test_deletion_waits_for_destroyed(provider: DaytonaProvider): + respx.get(BASE + "sandbox/sb-test").respond(200, json=SANDBOX) + respx.delete(BASE + "sandbox/sandbox-1").respond(200, json=SANDBOX) + polling = respx.get(BASE + "sandbox/sandbox-1").mock( + side_effect=[ + httpx.Response(200, json={"state": "destroying"}), + httpx.Response(200, json={"state": "destroyed"}), + ] + ) + with patch("providers.daytona.api.asyncio.sleep", new_callable=AsyncMock): + await provider.delete_vm("sb-test") + assert polling.call_count == 2 + + +@respx.mock +async def test_foreign_sandbox_is_never_deleted(provider: DaytonaProvider): + respx.get(BASE + "sandbox/sb-test").respond( + 200, json={**SANDBOX, "labels": {"managed-by": "someone-else"}} + ) + with pytest.raises(ProviderCommandError, match="does not belong"): + await provider.delete_vm("sb-test") + assert len(respx.calls) == 1 + + +@respx.mock +async def test_missing_sandbox_translates_not_found(provider: DaytonaProvider): + respx.get(BASE + "sandbox/sb-test").respond(404) + with pytest.raises(ProviderNotFoundError): + await provider.delete_vm("sb-test") + + +@respx.mock +@pytest.mark.parametrize( + "url", ["https://evil.test/toolbox", "http://proxy.app.daytona.io/toolbox"] +) +async def test_toolbox_cannot_send_credentials_to_another_host(provider: DaytonaProvider, url: str): + with pytest.raises(ProviderTransportError, match="toolbox URL"): + await provider.api.bootstrap({**SANDBOX, "toolboxProxyUrl": url}, "true") + assert not respx.calls + + +@respx.mock +async def test_diagnose_checks_authentication_and_snapshot(provider: DaytonaProvider): + listing = respx.get(BASE + "sandbox").respond(200, json={"items": []}) + respx.get(BASE + "snapshots/drukbox-vm").respond(200, json=SNAPSHOT) + assert "Linux VM snapshot is active" in await provider.diagnose() + assert listing.calls[0].request.url.params["limit"] == "1" + listing.respond(401) + with pytest.raises(ProviderAuthError): + await provider.diagnose() + + +def test_provider_is_registered(): + assert "daytona" in get_provider_names() + + +@respx.mock +async def test_deletion_timeout_is_retryable(provider: DaytonaProvider): + provider.settings.lifecycle_timeout_seconds = 0.01 + respx.get(BASE + "sandbox/sb-test").respond(200, json=SANDBOX) + respx.delete(BASE + "sandbox/sandbox-1").respond(200, json=SANDBOX) + respx.get(BASE + "sandbox/sandbox-1").respond(200, json={"state": "destroying"}) + with pytest.raises(ProviderTransportError, match="deletion timed out"): + await provider.delete_vm("sb-test") + + +@respx.mock +async def test_start_timeout_cleans_up(provider: DaytonaProvider): + provider.settings.lifecycle_timeout_seconds = 0.01 + respx.get(BASE + "snapshots/snapshot").respond(200, json=SNAPSHOT) + respx.post(BASE + "sandbox").respond(200, json=SANDBOX) + respx.get(BASE + "sandbox/sb-test").respond(200, json={**SANDBOX, "state": "starting"}) + deletion = respx.delete(BASE + "sandbox/sandbox-1").respond(200, json=SANDBOX) + respx.get(BASE + "sandbox/sandbox-1").respond(404) + with pytest.raises(ProviderTransportError, match="bootstrap failed"): + await provider.create_vm(name="sb-test", image="snapshot", setup_script="true") + assert deletion.called