Skip to content
Merged
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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ It owns:
- Template records, async template builds, and their provider images
- Inline provisioning in `POST /hosts`
- Provider VM creation and deletion (exe.dev, AWS, Hetzner, Exoscale, Boat,
Cloudflare, Daytona, local Docker, Docker Sandboxes)
Cloudflare, Daytona, Vercel, local Docker, Docker Sandboxes)
- Tailscale auth key creation, device discovery, and cleanup
- SSH host key scanning and `known_hosts` material
- An SSH gateway for hosts of gateway providers (`python -m gateway.server`)
Expand Down Expand Up @@ -156,6 +156,9 @@ Only `active` and `bootstrapping` hosts renew, and unclaimed pool members
refuse with `409` (pool maintenance owns them). The janitor reaps hosts whose
`expires_at` has passed.

A provider whose VMs stop after a fixed time sets `max_lifetime`.
`VMProvider.lease_end` cuts every lease at `created_at + max_lifetime`.

### Delete Semantics

`DELETE /hosts/{id}` needs an admin key or a service account token.
Expand Down
3 changes: 3 additions & 0 deletions docs/add-a-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ provider with a secret store of its own, such as docker-sbx, implements
Do not add provider-specific fields to the host schema. Add a capability
instead.

Set `max_lifetime` when the provider stops every VM after a fixed time. No
lease then ends later than that time.

## 7. Tests

- Unit-test the provider with a mocked api object
Expand Down
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).
| `hetzner` | Hetzner Cloud VMs | Remote |
| `exoscale` | Exoscale VMs | Remote |
| `cloudflare` | Containers ([Cloudflare setup](cloudflare.md)) | Remote |
| `vercel` | microVMs ([Vercel setup](vercel.md)) | 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 |
Expand Down
55 changes: 55 additions & 0 deletions docs/vercel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Vercel sandboxes

`POST /hosts` with `{"provider": "vercel"}` starts a Vercel sandbox, joins it
to Tailscale, and returns `internal_ssh_host`. The sandbox has no public SSH
address, so this provider requires `TAILSCALE_ENABLED=true`.

## Prepare the image

Vercel boots sandboxes from images in the project's Vercel Container Registry.
In `images/vercel`, with the project linked in the Vercel CLI:

```bash
vercel vcr login docker
vercel vcr build docker . drukbox-sandbox:v1 --push
```

A custom image needs the tools in `images/vercel/Dockerfile`. Do not install an
SSH server: Tailscale SSH serves the host, and image host keys would be the
same in every sandbox.

## Configure

Give drukbox an access token for the team and project, and the Tailscale
settings from [Networking](networking.md). The tailnet SSH policy must let the
API and callers reach the sandbox tag as `root`.

```dotenv
VERCEL_TOKEN=YOUR-ACCESS-TOKEN
VERCEL_TEAM_ID=team_YOUR_TEAM
VERCEL_PROJECT_ID=prj_YOUR_PROJECT
VERCEL_DEFAULT_IMAGE=drukbox-sandbox:v1
TAILSCALE_ENABLED=true
```

The token can also come from `/run/secrets/VERCEL_TOKEN`.

| Variable | Default | Purpose |
| --- | --- | --- |
| `VERCEL_VCPUS` | `2` | Default vCPU count; each vCPU has 2 GB of memory |
| `VERCEL_MAX_LIFETIME_SECONDS` | `2700` | Time until Vercel stops a sandbox |
| `VERCEL_API_TIMEOUT` | `150` | API request timeout, in seconds |
| `VERCEL_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `120` | SSH readiness timeout, in seconds |

## Limits

- Vercel stops a sandbox after `VERCEL_MAX_LIFETIME_SECONDS`: at most 45
minutes on Hobby and 24 hours on Pro and Enterprise. No lease ends after
that time: drukbox cuts a later or permanent `expires_at` to it, at create
and at renewal.
- `instance_type` is a vCPU count, such as `"4"`, up to the plan's maximum.
Template builds and `disk_gb` are not supported.
- Without `/dev/net/tun`, Tailscale runs in userspace, so the sandbox reaches
`SECRETS_PROXY_URL` over its normal outbound network, not over the tailnet.
- A stopped sandbox loses its files. Keep the janitor running: it deletes the
sandbox and its Tailscale device when the lease ends.
8 changes: 8 additions & 0 deletions images/vercel/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-client iptables iproute2 \
&& rm -rf /var/lib/apt/lists/*

COPY --from=tailscale /usr/local/bin/tailscale /usr/local/bin/tailscaled /usr/local/bin/
88 changes: 46 additions & 42 deletions src/hosts/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,19 +186,22 @@ async def _try_claim_pool_host(
# lock + WHERE filter). Losers return None and the caller falls
# through to fresh provisioning.
now = utc_now()
candidate_id = (
await self.session.execute(
select(Host.id)
.where(Host.provider == provider)
.where(Host.pool_member.is_(True))
.where(Host.claimed_at.is_(None))
.where(Host.status == HostStatus.ACTIVE.value)
.where(or_(Host.expires_at.is_(None), Host.expires_at > now))
.order_by(Host.created_at.asc())
.limit(1)
)
).scalar_one_or_none()
if not candidate_id:
vm = get_vm_provider(provider)
candidates = (
select(Host)
.where(Host.provider == provider)
.where(Host.pool_member.is_(True))
.where(Host.claimed_at.is_(None))
.where(Host.status == HostStatus.ACTIVE.value)
.where(or_(Host.expires_at.is_(None), Host.expires_at > now))
.order_by(Host.created_at.asc())
.limit(1)
)
if vm.max_lifetime and isinstance(expires_at, datetime):
# A warm host that the provider stops before the requested end is no match.
candidates = candidates.where(Host.created_at >= expires_at - vm.max_lifetime)
candidate = (await self.session.execute(candidates)).scalar_one_or_none()
if not candidate:
return

if expires_at is ...:
Expand All @@ -208,22 +211,20 @@ async def _try_claim_pool_host(
# who deliberately opted into a permanent host.
result = await self.session.execute(
update(Host)
.where(Host.id == candidate_id)
.where(Host.id == candidate.id)
.where(Host.claimed_at.is_(None))
.values(
service_account=service_account,
claimed_at=now,
updated_at=now,
expires_at=expires_at,
expires_at=vm.lease_end(candidate.created_at, expires_at),
)
.returning(Host)
)
host = result.scalar_one_or_none()
await self.session.commit()
if not host:
# Lost the race to another claimant; let the caller fall through.
return
logger.info("pool: claimed host_id=%s name=%s", host.id, host.name)
if host:
logger.info("pool: claimed host_id=%s name=%s", host.id, host.name)
return host

async def create_host(
Expand Down Expand Up @@ -333,7 +334,7 @@ async def create_host(
update(Host)
.where(Host.id == host.id)
.where(Host.expires_at == initial_expires_at)
.values(expires_at=expires_at, updated_at=utc_now())
.values(expires_at=vm.lease_end(host.created_at, expires_at), updated_at=utc_now())
)
await ttl_session.commit()
await self.session.refresh(host)
Expand Down Expand Up @@ -411,27 +412,28 @@ async def _release_idempotency_loser(self, host: Host) -> None:
# fresh max-age TTL; freshly-created host → mark expired so the
# janitor reaps it (delete_host refuses PROVISIONING).
async with async_session_factory() as fix_session:
fresh = await fix_session.get(Host, host.id)
if not fresh:
return
now = utc_now()
if fresh.claimed_at:
fresh.claimed_at = None
fresh.service_account = None
fresh.expires_at = now + timedelta(hours=self.settings.pool_host_max_age_hours)
fresh.updated_at = now
logger.info(
"idempotency: returned pool host_id=%s to pool after lost race",
fresh.id,
)
else:
fresh.expires_at = now
fresh.updated_at = now
logger.info(
"idempotency: marked host_id=%s for janitor reaping after lost race",
fresh.id,
)
await fix_session.commit()
if fresh := await fix_session.get(Host, host.id):
now = utc_now()
if fresh.claimed_at:
fresh.claimed_at = None
fresh.service_account = None
fresh.expires_at = get_vm_provider(fresh.provider).lease_end(
fresh.created_at,
now + timedelta(hours=self.settings.pool_host_max_age_hours),
)
fresh.updated_at = now
logger.info(
"idempotency: returned pool host_id=%s to pool after lost race",
fresh.id,
)
else:
fresh.expires_at = now
fresh.updated_at = now
logger.info(
"idempotency: marked host_id=%s for janitor reaping after lost race",
fresh.id,
)
await fix_session.commit()

async def get_host(self, host_id: uuid.UUID) -> Host | None:
return await self.session.get(Host, host_id)
Expand All @@ -458,7 +460,9 @@ async def renew_host(self, host_id: uuid.UUID, *, expires_at: datetime | None =
if host.status not in RENEWABLE_STATUSES:
raise HostStateError(f"cannot renew a host in status {host.status}")

host.expires_at = expires_at or self._default_lease_expires_at()
host.expires_at = get_vm_provider(host.provider).lease_end(
host.created_at, expires_at or self._default_lease_expires_at()
)
host.updated_at = utc_now()
await self.session.commit()
await self.session.refresh(host)
Expand Down
89 changes: 89 additions & 0 deletions src/hosts/tests/test_max_lifetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock
from uuid import UUID

import pytest
from httpx import AsyncClient

from core.database import async_session_factory
from hosts.models import Host, HostStatus
from hosts.service import HostService, utc_now

AUTH = {"Authorization": "Bearer service-token"}
LIFETIME = timedelta(minutes=44)


@pytest.fixture
def limited_provider(stub_provider, monkeypatch):
stub_provider.max_lifetime = LIFETIME
monkeypatch.setattr(HostService, "provision", AsyncMock())
return stub_provider


async def activate(host_id: UUID) -> None:
async with async_session_factory() as session:
host = await session.get(Host, host_id)
assert host
host.status = HostStatus.ACTIVE.value
await session.commit()


@pytest.mark.parametrize(
"lease",
[{}, {"expires_at": None}, {"expires_at": (datetime.now(UTC) + timedelta(days=1)).isoformat()}],
ids=["default", "permanent", "later"],
)
async def test_lease_ends_when_the_provider_stops_the_vm(
client: AsyncClient, limited_provider, lease
):
response = await client.post("/hosts", headers=AUTH, json={"provider": "stub"} | lease)
body = response.json()
assert datetime.fromisoformat(body["expires_at"]) == (
datetime.fromisoformat(body["created_at"]) + LIFETIME
)


@pytest.mark.parametrize(
"renewal",
[{}, {"expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat()}],
ids=["default", "later"],
)
async def test_renewal_ends_when_the_provider_stops_the_vm(
client: AsyncClient, limited_provider, renewal
):
soon = (datetime.now(UTC) + timedelta(minutes=5)).isoformat()
body = (
await client.post("/hosts", headers=AUTH, json={"provider": "stub", "expires_at": soon})
).json()
await activate(UUID(body["id"]))

renewed = await client.post(f"/hosts/{body['id']}/renew", headers=AUTH, json=renewal)
assert datetime.fromisoformat(renewed.json()["expires_at"]) == (
datetime.fromisoformat(body["created_at"]) + LIFETIME
)


async def test_pool_leases_end_when_the_provider_stops_the_vm(limited_provider):
async with async_session_factory() as session:
service = HostService(session)
warm = await service.create_host(
env={},
image=None,
expires_at=utc_now() + timedelta(hours=4),
provider="stub",
pool_member=True,
)
stop = warm.created_at + LIFETIME
assert warm.expires_at == stop
await activate(warm.id)

late = stop + timedelta(minutes=1)
assert not await service._try_claim_pool_host(provider="stub", expires_at=late)
claimed = await service._try_claim_pool_host(provider="stub", expires_at=...)
assert claimed
assert claimed.expires_at == stop

await service._release_idempotency_loser(claimed)
await session.refresh(warm)
assert not warm.claimed_at
assert warm.expires_at == stop
3 changes: 2 additions & 1 deletion src/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
import providers.docker_sbx
import providers.exe
import providers.exoscale
import providers.hetzner # noqa: F401
import providers.hetzner
import providers.vercel # noqa: F401
9 changes: 9 additions & 0 deletions src/providers/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import abc
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import ClassVar, NamedTuple, Self

import asyncssh
Expand Down Expand Up @@ -79,6 +80,8 @@ class VMProvider(abc.ABC):
supports_disk_gb: ClassVar[bool] = False
# A local provider's hosts keep the external path only.
supports_tailnet: ClassVar[bool] = True
# Set when the provider stops every VM this long after create.
max_lifetime: timedelta | None = None

@classmethod
@abc.abstractmethod
Expand Down Expand Up @@ -107,6 +110,12 @@ async def create_vm(
@abc.abstractmethod
async def delete_vm(self, name: str) -> None: ...

def lease_end(self, created_at: datetime, expires_at: datetime | None) -> datetime | None:
if self.max_lifetime:
stop = created_at + self.max_lifetime
return min(expires_at, stop) if expires_at else stop
return expires_at

async def open_gateway_tunnel(self, name: str) -> asyncssh.SSHClientConnection:
"""Open an SSH tunnel into a gateway host's sandbox. The gateway
forwards TCP channels through it."""
Expand Down
4 changes: 4 additions & 0 deletions src/providers/vercel/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from providers.registry import register_vm_provider
from providers.vercel.provider import VercelProvider

register_vm_provider(VercelProvider)
Loading
Loading