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
10 changes: 9 additions & 1 deletion backend/druks/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pydantic import BaseModel, ConfigDict

from druks.apps.registry import agents
from druks.apps.sandboxes import resolve_declared_sandbox
from druks.database import db_session
from druks.durable.activity import set_run_phase
from druks.durable.engine import _step_engine, step_session
Expand Down Expand Up @@ -50,7 +51,14 @@ async def _runner(
if host_id:
vm = sandbox_client.attach(host_id=host_id)
else:
vm = sandbox_client.ephemeral(idempotency_key=f"{workflow_id}:{step}")
image_override = template = None
if workflow.sandbox:
image_override, template = await resolve_declared_sandbox(workflow.sandbox)
vm = sandbox_client.ephemeral(
idempotency_key=f"{workflow_id}:{step}",
image_override=image_override,
template=template,
)
async with vm as box:
yield await workflow.get_workspace(box)

Expand Down
5 changes: 5 additions & 0 deletions backend/druks/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from druks.api.subjects import router as subjects_router
from druks.apps.loader import iter_apps, load
from druks.apps.routes import router as apps_router
from druks.apps.sandboxes import ensure_declared_sandboxes
from druks.browser.exceptions import BrowserApiError
from druks.browser.routes import router as browser_sessions_router
from druks.core.templates import render_page
Expand Down Expand Up @@ -98,6 +99,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
logging.getLogger(__name__).exception(
"app %r on_startup failed", registered_app.name
)
try:
await ensure_declared_sandboxes()
except Exception:
logging.getLogger(__name__).exception("declared sandbox ensure failed")

try:
yield
Expand Down
75 changes: 75 additions & 0 deletions backend/druks/apps/sandboxes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import asyncio
from typing import TYPE_CHECKING

from druks.durable.activity import set_run_phase
from druks.sandbox.client import sandbox_client
from druks.sandbox.declaration import Sandbox, get_content_hash
from druks.sandbox.exceptions import SandboxTemplateNotFound, SandboxTemplateUnavailable
from druks.settings import load_settings

from . import loader

if TYPE_CHECKING:
from druks.workflows import Workflow

_TEMPLATE_POLL_SECONDS = 5


def collect_declared_sandboxes() -> dict[str, tuple[str, bytes, list[type["Workflow"]]]]:
declared: dict[str, tuple[str, bytes, list[type[Workflow]]]] = {}
for app in loader.iter_apps():
for workflow in app.workflows():
if sandbox := workflow.sandbox:
base, script = sandbox.resolve()
content_hash = get_content_hash(base, script)
if content_hash in declared:
declared[content_hash][2].append(workflow)
else:
declared[content_hash] = (base, script, [workflow])
return declared


async def ensure_declared_sandboxes() -> dict[str, tuple[str, bytes, list[type["Workflow"]]]]:
if not load_settings().sandbox.service_url:
return {}

declared = collect_declared_sandboxes()
for requirements_hash, (base_image, script, _) in declared.items():
await sandbox_client.ensure_template(
base_image=base_image,
script=script,
requirements_hash=requirements_hash,
)
return declared


async def resolve_declared_sandbox(sandbox: Sandbox) -> tuple[str | None, str | None]:
base_image, script = sandbox.resolve()
requirements_hash = get_content_hash(base_image, script)
if pinned_image := load_settings().sandbox.pins.get(requirements_hash):
return pinned_image, None

try:
template = await sandbox_client.get_template(requirements_hash=requirements_hash)
except SandboxTemplateNotFound as error:
raise SandboxTemplateUnavailable(
f"sandbox template {requirements_hash} is missing. "
"Reinstall the app or run `druks doctor`."
) from error

is_building = template.status == "building"
if is_building:
await set_run_phase("sandbox_building")
while template.status == "building":
await asyncio.sleep(_TEMPLATE_POLL_SECONDS)
template = await sandbox_client.get_template(requirements_hash=requirements_hash)

if template.status == "available":
if is_building:
await set_run_phase("provisioning_vm")
return None, template.id

raise SandboxTemplateUnavailable(
f"sandbox template {requirements_hash} has status {template.status!r}. "
"Fix its setup and run `druks doctor`."
)
1 change: 1 addition & 0 deletions backend/druks/contrib/ship/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
# to name it, so the phase that clears provisioning maps to nothing.
_PHASE_META: dict[str, SubjectActivity] = {
"provisioning_vm": SubjectActivity(label="Building sandbox VM…", kind="infra"),
"sandbox_building": SubjectActivity(label="Building sandbox…", kind="infra"),
}


Expand Down
53 changes: 53 additions & 0 deletions backend/druks/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
from .agents import Agent
from .apps.loader import iter_apps
from .apps.registry import _ROLES, agents, autodiscover, services, webhooks, workflows
from .apps.sandboxes import ensure_declared_sandboxes
from .core.apis.github import get_github_client
from .database import create_async_engine_from_url, create_engine_from_url, session_scope
from .harnesses.models import HarnessConnection
from .harnesses.registry import get_harnesses
from .sandbox.client import sandbox_client
from .sandbox.exceptions import SandboxTemplateNotFound
from .services import Service, ServiceNotConnectedError
from .services.models import ServiceIdentity
from .settings import Settings, load_settings
Expand Down Expand Up @@ -293,6 +295,56 @@ async def check_sandbox_e2e(settings: Settings) -> CheckResult:
return CheckResult(name="sandbox_e2e", ok=True, detail=detail)


async def check_declared_sandboxes(settings: Settings) -> CheckResult | list[CheckResult]:
if not settings.sandbox.service_url:
return CheckResult(name="sandbox_templates", ok=True, detail="not configured")

try:
declared = await ensure_declared_sandboxes()
except Exception as error: # noqa: BLE001 — doctor reports, never raises
return CheckResult(
name="sandbox_templates",
ok=False,
detail=f"could not ensure declared sandboxes: {error}",
)

if not declared:
return CheckResult(name="sandbox_templates", ok=True, detail="no declared sandboxes")

results = []
for requirements_hash, (_, _, workflow_classes) in declared.items():
workflow_names = ", ".join(workflow.kind for workflow in workflow_classes)
name = f"sandbox:{requirements_hash[:12]}"
detail = f"{workflow_names}; hash {requirements_hash}"
try:
template = await sandbox_client.get_template(requirements_hash=requirements_hash)
except SandboxTemplateNotFound:
result = CheckResult(
name=name,
ok=False,
detail=f"{detail}; missing",
)
except Exception as error: # noqa: BLE001 — one lookup failure is one result
result = CheckResult(
name=name,
ok=False,
detail=f"{detail}; lookup failed: {error}",
)
else:
ok, pending = {
"available": (True, False),
"building": (False, True),
}.get(template.status, (False, False))
result = CheckResult(
name=name,
ok=ok,
pending=pending,
detail=f"{detail}; {template.status}",
)
results.append(result)
return results


async def _sandbox_e2e() -> str:
start = time.monotonic()
# acquire rolls its own host back on failure; once it yields, we own
Expand Down Expand Up @@ -482,6 +534,7 @@ async def _run_app_check(app_name: str, check) -> CheckResult:
check_drukbox,
check_capability_modules,
check_apps,
check_declared_sandboxes,
)


Expand Down
36 changes: 35 additions & 1 deletion backend/druks/sandbox/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

import asyncssh
from drukbox_sdk import SandboxAPI, SandboxHost
Expand All @@ -19,7 +20,7 @@
from druks.settings import load_settings

from .constants import SANDBOX_HOST_LEASE_SECONDS
from .exceptions import HostGone, SandboxError, SandboxUnreachable
from .exceptions import HostGone, SandboxError, SandboxTemplateNotFound, SandboxUnreachable
from .host import Sandbox
from .layout import get_helper_script_path, get_remote_home

Expand Down Expand Up @@ -53,6 +54,7 @@ async def ephemeral(
image_override: str | None = None,
provider: str | None = None,
sandbox_env: dict[str, str] | None = None,
template: str | None = None,
) -> AsyncIterator[Sandbox]:
"""One-shot lifecycle: acquire → yield → release. For callers
whose sandbox is bound to a single context manager body."""
Expand All @@ -64,6 +66,7 @@ async def ephemeral(
image_override=image_override,
provider=provider,
sandbox_env=sandbox_env,
template=template,
) as sandbox:
host_id = sandbox.id
yield sandbox
Expand All @@ -79,6 +82,7 @@ async def acquire(
image_override: str | None = None,
provider: str | None = None,
sandbox_env: dict[str, str] | None = None,
template: str | None = None,
) -> AsyncIterator[Sandbox]:
"""Create a new host (or reuse one matching ``idempotency_key``)
and yield it with SSH connected. Closes SSH on exit but does NOT
Expand All @@ -92,13 +96,18 @@ async def acquire(
# Fixed lease: drukbox reaps the host when this lapses, so a run whose
# worker dies frees its VM without a druks-side reconciler.
expires_at = datetime.now(UTC) + timedelta(seconds=SANDBOX_HOST_LEASE_SECONDS)
create_host_kwargs: dict[str, Any] = {}
if template:
# SDK 0.0.7 rejects template= even when unset, so ordinary leases omit it.
create_host_kwargs["template"] = template
try:
record = await api.create_host(
expires_at=expires_at,
env=sandbox_env,
idempotency_key=key,
image=image or None,
provider=provider,
**create_host_kwargs,
)
except (SandboxProvisioningError, SandboxUnavailableError) as exc:
# Transient control-plane failures — a 502 the service raises
Expand Down Expand Up @@ -148,6 +157,29 @@ async def list_hosts(self) -> list[SandboxHost]:
finally:
await api.aclose()

async def ensure_template(
self, *, base_image: str, script: bytes, requirements_hash: str
) -> Any:
api: Any = self._api()
try:
return await api.create_template(
base_image=base_image,
script=script,
requirements_hash=requirements_hash,
)
finally:
await api.aclose()

async def get_template(self, *, requirements_hash: str) -> Any:
api: Any = self._api()
try:
for template in await api.list_templates():
if template.requirements_hash == requirements_hash:
return template
finally:
await api.aclose()
raise SandboxTemplateNotFound(f"sandbox template {requirements_hash} does not exist")

@staticmethod
async def _best_effort_delete(api: SandboxAPI, host_id: str) -> None:
try:
Expand Down Expand Up @@ -193,6 +225,7 @@ async def provision(
image_override: str | None = None,
provider: str | None = None,
sandbox_env: dict[str, str] | None = None,
template: str | None = None,
) -> Sandbox:
"""Create a host and return its handle without holding an SSH connection —
the handle reconnects lazily when used (its id and lease expiry are readable
Expand All @@ -202,6 +235,7 @@ async def provision(
image_override=image_override,
provider=provider,
sandbox_env=sandbox_env,
template=template,
) as sandbox:
return sandbox

Expand Down
46 changes: 46 additions & 0 deletions backend/druks/sandbox/declaration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import hashlib
import importlib.util
from dataclasses import dataclass
from pathlib import Path

from druks.apps import loader
from druks.settings import load_settings

from .exceptions import SandboxSetupError


def get_content_hash(base: str, script: bytes) -> str:
content = base.encode("utf-8") + b"\0" + script
return hashlib.sha256(content).hexdigest()


@dataclass(frozen=True)
class Sandbox:
setup: str

def resolve(self) -> tuple[str, bytes]:
app_name, separator, relative_path = self.setup.partition("/")
if not separator or not relative_path:
raise SandboxSetupError(f"sandbox setup {self.setup!r} must be '<app>/<path>'")

for app in loader.iter_apps():
if app.name == app_name:
spec = importlib.util.find_spec(app.package)
if not spec or not spec.submodule_search_locations:
raise SandboxSetupError(
f"sandbox setup {self.setup!r} cannot find app package {app.package!r}"
)
path = Path(spec.submodule_search_locations[0]) / "templates" / relative_path
try:
script = path.read_bytes()
except OSError as error:
raise SandboxSetupError(
f"sandbox setup {self.setup!r} cannot be read: {error}"
) from error
return load_settings().sandbox.image, script

raise SandboxSetupError(f"sandbox setup {self.setup!r} names unknown app {app_name!r}")

@property
def content_hash(self) -> str:
return get_content_hash(*self.resolve())
12 changes: 12 additions & 0 deletions backend/druks/sandbox/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ class SandboxError(Exception):
"""Base for everything ``druks.sandbox`` raises out of its layer."""


class SandboxSetupError(SandboxError):
"""A declared setup path cannot resolve to package bytes."""


class SandboxTemplateNotFound(SandboxError):
"""Drukbox has no template for a requirements hash."""


class SandboxTemplateUnavailable(SandboxError):
"""A declared template cannot be leased."""


class SandboxUnreachable(SandboxError):
"""The SSH connection to the VM cannot be (re-)established.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from druks.workflows import FatalError, Gate, Workflow, step # noqa: F401
from druks.workflows import FatalError, Gate, Sandbox, Workflow, step # noqa: F401

# Durable workflows. Subclass ``Workflow`` and implement exactly one of:
# async def run(self, ...) — a single durable operation
# async def run_multistep(self, ...) — orchestration across @step calls and Gates
# The parameters ARE the workflow's input: plain typed params, validated at start().
# Set ``every = "<cron>"`` to schedule one; raise ``FatalError`` for a clean domain
# stop; a ``Gate`` subclass parks the run for human input.
# Set ``sandbox = Sandbox(setup="<app>/sandbox.sh")`` when the workflow needs
# tools beyond the platform base. Ship the shell file under the package's templates/.
#
# Declare what a workflow's runs are about with ``subject = YourSubject`` — the class,
# with a table or without — and druks gives that subject a board and a page.
Expand Down
Loading