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
17 changes: 17 additions & 0 deletions backend/druks/sandbox/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,23 @@ async def provision(
) as host:
return host

async def set_expiry(self, *, host_id: str, expires_at: datetime) -> None:
"""Move a host's lease to ``expires_at`` without touching the VM.

Clipping the expiry down is how an idle hold ends: the host keeps
running until the new expiry, and drukbox's janitor reaps it then,
so druks needs no reconciler of its own. ``release`` remains the
hard delete for callers that want the VM gone now.

A host the control plane no longer knows about raises the SDK's
``SandboxNotFoundError`` — it is not swallowed here.
"""
api = self._api()
try:
await api.renew_host(host_id, expires_at=expires_at)
finally:
await api.aclose()

async def release(self, *, host_id: str) -> None:
"""Terminate the VM. Idempotent and infallible — already-gone hosts
no-op silently; any other failure is logged but not surfaced so
Expand Down
47 changes: 46 additions & 1 deletion backend/tests/test_sandbox_lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -88,10 +88,12 @@ class _FakeAPI:
created_secrets: list[dict[str, Secret] | None] = field(default_factory=list)
created_expires_at: list[datetime | None] = field(default_factory=list)
deleted_ids: list[str] = field(default_factory=list)
renewed: list[tuple[str, datetime | None]] = field(default_factory=list)
get_host_responses: list[SandboxHostRecord] = field(default_factory=list)
create_record: SandboxHostRecord | None = None
create_raises: Exception | None = None
delete_raises: Exception | None = None
renew_raises: Exception | None = None
# When set, every get_host call raises this exception instead of
# returning from get_host_responses. Used by the attach() tests
# to simulate the provider 404'ing a host we still have in our
Expand Down Expand Up @@ -131,6 +133,17 @@ async def delete_host(self, host_id: str) -> None:
if self.delete_raises is not None:
raise self.delete_raises

async def renew_host(
self,
host_id: str,
*,
expires_at: datetime | None = None,
) -> SandboxHostRecord:
self.renewed.append((host_id, expires_at))
if self.renew_raises is not None:
raise self.renew_raises
return _record(host_id=host_id)


def _record(
status: str = "active",
Expand Down Expand Up @@ -772,3 +785,35 @@ async def test_release_swallows_sdk_delete_failure(
await sandbox_client.release(host_id="host-xyz")

assert api.deleted_ids == ["host-xyz"]


async def test_set_expiry_forwards_expires_at_to_sdk_renew(
patched_sandbox_api: list[_FakeAPI],
):
"""Clipping a lease is a renew, not a delete: the caller's expiry goes
through to the SDK verbatim — no druks-side clamp — and the VM stays up."""
api = _FakeAPI(create_record=None)
patched_sandbox_api.append(api)
expires_at = datetime.now(UTC) + timedelta(seconds=90)

await sandbox_client.set_expiry(host_id="host-xyz", expires_at=expires_at)

assert api.renewed == [("host-xyz", expires_at)]
assert api.deleted_ids == []


async def test_set_expiry_surfaces_missing_host(patched_sandbox_api: list[_FakeAPI]):
"""A host drukbox no longer knows about surfaces as the SDK's
``SandboxNotFoundError`` — the idle-hold caller decides what "gone"
means, so it is neither swallowed nor remapped to ``HostGone``."""
missing = SandboxNotFoundError("host-xyz not found")
api = _FakeAPI(create_record=None, renew_raises=missing)
patched_sandbox_api.append(api)

with pytest.raises(SandboxNotFoundError) as excinfo:
await sandbox_client.set_expiry(
host_id="host-xyz",
expires_at=datetime.now(UTC) + timedelta(seconds=90),
)

assert excinfo.value is missing