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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ The effective host image is captured on the host record at creation time. If
`POST /hosts` omits `image`, store `EXE_DEFAULT_IMAGE`; provisioning must use the
stored `host.image`, not the current runtime default.

Every host is a renewable lease. `POST /hosts` without `expires_at` stores
`now + LEASE_DEFAULT_TTL`; an explicit `expires_at: null` is the deliberate
opt-in to a permanent host. `POST /hosts/{id}/renew` bumps `expires_at` — to
the given future instant, or by `LEASE_DEFAULT_TTL` from now on an empty body.
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.

### Delete Semantics

`DELETE /hosts/{id}` is service-token only.
Expand Down
15 changes: 15 additions & 0 deletions api-tests/tests/full-api.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const EXPECTED_OPENAPI_OPERATIONS = [
"POST /http-proxies",
"POST /http-proxies/{name}/hosts/{host_id}",
"POST /hosts",
"POST /hosts/{host_id}/renew",
];

const HOST_KEYS = [
Expand Down Expand Up @@ -248,6 +249,20 @@ test.describe("Drukbox API", () => {
createdProxyName = null;
});

test("POST /hosts/{host_id}/renew extends the host lease", async () => {
await expectStatus(await publicApi.post(`/hosts/${createdHost.id}/renew`), 401);
await expectStatus(await badTokenApi.post(`/hosts/${createdHost.id}/renew`), 403);

const renewed = await expectJson(await api.post(`/hosts/${createdHost.id}/renew`), 200);
expectHost(renewed);
expect(renewed.id).toBe(createdHost.id);
expect(Date.parse(renewed.expires_at)).toBeGreaterThan(Date.now());

const missingId = "00000000-0000-0000-0000-000000000000";
const missing = await expectJson(await api.post(`/hosts/${missingId}/renew`), 404);
expect(missing.detail).toBe("host not found");
});

test("DELETE /hosts/{host_id} tears down the created host", async () => {
await expectStatus(await publicApi.delete(`/hosts/${createdHost.id}`), 401);
await expectStatus(await badTokenApi.delete(`/hosts/${createdHost.id}`), 403);
Expand Down
9 changes: 9 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ successful key returns the original host instead of a duplicate.
Caller `env` is stored for provisioning and never returned by the API;
keys in `hosts.schemas.RESERVED_HOST_ENV_KEYS` are rejected.

Every host is a renewable lease. A create without `expires_at` gets
`now + LEASE_DEFAULT_TTL`, so a host whose owner disappears lapses and
self-reaps instead of leaking VM cost; an explicit `expires_at: null`
is the deliberate opt-in to a permanent host. `POST /hosts/{id}/renew`
is the keepalive: it bumps `expires_at` to the requested instant, or by
`LEASE_DEFAULT_TTL` from now when the body is empty. Only caller-owned
hosts renew — unclaimed warm-pool members belong to pool maintenance
and refuse with `409`.

Two maintenance commands run as cron jobs from the same image:
`hosts.janitor` reaps expired and orphaned hosts, `hosts.pool` keeps a
warm pool of pre-provisioned hosts per provider (`POOL_SIZES`, with
Expand Down
1 change: 1 addition & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ Core, optional:
| `SERVICE_LABEL` | `drukbox` | Label stamped onto provider resources (VM tags, SG tags). |
| `UVICORN_HOST` | `0.0.0.0` | API bind address. Set `127.0.0.1` to restrict to loopback. |
| `PROVISIONING_GRACE_SECONDS` | `600` | Safety TTL on in-flight hosts so the janitor reaps row + VM if the client disconnects mid-provision. Must exceed the worst-case provision duration. |
| `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. |
| `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
6 changes: 6 additions & 0 deletions src/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ class Settings(BaseSettings):
validation_alias="PROVISIONING_GRACE_SECONDS",
description="Safety TTL on the host row while provisioning is in flight.",
)
lease_default_ttl: int = Field(
default=86400,
gt=0,
validation_alias="LEASE_DEFAULT_TTL",
description="Lease TTL in seconds for hosts created without an explicit expires_at.",
)
pool_sizes: dict[str, Annotated[int, Field(ge=0)]] = Field(
default_factory=dict,
validation_alias="POOL_SIZES",
Expand Down
21 changes: 19 additions & 2 deletions src/hosts/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from hosts.deps import get_host_service
from hosts.exceptions import HostTeardownError
from hosts.models import Host
from hosts.schemas import HostCreate, HostOut
from hosts.schemas import HostCreate, HostOut, HostRenew
from hosts.service import HostService
from networking.tailscale import NetworkError
from providers.exceptions import ProviderError, UnknownProviderError
Expand Down Expand Up @@ -46,12 +46,15 @@ async def create_host(
] = None,
) -> Host:
host_create = payload or HostCreate()
# An omitted expires_at gets the default lease; an explicit null is the
# caller's deliberate opt-in to a permanent host.
expires_at = host_create.expires_at if "expires_at" in host_create.model_fields_set else ...

try:
return await service.get_or_create_host(
env=host_create.env,
image=host_create.image,
expires_at=host_create.expires_at,
expires_at=expires_at,
idempotency_key=idempotency_key,
provider=host_create.provider,
)
Expand Down Expand Up @@ -85,6 +88,20 @@ async def get_host(host_id: uuid.UUID, service: HostServiceDep) -> Host:
raise HTTPException(status_code=404, detail="host not found")


@router.post(
"/{host_id}/renew",
response_model=HostOut,
dependencies=[Depends(require_service_auth)],
)
async def renew_host(
host_id: uuid.UUID,
service: HostServiceDep,
payload: HostRenew | None = None,
) -> Host:
host_renew = payload or HostRenew()
return await service.renew_host(host_id, expires_at=host_renew.expires_at)


@router.delete(
"/{host_id}",
status_code=status.HTTP_204_NO_CONTENT,
Expand Down
19 changes: 13 additions & 6 deletions src/hosts/janitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ async def reap_expired_hosts() -> list[uuid.UUID]:
"""Delete hosts whose ``expires_at`` has passed.

Iterates candidate ids without holding row locks; each delete acquires its
own per-row lock through HostService.delete_host. Rows still in an early
provisioning state are force-reaped: their expires_at is the safety TTL
(created + provisioning_grace_seconds), so an expired one means provisioning
was abandoned — a live provision's TTL is still in the future. The force
path tears down any VM partial provisioning may have created.
own per-row lock through HostService.delete_host and re-validates the lease
there (expired_only), so a renewal that lands after selection spares the
host. Rows still in an early provisioning state are force-reaped: their
expires_at is the safety TTL (created + provisioning_grace_seconds), so an
expired one means provisioning was abandoned — a live provision's TTL is
still in the future. The force path tears down any VM partial provisioning
may have created.
"""
async with async_session_factory() as session:
now = utc_now()
Expand All @@ -39,14 +41,19 @@ async def reap_expired_hosts() -> list[uuid.UUID]:
for host_id in expired_ids:
try:
async with async_session_factory() as session:
await HostService(session, tailscale=tailscale).delete_host(host_id, force=True)
deleted = await HostService(session, tailscale=tailscale).delete_host(
host_id, force=True, expired_only=True
)
except ResourceNotFoundError:
# Another janitor cycle, or an explicit DELETE, already removed it.
continue
except Exception:
log.exception("janitor: failed to reap expired host: host_id=%s", host_id)
continue
else:
if not deleted:
# Renewed between selection and the locked delete — spared.
continue
log.info("janitor: reaped expired host: host_id=%s", host_id)
reaped.append(host_id)
finally:
Expand Down
4 changes: 2 additions & 2 deletions src/hosts/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ async def _maintain(
try:
async with async_session_factory() as session:
service = HostService(session, settings=settings, tailscale=tailscale)
await service.delete_host(host_id, pool_shed=True)
removed_excess += 1
if await service.delete_host(host_id, pool_shed=True):
removed_excess += 1
except Exception:
log.exception("pool: failed to shed excess pool host_id=%s", host_id)

Expand Down
29 changes: 19 additions & 10 deletions src/hosts/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
_ENV_KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")


def _expires_at_must_be_future_and_tz_aware(expires_at: datetime | None) -> datetime | None:
if expires_at is None:
return None
if expires_at.tzinfo is None:
raise ValueError("expires_at must include a timezone offset")
if expires_at <= datetime.now(UTC):
raise ValueError("expires_at must be in the future")
return expires_at


class HostCreate(BaseModel):
image: str | None = None
env: dict[str, str] = Field(default_factory=dict)
Expand Down Expand Up @@ -40,16 +50,15 @@ def reject_reserved_env_keys(cls, env: dict[str, str]) -> dict[str, str]:
raise ValueError(f"reserved env keys are not allowed: {', '.join(reserved_keys)}")
return env

@field_validator("expires_at")
@classmethod
def expires_at_must_be_future_and_tz_aware(cls, expires_at: datetime | None) -> datetime | None:
if expires_at is None:
return None
if expires_at.tzinfo is None:
raise ValueError("expires_at must include a timezone offset")
if expires_at <= datetime.now(UTC):
raise ValueError("expires_at must be in the future")
return expires_at
_validate_expires_at = field_validator("expires_at")(_expires_at_must_be_future_and_tz_aware)


class HostRenew(BaseModel):
# Omitted (or null) means "extend by LEASE_DEFAULT_TTL from now"; renewal
# never makes a host permanent — that is a create-time choice.
expires_at: datetime | None = None

_validate_expires_at = field_validator("expires_at")(_expires_at_must_be_future_and_tz_aware)


class HostOut(BaseModel):
Expand Down
Loading