From 237355d333c89c6d5cd98056f1be05321406dced Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 18 Oct 2026 13:05:07 +0000 Subject: [PATCH] Add Vercel sandboxes with Tailscale SSH Vercel stops each sandbox after a fixed time, so a provider's max_lifetime now cuts every lease at created_at + max_lifetime. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01JWeJyDmCJYS6etbwXsb3vw --- AGENTS.md | 5 +- docs/add-a-provider.md | 3 + docs/deploy.md | 1 + docs/vercel.md | 55 ++++++++ images/vercel/Dockerfile | 8 ++ src/hosts/service.py | 88 +++++++------ src/hosts/tests/test_max_lifetime.py | 89 +++++++++++++ src/providers/__init__.py | 3 +- src/providers/base.py | 9 ++ src/providers/vercel/__init__.py | 4 + src/providers/vercel/api.py | 108 ++++++++++++++++ src/providers/vercel/provider.py | 75 +++++++++++ src/providers/vercel/settings.py | 23 ++++ src/providers/vercel/tests/__init__.py | 0 src/providers/vercel/tests/test_provider.py | 134 ++++++++++++++++++++ 15 files changed, 561 insertions(+), 44 deletions(-) create mode 100644 docs/vercel.md create mode 100644 images/vercel/Dockerfile create mode 100644 src/hosts/tests/test_max_lifetime.py create mode 100644 src/providers/vercel/__init__.py create mode 100644 src/providers/vercel/api.py create mode 100644 src/providers/vercel/provider.py create mode 100644 src/providers/vercel/settings.py create mode 100644 src/providers/vercel/tests/__init__.py create mode 100644 src/providers/vercel/tests/test_provider.py diff --git a/AGENTS.md b/AGENTS.md index f36ee05..d9bf0d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`) @@ -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. diff --git a/docs/add-a-provider.md b/docs/add-a-provider.md index 69f10f7..0b20e79 100644 --- a/docs/add-a-provider.md +++ b/docs/add-a-provider.md @@ -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 diff --git a/docs/deploy.md b/docs/deploy.md index 842521b..bf43af7 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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 | diff --git a/docs/vercel.md b/docs/vercel.md new file mode 100644 index 0000000..f2d94ec --- /dev/null +++ b/docs/vercel.md @@ -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. diff --git a/images/vercel/Dockerfile b/images/vercel/Dockerfile new file mode 100644 index 0000000..a81ff32 --- /dev/null +++ b/images/vercel/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-client iptables iproute2 \ + && 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/service.py b/src/hosts/service.py index 0c09449..e3c7d8a 100644 --- a/src/hosts/service.py +++ b/src/hosts/service.py @@ -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 ...: @@ -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( @@ -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) @@ -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) @@ -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) diff --git a/src/hosts/tests/test_max_lifetime.py b/src/hosts/tests/test_max_lifetime.py new file mode 100644 index 0000000..0f62645 --- /dev/null +++ b/src/hosts/tests/test_max_lifetime.py @@ -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 diff --git a/src/providers/__init__.py b/src/providers/__init__.py index d66dbe9..d5bc72f 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -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 diff --git a/src/providers/base.py b/src/providers/base.py index a6fc2d0..1849867 100644 --- a/src/providers/base.py +++ b/src/providers/base.py @@ -1,5 +1,6 @@ import abc from dataclasses import dataclass +from datetime import datetime, timedelta from typing import ClassVar, NamedTuple, Self import asyncssh @@ -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 @@ -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.""" diff --git a/src/providers/vercel/__init__.py b/src/providers/vercel/__init__.py new file mode 100644 index 0000000..e1b1f9b --- /dev/null +++ b/src/providers/vercel/__init__.py @@ -0,0 +1,4 @@ +from providers.registry import register_vm_provider +from providers.vercel.provider import VercelProvider + +register_vm_provider(VercelProvider) diff --git a/src/providers/vercel/api.py b/src/providers/vercel/api.py new file mode 100644 index 0000000..961f6a0 --- /dev/null +++ b/src/providers/vercel/api.py @@ -0,0 +1,108 @@ +import contextlib +from typing import Any + +import httpx + +from providers.exceptions import ( + ProviderAuthError, + ProviderCommandError, + ProviderNotFoundError, + ProviderTransportError, +) + +from .settings import VercelSettings + + +class VercelAPI: + def __init__(self, settings: VercelSettings) -> None: + self.settings = settings + self.client = httpx.AsyncClient( + base_url="https://vercel.com/api/", + headers={"Authorization": f"Bearer {settings.token}"}, + params={"teamId": settings.team_id}, + timeout=httpx.Timeout(settings.api_timeout, connect=5), + ) + + async def create_sandbox(self, name: str, *, image: str, vcpus: int, label: str) -> str: + created = await self.request( + "POST", + "v3/sandboxes", + json={ + "projectId": self.settings.project_id, + "name": name, + "image": image, + "resources": {"vcpus": vcpus}, + "timeout": self.settings.max_lifetime_seconds * 1000, + "persistent": False, + "tags": {"managed-by": label}, + }, + ) + return created["session"]["id"] + + async def bootstrap(self, session_id: str, script: str) -> None: + commands = f"v2/sandboxes/sessions/{session_id}/cmd" + # Vercel's rejection can quote the command, and the command carries + # the Tailscale auth key, so the reason stays out of the error. + try: + started = await self.request( + "POST", + commands, + json={ + "command": "bash", + "args": ["-e", "-c", script], + "sudo": True, + "timeout": 120_000, + }, + ) + except ProviderCommandError: + raise ProviderCommandError("Vercel rejected the bootstrap command") from None + + finished = await self.request( + "GET", f"{commands}/{started['command']['id']}", params={"wait": "true"} + ) + if finished["command"]["exitCode"]: + raise ProviderCommandError("Vercel sandbox bootstrap failed") + + async def delete_sandbox(self, name: str) -> None: + await self.request( + "DELETE", + f"v2/sandboxes/{name}", + params={"projectId": self.settings.project_id, "deleteOrphanSnapshots": "true"}, + ) + + async def diagnose(self) -> str: + await self.request( + "GET", "v2/sandboxes", params={"project": self.settings.project_id, "limit": "1"} + ) + return "Vercel API authentication 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, + ) -> dict[str, Any]: + try: + response = await self.client.request(method, path, json=json, params=params) + except httpx.RequestError as exc: + raise ProviderTransportError("Vercel API transport failed") from exc + if response.status_code in {401, 403}: + raise ProviderAuthError("Vercel API authentication failed") + if response.status_code == 404: + raise ProviderNotFoundError("Vercel resource was not found") + if response.status_code >= 500 or response.status_code == 429: + raise ProviderTransportError(f"Vercel API returned HTTP {response.status_code}") + if not 200 <= response.status_code < 300: + reason = f"Vercel API rejected request (HTTP {response.status_code})" + with contextlib.suppress(ValueError, KeyError, TypeError): + reason = f"{reason}: {response.json()['error']['message']}" + raise ProviderCommandError(reason) + try: + return response.json() + except ValueError as exc: + raise ProviderTransportError("Invalid Vercel API response") from exc diff --git a/src/providers/vercel/provider.py b/src/providers/vercel/provider.py new file mode 100644 index 0000000..d08943d --- /dev/null +++ b/src/providers/vercel/provider.py @@ -0,0 +1,75 @@ +from datetime import timedelta +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, + ProviderNotFoundError, +) + +from .api import VercelAPI +from .settings import VercelSettings + + +class VercelProvider(VMProvider): + name: ClassVar[str] = "vercel" + diagnose_hint: ClassVar[str] = "check_vercel_and_tailscale_settings" + supports_instance_type = True + + def __init__(self, api: VercelAPI, settings: VercelSettings, *, service_label: str) -> None: + self.api = api + self.settings = settings + self.service_label = service_label + self.max_lifetime = timedelta(seconds=settings.max_lifetime_seconds) + + @classmethod + def from_settings(cls) -> Self: + settings = VercelSettings() # pyright: ignore[reportCallIssue] + return cls(VercelAPI(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("Vercel requires TAILSCALE_ENABLED=true") + try: + vcpus = int(instance_type or self.settings.vcpus) + except ValueError: + raise ProviderCommandError("Vercel instance_type must be a vCPU count") from None + try: + session_id = await self.api.create_sandbox( + name, image=image, vcpus=vcpus, label=self.service_label + ) + await self.api.bootstrap(session_id, environment.get_cloud_init(setup_script, env)) + except (ProviderAuthError, ProviderNotFoundError) as exc: + raise ProviderCommandError(str(exc)) from exc + return VMCreateResult(provider_id=session_id, name=name, ssh_username="root") + + async def delete_vm(self, name: str) -> None: + await self.api.delete_sandbox(name) + + async def diagnose(self) -> str: + if not get_settings().tailscale_enabled: + raise ProviderCommandError("Vercel requires TAILSCALE_ENABLED=true") + return await self.api.diagnose() + + async def aclose(self) -> None: + await self.api.aclose() diff --git a/src/providers/vercel/settings.py b/src/providers/vercel/settings.py new file mode 100644 index 0000000..bc4373d --- /dev/null +++ b/src/providers/vercel/settings.py @@ -0,0 +1,23 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from core.settings import get_secrets_dir + + +class VercelSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_prefix="VERCEL_", + extra="ignore", + hide_input_in_errors=True, + secrets_dir=get_secrets_dir(), + ) + + token: str + team_id: str + project_id: str + default_image: str + vcpus: int = 2 + max_lifetime_seconds: int = Field(default=2700, gt=0) + api_timeout: float = Field(default=150, gt=0) + bootstrap_ssh_timeout_seconds: float = Field(default=120, gt=0) diff --git a/src/providers/vercel/tests/__init__.py b/src/providers/vercel/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/providers/vercel/tests/test_provider.py b/src/providers/vercel/tests/test_provider.py new file mode 100644 index 0000000..f318b31 --- /dev/null +++ b/src/providers/vercel/tests/test_provider.py @@ -0,0 +1,134 @@ +import json +import traceback +from collections.abc import AsyncIterator + +import httpx +import pytest +import respx + +from providers.exceptions import ProviderAuthError, ProviderCommandError, ProviderNotFoundError +from providers.registry import get_provider_names +from providers.vercel.api import VercelAPI +from providers.vercel.provider import VercelProvider +from providers.vercel.settings import VercelSettings + +BASE = "https://vercel.com/api/" +COMMANDS = BASE + "v2/sandboxes/sessions/session-1/cmd" +CREATED = {"sandbox": {"name": "sb-test"}, "session": {"id": "session-1"}} + + +@pytest.fixture +async def provider() -> AsyncIterator[VercelProvider]: + settings = VercelSettings( + token="test-token", team_id="team-1", project_id="project-1", default_image="sandbox:v1" + ) + provider = VercelProvider(VercelAPI(settings), settings, service_label="drukbox") + yield provider + await provider.aclose() + + +@respx.mock +@pytest.mark.parametrize(("instance_type", "vcpus"), [(None, 2), ("4", 4)]) +async def test_create_bootstraps_sandbox_on_the_tailnet( + provider: VercelProvider, instance_type: str | None, vcpus: int +): + creation = respx.post(BASE + "v3/sandboxes").respond(201, json=CREATED) + command = respx.post(COMMANDS).respond(200, json={"command": {"id": "command-1"}}) + completion = respx.get(COMMANDS + "/command-1").respond(200, json={"command": {"exitCode": 0}}) + result = await provider.create_vm( + name="sb-test", + image="custom:v2", + setup_script="true", + env={"FOO": "hello world"}, + instance_type=instance_type, + ) + request = creation.calls[0].request + assert request.headers["Authorization"] == "Bearer test-token" + assert request.url.params["teamId"] == "team-1" + assert json.loads(request.content) == { + "projectId": "project-1", + "name": "sb-test", + "image": "custom:v2", + "resources": {"vcpus": vcpus}, + "timeout": 2_700_000, + "persistent": False, + "tags": {"managed-by": "drukbox"}, + } + body = json.loads(command.calls[0].request.content) + assert body["command"] == "bash" + assert body["sudo"] is True + assert "/etc/environment" in body["args"][-1] + assert completion.calls[0].request.url.params["wait"] == "true" + assert result.provider_id == "session-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: VercelProvider): + with pytest.raises(ProviderCommandError, match="TAILSCALE_ENABLED"): + await provider.create_vm(name="sb-test", image="sandbox:v1") + assert not respx.calls + + +@respx.mock +async def test_rejects_instance_type_that_is_not_a_vcpu_count(provider: VercelProvider): + with pytest.raises(ProviderCommandError, match="vCPU count"): + await provider.create_vm( + name="sb-test", image="sandbox:v1", setup_script="true", instance_type="large" + ) + assert not respx.calls + + +@respx.mock +async def test_rejected_create_names_vercel_reason(provider: VercelProvider): + respx.post(BASE + "v3/sandboxes").respond( + 400, json={"error": {"code": "bad_request", "message": "Image not found"}} + ) + with pytest.raises(ProviderCommandError, match="Image not found"): + await provider.create_vm(name="sb-test", image="missing:v1", setup_script="true") + + +@respx.mock +@pytest.mark.parametrize( + "command_response", + [ + httpx.Response(200, json={"command": {"id": "command-1"}}), + httpx.Response(400, json={"error": {"message": "secret output"}}), + ], +) +async def test_failed_bootstrap_hides_output( + provider: VercelProvider, command_response: httpx.Response +): + respx.post(BASE + "v3/sandboxes").respond(201, json=CREATED) + respx.post(COMMANDS).mock(return_value=command_response) + respx.get(COMMANDS + "/command-1").respond(200, json={"command": {"exitCode": 1}}) + with pytest.raises(ProviderCommandError) as error: + await provider.create_vm(name="sb-test", image="sandbox:v1", setup_script="true") + assert "secret output" not in "".join(traceback.format_exception(error.value)) + + +@respx.mock +async def test_delete_removes_sandbox_and_its_snapshots(provider: VercelProvider): + deletion = respx.delete(BASE + "v2/sandboxes/sb-test").respond(200, json=CREATED) + await provider.delete_vm("sb-test") + params = deletion.calls[0].request.url.params + assert params["projectId"] == "project-1" + assert params["deleteOrphanSnapshots"] == "true" + deletion.respond(404) + with pytest.raises(ProviderNotFoundError): + await provider.delete_vm("sb-test") + + +@respx.mock +async def test_diagnose_checks_authentication(provider: VercelProvider): + listing = respx.get(BASE + "v2/sandboxes").respond(200, json={"sandboxes": []}) + assert "authentication succeeded" in await provider.diagnose() + listing.respond(401) + with pytest.raises(ProviderAuthError): + await provider.diagnose() + + +def test_provider_is_registered(): + assert "vercel" in get_provider_names()