diff --git a/docs/boat.md b/docs/boat.md new file mode 100644 index 0000000..9707dc6 --- /dev/null +++ b/docs/boat.md @@ -0,0 +1,69 @@ +# Boat sandboxes + +`POST /hosts` with `{"provider": "boat"}` creates a Boat sandbox and returns +direct SSH coordinates. The response contains a per-host private key once. +The provider uses the public Boat HTTP API. It needs no Boat CLI or SDK. + +Boat supplies an Ubuntu VM with passwordless sudo. The provider uses this +external SSH path even when `TAILSCALE_ENABLED` is true. The provider does not +join the tailnet. The secrets proxy must accept connections from the sandbox. + +## Configuration + +Set `BOAT_API_TOKEN` to an API key with sandbox read, create, update, command, +SSH-key, and delete permissions. The provider also reads deployment secrets +from files through the standard settings loader. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `BOAT_API_TOKEN` | Required | Boat bearer token | +| `BOAT_API_URL` | `https://boat.dev/api/v1` | API base URL | +| `BOAT_DEFAULT_IMAGE` | `default` | Native Boat image, or a named Boat snapshot | +| `BOAT_INSTANCE_TYPE` | `default` | `small`, `default`, or `large` | +| `BOAT_API_TIMEOUT` | `30` | HTTP timeout in seconds | +| `BOAT_PROVISION_TIMEOUT` | `300` | Readiness timeout in seconds | +| `BOAT_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `120` | SSH keyscan timeout in seconds | + +`instance_type` in a host request overrides `BOAT_INSTANCE_TYPE`. +`image` selects a named Boat snapshot. The reserved value `default` selects +the native image. OCI images, custom disk sizes, and template builds are +unsupported. A named snapshot must retain Bash, sudo, SSH, and the standard +Ubuntu certificate tools. + +The provider sets `noEnv: true`. Boat account credentials, repositories, and +secret files do not enter the sandbox. Drukbox writes its environment through +the command API and installs the proxy CA before it returns the host. + +## Lifecycle + +The provider sets `ttlSeconds: null` and disables snapshots. Drukbox leases +and the janitor own host expiry. Provisioning fails if Boat imposes an archival +deadline, for example on a restricted account. + +The Boat display name is `SERVICE_LABEL:host-name`. Drukbox uses this exact +name for teardown and follows every page of the sandbox list. Keep +`SERVICE_LABEL` stable while hosts exist. + +Create requests use a stable idempotency key. The adapter retries transport +failures with the same body and key. If all attempts fail before Boat returns +an ID, reconcile the request in Boat before another allocation. Boat retains +idempotency keys for 24 hours. A failed provision attempts immediate deletion. +The error retains the resource ID when Boat returned one. + +Deletion submits the permanent-delete operation with the exact sandbox ID +in the confirmation header. Boat owns the accepted background purge. Archive +is not used, and no snapshot is retained for a new sandbox. A source named +snapshot remains independent. + +`GET /doctor` uses one read-only sandbox list request. + +## API evidence + +- [Boat API and idempotency](https://docs.boat.dev/api/v1) +- [Create sandbox](https://docs.boat.dev/api/reference/sandboxes/create-sandbox) +- [SSH key and coordinates](https://docs.boat.dev/api/reference/agent/configure-sandbox-ssh-key) +- [Permanent deletion](https://docs.boat.dev/api/reference/sandboxes/permanently-delete-sandbox-data) +- [Machine capabilities](https://docs.boat.dev/machines) + +Provider and HTTP tests use mocked responses. They do not prove live account +permissions, SSH connectivity, proxy access, or provider capacity. diff --git a/docs/deploy.md b/docs/deploy.md index e537ca7..81d2417 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -102,6 +102,7 @@ account token returns `503`. See [API](api.md#service-accounts). | `exoscale` | Exoscale VMs | 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 | `DEFAULT_HOST_PROVIDER` selects the provider for `POST /hosts` (default `exe`). Set the matching provider variables below. The image contains diff --git a/src/providers/__init__.py b/src/providers/__init__.py index 4091128..ac42a43 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -1,4 +1,5 @@ import providers.aws +import providers.boat import providers.docker import providers.docker_sbx import providers.exe diff --git a/src/providers/boat/__init__.py b/src/providers/boat/__init__.py new file mode 100644 index 0000000..91b01d7 --- /dev/null +++ b/src/providers/boat/__init__.py @@ -0,0 +1,4 @@ +from providers.boat.provider import BoatProvider +from providers.registry import register_vm_provider + +register_vm_provider(BoatProvider) diff --git a/src/providers/boat/api.py b/src/providers/boat/api.py new file mode 100644 index 0000000..03414c6 --- /dev/null +++ b/src/providers/boat/api.py @@ -0,0 +1,145 @@ +import asyncio +from typing import Any +from uuid import NAMESPACE_URL, uuid5 + +import httpx + +from .exceptions import ( + BoatAuthError, + BoatCommandError, + BoatNotFoundError, + BoatTransportError, +) +from .settings import BoatSettings + + +class BoatAPI: + def __init__(self, settings: BoatSettings) -> None: + self.settings = settings + self.client = httpx.AsyncClient( + base_url=settings.api_url.rstrip("/") + "/", + headers={"Authorization": f"Bearer {settings.api_token}"}, + timeout=httpx.Timeout(settings.api_timeout, connect=5), + ) + + async def create_sandbox(self, name: str, *, image: str, instance_type: str) -> str: + body: dict[str, Any] = { + "type": instance_type, + "ttlSeconds": None, + "noEnv": True, + "snapshots": False, + } + if image != "default": + body["from"] = image + for attempt in range(3): + try: + payload = await self.request( + "POST", + "sandboxes", + json=body, + headers={"Idempotency-Key": str(uuid5(NAMESPACE_URL, name))}, + ) + except BoatTransportError: + if attempt == 2: + raise + await asyncio.sleep(1) + else: + return payload["sandbox"]["id"] + raise AssertionError("creation attempts exhausted") + + async def name_sandbox(self, sandbox_id: str, name: str) -> None: + await self.request("PATCH", f"sandboxes/{sandbox_id}", json={"name": name}) + + async def wait_ready(self, sandbox_id: str) -> None: + try: + async with asyncio.timeout(self.settings.provision_timeout): + while True: + sandbox = (await self.request("GET", f"sandboxes/{sandbox_id}"))["sandbox"] + if sandbox["state"] in {"ready", "idle", "running"}: + if sandbox["archiveAfter"]: + raise BoatCommandError("Boat did not disable automatic archival") + return + if sandbox["state"] in {"error", "cancelled", "archived", "archiving"}: + raise BoatCommandError(f"Boat sandbox entered state {sandbox['state']}") + await asyncio.sleep(1) + except TimeoutError as exc: + raise BoatTransportError("Boat sandbox did not become ready") from exc + + async def configure_ssh(self, sandbox_id: str, public_key: str) -> dict[str, Any]: + return await self.request( + "POST", f"sandboxes/{sandbox_id}/sshkey", json={"key": public_key} + ) + + async def run_command(self, sandbox_id: str, command: str) -> None: + result = await self.request( + "POST", + f"sandboxes/{sandbox_id}/commands", + json={"command": command, "timeoutSeconds": 120}, + timeout=130, + ) + if result["timedOut"] or result["exitCode"] != 0 or not result["success"]: + raise BoatCommandError("Boat sandbox bootstrap failed") + + async def find_sandbox(self, name: str) -> str: + params = {"limit": "100"} + while True: + payload = await self.request("GET", "sandboxes", params=params) + for sandbox in payload["sandboxes"]: + if sandbox["name"] == name: + return sandbox["id"] + page = payload.get("pageInfo") + if not page or not page["hasMore"]: + raise BoatNotFoundError(f"Boat sandbox {name!r} was not found") + params["cursor"] = page["nextCursor"] + + async def delete_sandbox(self, sandbox_id: str) -> None: + # Boat owns the accepted purge even after it hides the sandbox from lookups. + await self.request( + "DELETE", + f"sandboxes/{sandbox_id}", + headers={"X-Ascii-Confirm-Delete": sandbox_id}, + ) + + async def diagnose(self) -> str: + await self.request("GET", "sandboxes", params={"limit": "1"}) + return "Boat API authentication and sandbox access succeeded" + + 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, + headers: dict[str, str] | None = None, + timeout: float | None = None, + ) -> dict[str, Any]: + try: + response = await self.client.request( + method, + path, + json=json, + params=params, + headers=headers, + timeout=timeout or self.settings.api_timeout, + ) + except httpx.RequestError as exc: + raise BoatTransportError("Boat API transport failed") from exc + if response.status_code in {401, 403}: + raise BoatAuthError("Boat API authentication or authorization failed") + if response.status_code == 404: + raise BoatNotFoundError("Boat resource was not found") + if response.status_code >= 500 or response.status_code == 429: + raise BoatTransportError(f"Boat API returned HTTP {response.status_code}") + if response.status_code >= 400: + raise BoatCommandError(f"Boat API rejected the request: HTTP {response.status_code}") + try: + payload = response.json() + except ValueError as exc: + raise BoatTransportError("Boat API returned invalid JSON") from exc + if not isinstance(payload, dict): + raise BoatTransportError("Boat API returned a non-object response") + return payload diff --git a/src/providers/boat/exceptions.py b/src/providers/boat/exceptions.py new file mode 100644 index 0000000..c9162a7 --- /dev/null +++ b/src/providers/boat/exceptions.py @@ -0,0 +1,22 @@ +from providers.exceptions import ( + ProviderAuthError, + ProviderCommandError, + ProviderError, + ProviderNotFoundError, + ProviderTransportError, +) + + +class BoatError(ProviderError): ... + + +class BoatAuthError(ProviderAuthError, BoatError): ... + + +class BoatCommandError(ProviderCommandError, BoatError): ... + + +class BoatNotFoundError(ProviderNotFoundError, BoatError): ... + + +class BoatTransportError(ProviderTransportError, BoatError): ... diff --git a/src/providers/boat/provider.py b/src/providers/boat/provider.py new file mode 100644 index 0000000..67da36d --- /dev/null +++ b/src/providers/boat/provider.py @@ -0,0 +1,101 @@ +import contextlib +import shlex +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 ProviderCommandError, ProviderTransportError +from providers.ssh_keys import generate_ed25519_keypair + +from .api import BoatAPI +from .exceptions import BoatError +from .settings import BoatSettings + + +class BoatProvider(VMProvider): + name: ClassVar[str] = "boat" + diagnose_hint: ClassVar[str] = "check_boat_api_token_and_sandbox_permissions" + supports_tailnet = False + supports_instance_type = True + + def __init__(self, api: BoatAPI, settings: BoatSettings, *, service_label: str) -> None: + self.api = api + self.settings = settings + self.service_label = service_label + + @classmethod + def from_settings(cls) -> Self: + settings = BoatSettings() # pyright: ignore[reportCallIssue] + return cls(BoatAPI(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 setup_script or disk_gb: + raise ProviderCommandError("Boat does not support Tailscale bootstrap or disk sizing") + size = instance_type or self.settings.instance_type + if size not in {"small", "default", "large"}: + raise ProviderCommandError("Boat instance_type must be small, default, or large") + try: + script = environment.get_cloud_init("set -e", env) + except ValueError as exc: + raise ProviderCommandError(str(exc)) from exc + private_key, public_key = generate_ed25519_keypair() + resource_name = f"{self.service_label}:{name}" + try: + sandbox_id = await self.api.create_sandbox( + resource_name, image=image, instance_type=size + ) + except BoatError as exc: + raise ProviderTransportError("Boat sandbox allocation failed") from exc + try: + await self.api.name_sandbox(sandbox_id, resource_name) + await self.api.wait_ready(sandbox_id) + await self.api.run_command(sandbox_id, f"sudo -n bash -e -c {shlex.quote(script)}") + ssh = await self.api.configure_ssh(sandbox_id, public_key) + host = ssh["machineIp"] + port = 22 + if endpoint := ssh.get("sshEndpoint"): + host, _, port_text = endpoint.rpartition(":") + port = int(port_text) + if not host: + raise ProviderCommandError("Boat returned no SSH address") + username = ssh["sshUser"] + except (BoatError, ProviderCommandError, KeyError, TypeError, ValueError) as exc: + with contextlib.suppress(BoatError): + await self.api.delete_sandbox(sandbox_id) + raise ProviderCommandError(f"Boat sandbox {sandbox_id} provisioning failed") from exc + return VMCreateResult( + provider_id=sandbox_id, + name=name, + ssh_host=host, + ssh_port=port, + ssh_username=username, + private_key=private_key, + ) + + async def delete_vm(self, name: str) -> None: + sandbox_id = await self.api.find_sandbox(f"{self.service_label}:{name}") + await self.api.delete_sandbox(sandbox_id) + + async def diagnose(self) -> str: + return await self.api.diagnose() + + async def aclose(self) -> None: + await self.api.aclose() diff --git a/src/providers/boat/settings.py b/src/providers/boat/settings.py new file mode 100644 index 0000000..6870987 --- /dev/null +++ b/src/providers/boat/settings.py @@ -0,0 +1,22 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from core.settings import get_secrets_dir + + +class BoatSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_prefix="BOAT_", + extra="ignore", + hide_input_in_errors=True, + secrets_dir=get_secrets_dir(), + ) + + api_token: str + api_url: str = "https://boat.dev/api/v1" + default_image: str = "default" + instance_type: str = "default" + api_timeout: float = Field(default=30, gt=0) + provision_timeout: float = Field(default=300, gt=0) + bootstrap_ssh_timeout_seconds: float = Field(default=120, gt=0) diff --git a/src/providers/boat/tests/__init__.py b/src/providers/boat/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/providers/boat/tests/test_provider.py b/src/providers/boat/tests/test_provider.py new file mode 100644 index 0000000..7ef4156 --- /dev/null +++ b/src/providers/boat/tests/test_provider.py @@ -0,0 +1,232 @@ +import json +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +import respx + +from providers.boat.api import BoatAPI +from providers.boat.provider import BoatProvider +from providers.boat.settings import BoatSettings +from providers.exceptions import ( + ProviderAuthError, + ProviderCommandError, + ProviderNotFoundError, + ProviderTransportError, +) +from providers.registry import get_provider_names + +BASE = "https://boat.dev/api/v1" +SANDBOX = "bx_23456789" + + +@pytest.fixture +async def provider() -> AsyncIterator[BoatProvider]: + settings = BoatSettings(api_token="test-token") + instance = BoatProvider(BoatAPI(settings), settings, service_label="test") + yield instance + await instance.aclose() + + +@pytest.fixture +def sandbox(respx_mock: respx.MockRouter) -> dict[str, respx.Route]: + return { + "create": respx_mock.post(f"{BASE}/sandboxes").respond( + 202, json={"sandbox": {"id": SANDBOX}} + ), + "name": respx_mock.patch(f"{BASE}/sandboxes/{SANDBOX}").respond(200, json={}), + "ready": respx_mock.get(f"{BASE}/sandboxes/{SANDBOX}").respond( + 200, json={"sandbox": {"state": "ready", "archiveAfter": None}} + ), + "command": respx_mock.post(f"{BASE}/sandboxes/{SANDBOX}/commands").respond( + 200, json={"success": True, "timedOut": False, "exitCode": 0} + ), + "ssh": respx_mock.post(f"{BASE}/sandboxes/{SANDBOX}/sshkey").respond( + 200, json={"machineIp": "203.0.113.10", "sshUser": "user"} + ), + "delete": respx_mock.delete(f"{BASE}/sandboxes/{SANDBOX}").respond(202, json={}), + } + + +@pytest.mark.respx(assert_all_called=False) +async def test_provision_uses_isolated_account_environment_and_direct_ssh(provider, sandbox): + result = await provider.create_vm(name="host-1", image="default", env={"APP_MODE": "test"}) + assert result.name == "host-1" + assert result.provider_id == SANDBOX + assert (result.ssh_host, result.ssh_port, result.ssh_username) == ("203.0.113.10", 22, "user") + assert result.private_key.startswith("-----BEGIN OPENSSH PRIVATE KEY-----") + create = sandbox["create"].calls.last.request + assert json.loads(create.content) == { + "type": "default", + "ttlSeconds": None, + "noEnv": True, + "snapshots": False, + } + assert create.headers["Authorization"] == "Bearer test-token" + assert create.headers["Idempotency-Key"] + assert json.loads(sandbox["name"].calls.last.request.content) == {"name": "test:host-1"} + command = json.loads(sandbox["command"].calls.last.request.content)["command"] + assert "sudo -n bash -e" in command + assert "APP_MODE=test" in command + assert "/etc/environment" in command + assert "PRIVATE KEY" not in sandbox["ssh"].calls.last.request.content.decode() + assert not sandbox["delete"].called + + +@pytest.mark.respx(assert_all_called=False) +async def test_named_snapshot_and_forwarded_ssh_endpoint(provider, sandbox): + sandbox["ssh"].respond( + 200, json={"machineIp": None, "sshEndpoint": "203.0.113.20:22001", "sshUser": "user"} + ) + result = await provider.create_vm(name="host-1", image="agent-base", instance_type="large") + assert (result.ssh_host, result.ssh_port) == ("203.0.113.20", 22001) + body = json.loads(sandbox["create"].calls.last.request.content) + assert body["from"] == "agent-base" + assert body["type"] == "large" + + +@pytest.mark.respx(assert_all_called=False) +@pytest.mark.parametrize("state", ["error", "cancelled", "archived"]) +async def test_failed_provision_is_deleted(provider, sandbox, state): + sandbox["ready"].respond(200, json={"sandbox": {"state": state}}) + with pytest.raises(ProviderCommandError): + await provider.create_vm(name="host-1", image="default") + assert sandbox["delete"].called + assert not sandbox["command"].called + + +@pytest.mark.respx(assert_all_called=False) +async def test_ready_wait_is_bounded(provider, sandbox): + provider.settings.provision_timeout = 0.01 + sandbox["ready"].respond(200, json={"sandbox": {"state": "provisioning"}}) + with pytest.raises(ProviderCommandError): + await provider.create_vm(name="host-1", image="default") + assert sandbox["delete"].called + + +@pytest.mark.respx(assert_all_called=False) +async def test_platform_timeout_is_not_silently_accepted(provider, sandbox): + sandbox["ready"].respond( + 200, json={"sandbox": {"state": "ready", "archiveAfter": "2026-10-04T18:00:00Z"}} + ) + with pytest.raises(ProviderCommandError): + await provider.create_vm(name="host-1", image="default") + assert sandbox["delete"].called + + +@pytest.mark.respx(assert_all_called=False) +async def test_bootstrap_failure_does_not_return_a_host_or_leak_output(provider, sandbox): + sandbox["command"].respond( + 200, json={"success": False, "exitCode": 1, "timedOut": False, "stderr": "sensitive-output"} + ) + with pytest.raises(ProviderCommandError) as error: + await provider.create_vm(name="host-1", image="default") + assert "sensitive-output" not in str(error.value) + assert sandbox["delete"].called + + +@pytest.mark.parametrize( + "arguments", + [ + {"instance_type": "invalid"}, + {"disk_gb": 20}, + {"setup_script": "tailscale up"}, + {"env": {"INVALID-NAME": "value"}}, + ], +) +async def test_invalid_request_fails_before_allocation(provider, arguments, respx_mock): + with pytest.raises(ProviderCommandError): + await provider.create_vm(name="host-1", image="default", **arguments) + assert not respx_mock.calls + + +async def test_delete_follows_pagination_and_confirms_exact_resource(provider, respx_mock): + listing = respx_mock.get(f"{BASE}/sandboxes").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "sandboxes": [{"name": "another", "id": "other"}], + "pageInfo": {"hasMore": True, "nextCursor": "next"}, + }, + ), + httpx.Response(200, json={"sandboxes": [{"name": "test:host-1", "id": SANDBOX}]}), + ] + ) + deletion = respx_mock.delete(f"{BASE}/sandboxes/{SANDBOX}").respond(202, json={}) + await provider.delete_vm("host-1") + assert listing.calls.last.request.url.params["cursor"] == "next" + assert deletion.calls.last.request.headers["X-Ascii-Confirm-Delete"] == SANDBOX + + +async def test_missing_sandbox_raises_neutral_error(provider, respx_mock): + respx_mock.get(f"{BASE}/sandboxes").respond(200, json={"sandboxes": []}) + with pytest.raises(ProviderNotFoundError): + await provider.delete_vm("host-1") + + +@pytest.mark.parametrize( + ("status", "error"), + [ + (401, ProviderAuthError), + (403, ProviderAuthError), + (404, ProviderNotFoundError), + (400, ProviderCommandError), + (429, ProviderTransportError), + (503, ProviderTransportError), + ], +) +async def test_http_failures_are_typed_and_do_not_echo_response( + provider, respx_mock, status, error +): + respx_mock.get(f"{BASE}/sandboxes").respond(status, text="secret-value") + with pytest.raises(error) as caught: + await provider.diagnose() + assert "secret-value" not in str(caught.value) + + +async def test_diagnose_is_read_only(provider, respx_mock): + route = respx_mock.get(f"{BASE}/sandboxes").respond(200, json={"sandboxes": []}) + assert "succeeded" in await provider.diagnose() + assert route.calls.last.request.url.params["limit"] == "1" + + +async def test_transport_failure_is_typed(provider, respx_mock): + respx_mock.get(f"{BASE}/sandboxes").mock(side_effect=httpx.ReadTimeout("secret-value")) + with pytest.raises(ProviderTransportError, match="transport failed"): + await provider.diagnose() + + +async def test_allocation_retry_reuses_idempotency_key_and_body(provider, respx_mock): + create = respx_mock.post(f"{BASE}/sandboxes").mock( + side_effect=[ + httpx.ReadTimeout("response lost"), + httpx.Response(202, json={"sandbox": {"id": SANDBOX}}), + ] + ) + with patch("providers.boat.api.asyncio.sleep", new_callable=AsyncMock): + result = await provider.api.create_sandbox( + "test:host-1", image="default", instance_type="small" + ) + assert result == SANDBOX + first, second = (call.request for call in create.calls) + assert first.headers["Idempotency-Key"] == second.headers["Idempotency-Key"] + assert first.content == second.content + + +@pytest.mark.parametrize("body", ["not-json", "[]"]) +async def test_invalid_response_is_typed(provider, respx_mock, body): + respx_mock.get(f"{BASE}/sandboxes").respond(200, text=body) + with pytest.raises(ProviderTransportError): + await provider.diagnose() + + +def test_provider_is_registered(): + assert "boat" in get_provider_names() + + +async def test_close_releases_http_client(provider): + with patch.object(provider.api, "aclose", new_callable=AsyncMock) as close: + await provider.aclose() + close.assert_awaited_once()