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
69 changes: 69 additions & 0 deletions docs/boat.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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.boat
import providers.docker
import providers.docker_sbx
import providers.exe
Expand Down
4 changes: 4 additions & 0 deletions src/providers/boat/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from providers.boat.provider import BoatProvider
from providers.registry import register_vm_provider

register_vm_provider(BoatProvider)
145 changes: 145 additions & 0 deletions src/providers/boat/api.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions src/providers/boat/exceptions.py
Original file line number Diff line number Diff line change
@@ -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): ...
101 changes: 101 additions & 0 deletions src/providers/boat/provider.py
Original file line number Diff line number Diff line change
@@ -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()
22 changes: 22 additions & 0 deletions src/providers/boat/settings.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file.
Loading
Loading