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
18 changes: 18 additions & 0 deletions .github/workflows/vercel.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Validate Vercel Sandbox Image

on:
pull_request:
paths:
- images/vercel/**
- .github/workflows/vercel.yml

permissions:
contents: read

jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: docker build -t drukbox-vercel:check images/vercel
- run: docker run --rm drukbox-vercel:check tailscale version
23 changes: 23 additions & 0 deletions alembic/versions/0008_host_lease_deadline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Store the provider lifetime limit for each host.

Revision ID: 0008_host_lease_deadline
Revises: 0007_host_service_account
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "0008_host_lease_deadline"
down_revision: str | None = "0007_host_service_account"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column("hosts", sa.Column("lease_deadline", sa.DateTime(timezone=True), nullable=True))


def downgrade() -> None:
op.drop_column("hosts", "lease_deadline")
7 changes: 7 additions & 0 deletions docs/add-a-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ 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` to a `timedelta` when the provider has a fixed VM
lifetime. Drukbox stores `lease_deadline` before provisioning, caps default
leases and pool ages, and rejects explicit leases beyond that limit.
The value must be conservative: the provider must grant at least that
lifetime after creation begins. Leave it `None` for providers with no
fixed lifetime.

## 7. Tests

- Unit-test the provider with a mocked api object
Expand Down
14 changes: 14 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ or claimed the host, `admin` for an admin key, or `null` for an unclaimed
warm host. Callers cannot set it. An `Idempotency-Key` belongs to the
service account that first used it. Another one reusing it gets `409`.

## Host leases

`POST /hosts` without `expires_at` uses the default lease. An explicit
`null` requests a permanent host. `POST /hosts/{id}/renew` with an empty
body renews from now; supply `expires_at` to request a specific expiry.

A host response includes `lease_deadline`. A date means that the provider
will stop the VM after a fixed lifetime. Default leases and renewals are
capped at that date. A permanent lease or an explicit expiry beyond the
limit returns `400` with `HOST_LEASE`. The limit is stored per host and
does not change when provider settings change. `null` means there is no
fixed provider lifetime. Renewal does not restart a VM or extend its
provider lifetime.

## Refresh a host secret

`POST /hosts/{host_id}/secrets/{service}/refresh` makes the exchange drop
Expand Down
3 changes: 2 additions & 1 deletion docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ account token returns `503`. See [API](api.md#service-accounts).
| `aws` | EC2 instances | Remote |
| `hetzner` | Hetzner Cloud VMs | Remote |
| `exoscale` | Exoscale VMs | Remote |
| `vercel` | [Vercel sandboxes](vercel.md) | Remote, Tailscale required |
| `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 |

Expand Down Expand Up @@ -583,7 +584,7 @@ Core, optional:
| `TEMPLATE_BUILD_TIMEOUT` | `3600` | Max age in seconds of an unfinished template build before the janitor marks it failed. |
| `TEMPLATE_FAILED_RETENTION` | `86400` | Seconds that failed template records and diagnostics remain before the janitor deletes them. |
| `TEMPLATE_UNUSED_TTL` | `1209600` | Seconds that an available template remains after its last use, or creation when never used. |
| `LEASE_DEFAULT_TTL` | `86400` | Lease TTL in seconds for hosts created without an explicit `expires_at`, and the extension applied by an empty `POST /hosts/{id}/renew`. An explicit `expires_at: null` at create time opts out of expiry entirely. |
| `LEASE_DEFAULT_TTL` | `86400` | Lease TTL in seconds for hosts created without an explicit `expires_at`, and the extension applied by an empty `POST /hosts/{id}/renew`. An explicit `expires_at: null` at create time opts out where the provider permits it. Defaults are capped by the host's `lease_deadline`. |
| `IDEMPOTENCY_KEY_TTL_HOURS` | `24` | Retention period for successful `Idempotency-Key` mappings. |
| `POOL_SIZES` | `{}` | Warm hosts to keep ready per provider, as JSON (e.g. `{"exe": 2, "hetzner": 1}`). Overrides `POOL_SIZE` for the providers it names. |
| `POOL_SIZE` | `0` | Warm hosts to keep ready for the default provider. `0` disables its pool. |
Expand Down
109 changes: 109 additions & 0 deletions docs/vercel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Vercel sandboxes

Send `{"provider": "vercel"}` to `POST /hosts`. Drukbox creates a named
Vercel sandbox, starts Tailscale, and returns its `internal_ssh_host`.
The API and callers connect with Tailscale SSH as root. There is no
public SSH endpoint or gateway.

## Configure

Build the supplied `images/vercel/Dockerfile` as a `linux/amd64` image
and publish it to the Vercel Container Registry (VCR) of your project.
For example, from `images/vercel` with the project linked in the Vercel CLI:

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

Wait for the image to show `Ready` in VCR. Then configure Drukbox:

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

Use an access token with sandbox access to this team and project. These
settings also accept files in `/run/secrets`, including `VERCEL_TOKEN`.
Configure the Tailscale OAuth credentials and tag policy as described in
[Networking](networking.md). Permit Tailscale SSH as root from the API
and callers to the sandbox tag. A separate Vercel project per Drukbox
deployment keeps ownership clear.

The image contains bash, Tailscale, jq, sudo, git, gh, and CA tools.
Bootstrap uses userspace Tailscale when systemd is absent. It needs no
TUN device. Custom images must contain these tools; a Vercel managed
image alone does not satisfy this contract. Vercel ignores image
`ENTRYPOINT` and `CMD`; Drukbox starts bootstrap through the command API.

Userspace Tailscale provides incoming SSH but does not add kernel routes
for application traffic. The secrets proxy address must be reachable
through the sandbox's normal outbound network. A tailnet-only proxy
address is not sufficient.

| Setting | Default | Purpose |
| --- | --- | --- |
| `VERCEL_DEFAULT_IMAGE` | Required | VCR image reference; use a digest for a fixed image |
| `VERCEL_VCPUS` | `2` | Default virtual CPU count |
| `VERCEL_SESSION_TIMEOUT_SECONDS` | `2700` | Fixed session duration, including bootstrap |
| `VERCEL_API_TIMEOUT` | `150` | HTTP request timeout, in seconds |
| `VERCEL_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `120` | SSH readiness timeout |

An `image` in `POST /hosts` selects another prepared VCR image. An
`instance_type`, such as `"4"`, sets the vCPU count. Drukbox accepts 1–32;
Vercel enforces the account's plan limit. Per-host disk sizing and Drukbox
template builds are not supported. Bootstrap has a 120-second command
limit and a 130-second client deadline.

## Leases and cleanup

Vercel limits each uninterrupted session to 45 minutes on Hobby and
24 hours on Pro and Enterprise. Set `VERCEL_SESSION_TIMEOUT_SECONDS`
within the plan limit. Drukbox accepts 600–86400 seconds and defaults to
the Hobby limit. Each sandbox has persistence disabled. Drukbox does not
snapshot, resume, or replace a stopped sandbox.

Run `uv run alembic upgrade head` before using this version. The migration
adds a nullable `lease_deadline` to hosts. At creation, Drukbox stores the
creation time plus the configured session duration, less 60 seconds.
This conservative limit includes provisioning time and is fixed for that
host. A change to provider settings cannot extend an existing host.

Omitted leases and empty renewals are capped at `lease_deadline`. An
explicit permanent lease or a date beyond the limit returns `400` with
`HOST_LEASE`. Pool hosts use the same limit; a claim cannot extend it.
Renewal does not call Vercel or increase the session duration. Copy work
out before expiry.

Deletion removes the named sandbox and its orphan snapshots. A failed
bootstrap or an uncertain create response triggers a deletion attempt.
The generated name makes cleanup possible even if a create response is
lost. If cleanup fails, Drukbox retains the host row for the janitor.
Keep the janitor running to remove expired provider records and Tailscale
devices after a session stops.

`GET /doctor` checks read access to the project's sandbox list. It does
not create a sandbox, check the image, or test Tailscale SSH.

## Verify

```bash
uv run pytest src/providers/vercel/tests src/hosts/tests/test_lease_deadline.py
uv run ruff check
uv run ruff format --check
uv run pyright
```

The provider tests mock Vercel HTTP responses. Before production use,
verify a real create, Tailscale SSH connection, renewal, expiry, and
janitor deletion in the target project.

## References

- [Vercel sandbox images](https://vercel.com/docs/sandbox/concepts/images)
- [Vercel session duration and persistence](https://vercel.com/kb/guide/vercel-sandbox-duration-and-persistence)
- [Vercel SDK API client](https://github.com/vercel/sandbox/blob/main/packages/vercel-sandbox/src/api-client/api-client.ts)
- [Tailscale userspace networking](https://tailscale.com/kb/1112/userspace-networking)
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-server \
&& rm -rf /var/lib/apt/lists/*

COPY --from=tailscale /usr/local/bin/tailscale /usr/local/bin/tailscaled /usr/local/bin/
5 changes: 5 additions & 0 deletions src/hosts/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ class HostTeardownError(AppException):
class ProvisioningFailedError(AppException):
status_code = 502
error_code = "PROVISIONING_FAILED"


class HostLeaseError(AppException):
status_code = 400
error_code = "HOST_LEASE"
59 changes: 26 additions & 33 deletions src/hosts/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import uuid
from datetime import UTC, datetime
from enum import StrEnum
from types import EllipsisType

from sqlalchemy import JSON, DateTime, ForeignKey, String, Text, TypeDecorator, Uuid
from sqlalchemy.dialects.postgresql import JSONB
Expand All @@ -9,10 +10,9 @@
from uuid6 import uuid7

from core.database import Base
from hosts.exceptions import HostLeaseError

# Use JSONB on Postgres (indexable, binary storage); fall back to JSON
# (TEXT-backed) on SQLite and other dialects so the OSS quickstart works
# without Postgres.
# JSONB is available on Postgres; SQLite keeps the local development database usable.
_JSONType = JSON().with_variant(JSONB(), "postgresql")


Expand All @@ -31,8 +31,7 @@ class _UTCDateTime(TypeDecorator[datetime]):

def process_bind_param(self, value: datetime | None, dialect: object) -> datetime | None:
if value and value.tzinfo:
# SQLite drops the offset, so store the equivalent UTC instant —
# otherwise 00:00-08:00 reads back as 00:00Z, not 08:00Z.
# SQLite drops timezone offsets, so normalize before storage.
return value.astimezone(UTC)
return value

Expand All @@ -45,11 +44,7 @@ def process_result_value(self, value: datetime | None, dialect: object) -> datet
UTCDateTime = _UTCDateTime()

_HOST_NAME_PREFIX = "sb-"
# 48 bits of UUIDv7 entropy for a short readable name. UUIDv7's leading 48
# bits are the millisecond timestamp — concurrent creates in the same ms
# share an identical leading-hex prefix and collided on the unique index.
# Slice from the trailing random segment (rand_b, bits 64..125) so names
# are derived from actual entropy, not a clock reading.
# Use UUIDv7's random suffix because its timestamp prefix is shared by concurrent creates.
_HOST_NAME_UID_CHARS = 12


Expand All @@ -64,10 +59,7 @@ class HostStatus(StrEnum):

class Host(Base):
__tablename__ = "hosts"
# Allow non-Mapped[] annotations on this class (we use it for
# `private_key`, a transient per-instance attribute that must never
# be persisted). Without this flag SQLAlchemy 2.0's annotated
# declarative mapper rejects plain annotations.
# The private key exists only on the create response and must never be stored.
__allow_unmapped__ = True

id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid7)
Expand All @@ -78,20 +70,14 @@ class Host(Base):
status: Mapped[str] = mapped_column(String(32), default=HostStatus.PROVISIONING.value)
provider: Mapped[str] = mapped_column(String(20), default="exe")
image: Mapped[str] = mapped_column(Text)
# Per-request sizing, provider-native values (EC2 instance type, Hetzner
# server type). NULL means the provider's configured default size.
# Null sizing selects the provider default.
instance_type: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
disk_gb: Mapped[int | None] = mapped_column(nullable=True, default=None)
# Reachable SSH addresses. Both populated when Tailscale is enabled
# (internal = MagicDNS name, external = provider-given address); only
# external_ssh_host is populated when Tailscale is disabled. The
# internal path is always reached on port 22 by Tailscale convention,
# so no internal_ssh_port column.
# The internal Tailscale SSH port is always 22.
external_ssh_host: Mapped[str] = mapped_column(Text, default="")
external_ssh_port: Mapped[int] = mapped_column(default=22)
ssh_username: Mapped[str] = mapped_column(Text, default="")
# The public half of the per-host keypair. The gateway authenticates
# callers against it. The private half is returned once and never stored.
# Gateway callers authenticate against this public key.
public_key: Mapped[str] = mapped_column(Text, default="")
internal_ssh_host: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
known_hosts: Mapped[str] = mapped_column(Text, default="")
Expand All @@ -108,25 +94,32 @@ class Host(Base):
nullable=True,
default=None,
)
lease_deadline: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
claimed_at: Mapped[datetime | None] = mapped_column(
UTCDateTime,
nullable=True,
default=None,
)
# True only for hosts the pool maintainer warmed. Demand-provisioned hosts
# are False so pool claim/count/shed never hand out or delete a caller-owned
# sandbox — both kinds start with claimed_at NULL, so claimed_at alone can't
# tell them apart.
# An unclaimed caller-owned host must never be counted or removed as pool capacity.
pool_member: Mapped[bool] = mapped_column(default=False)
last_error: Mapped[str] = mapped_column(Text, default="")
# Non-persisted, transient per-instance attribute. provision() assigns
# the freshly-minted private key here so HostOut returns it exactly
# once at create time; a subsequent GET reads a row from disk where
# this attribute falls back to None. `__allow_unmapped__` above lets
# SQLAlchemy treat the plain annotation as a class attribute instead
# of a missing column.
private_key: str | None = None

def lease_expiry(
self, requested: datetime | None | EllipsisType, *, default: datetime
) -> datetime | None:
expiry = default if requested is ... else requested
if self.lease_deadline:
if self.lease_deadline <= datetime.now(UTC):
raise HostLeaseError("The provider lifetime has ended")
if requested is ...:
return min(default, self.lease_deadline)
if not expiry or expiry > self.lease_deadline:
raise HostLeaseError(
f"expires_at must be at or before {self.lease_deadline.isoformat()}"
)
return expiry

def __str__(self) -> str:
return f"{self.provider}:{self.name}"

Expand Down
3 changes: 3 additions & 0 deletions src/hosts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,6 @@ class HostOut(BaseModel):
updated_at: datetime
activated_at: datetime | None
expires_at: datetime | None
lease_deadline: datetime | None = Field(
description="Latest allowed lease expiry; null when the provider has no fixed lifetime."
)
Loading
Loading