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
21 changes: 21 additions & 0 deletions backend/druks/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ def main() -> None:
"changes; never in cron."
),
)
sandboxes_parser = subparsers.add_parser("sandboxes", help="Manage sandbox templates.")
sandboxes_subparsers = sandboxes_parser.add_subparsers(dest="action", required=True)
sandboxes_subparsers.add_parser(
"build",
help=(
"Pull each declared sandbox's base image and build its template. "
"Run after every deploy."
),
)
setup_parser = subparsers.add_parser(
"setup",
help=(
Expand Down Expand Up @@ -147,6 +156,18 @@ def main() -> None:
make_app_migration(args.app, args.message, settings.database_url)
return

if args.command == "sandboxes":
import asyncio

from .apps.loader import iter_apps
from .chat.sandbox import CHAT_SANDBOX
from .sandbox.templates import prepare_sandbox_templates

for app in iter_apps():
app.discover()
asyncio.run(prepare_sandbox_templates(extra=(CHAT_SANDBOX,)))
return

raise AssertionError(f"Unhandled command: {args.command}")


Expand Down
9 changes: 4 additions & 5 deletions backend/druks/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from .harnesses.registry import get_harnesses
from .sandbox.client import sandbox_client
from .sandbox.exceptions import TemplateNotFound
from .sandbox.templates import get_declared_sandboxes, prepare_sandbox_templates
from .sandbox.templates import get_declared_sandboxes
from .secrets.datastructures import Audience
from .secrets.enums import SecretKind
from .secrets.models import VaultSecret
Expand Down Expand Up @@ -304,13 +304,12 @@ async def check_sandbox_e2e(settings: Settings) -> CheckResult | list[CheckResul

async def check_declared_sandboxes(settings: Settings) -> CheckResult | list[CheckResult]:
try:
await prepare_sandbox_templates(CHAT_SANDBOX)
declared = get_declared_sandboxes(CHAT_SANDBOX)
declared = get_declared_sandboxes(extra=(CHAT_SANDBOX,))
except Exception as error: # noqa: BLE001 — doctor reports, never raises
return CheckResult(
name="sandbox_templates",
ok=False,
detail=f"could not prepare declared sandbox templates: {error}",
detail=f"could not read declared sandboxes: {error}",
)

if not declared:
Expand All @@ -326,7 +325,7 @@ async def check_declared_sandboxes(settings: Settings) -> CheckResult | list[Che
result = CheckResult(
name=name,
ok=False,
detail=f"{detail}; missing",
detail=f"{detail}; missing — run `druks sandboxes build`",
)
except Exception as error: # noqa: BLE001 — one lookup failure is one result
result = CheckResult(
Expand Down
13 changes: 6 additions & 7 deletions backend/druks/sandbox/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@
_TEMPLATE_POLL_SECONDS = 5


def get_declared_sandboxes(*sandboxes: Sandbox) -> dict[str, Sandbox]:
declared = {sandbox.setup_script_hash: sandbox for sandbox in sandboxes}
def get_declared_sandboxes(*, extra: tuple[Sandbox, ...] = ()) -> dict[str, Sandbox]:
declared = {sandbox.setup_script_hash: sandbox for sandbox in extra}
for app in loader.iter_apps():
for workflow in app.workflows():
if sandbox := workflow.sandbox:
declared[sandbox.setup_script_hash] = sandbox
return declared


async def prepare_sandbox_templates(*sandboxes: Sandbox) -> None:
async def prepare_sandbox_templates(*, extra: tuple[Sandbox, ...] = ()) -> None:
base_image = load_settings().sandbox.image
for sandbox in get_declared_sandboxes(*sandboxes).values():
for sandbox in get_declared_sandboxes(extra=extra).values():
app_name = sandbox.package or loader.resolve_workflow_app(sandbox.module)
label = f"{app_name}-{PurePosixPath(sandbox.setup).stem}".replace("_", "-")
await sandbox_client.create_template(
Expand All @@ -42,8 +42,7 @@ async def get_template_id(sandbox: Sandbox) -> str:
)
except TemplateNotFound as error:
raise TemplateUnavailable(
f"sandbox template {setup_script_hash} is missing. "
"Reinstall the app or run `druks doctor`."
f"sandbox template {setup_script_hash} is missing. Run `druks sandboxes build`."
) from error

if template.status == "building":
Expand All @@ -59,5 +58,5 @@ async def get_template_id(sandbox: Sandbox) -> str:

raise TemplateUnavailable(
f"sandbox template {setup_script_hash} has status {template.status!r}. "
"Fix its setup and run `druks doctor`."
"Fix its setup and run `druks sandboxes build`."
)
15 changes: 10 additions & 5 deletions backend/tests/test_declared_sandboxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async def test_prepare_sandbox_templates_requests_each_declaration(monkeypatch):
monkeypatch.setattr(
templates,
"get_declared_sandboxes",
lambda *extra: {sandbox.setup_script_hash: sandbox},
lambda extra: {sandbox.setup_script_hash: sandbox},
)
monkeypatch.setattr(
templates,
Expand All @@ -93,6 +93,8 @@ async def test_prepare_sandbox_templates_requests_each_declaration(monkeypatch):
base_image="base",
label="notes-setup",
)
await templates.prepare_sandbox_templates()
assert create_template.await_count == 2


async def test_prepare_templates_labels_each_app_and_script(monkeypatch):
Expand All @@ -110,7 +112,7 @@ async def test_prepare_templates_labels_each_app_and_script(monkeypatch):
monkeypatch.setattr(
templates,
"get_declared_sandboxes",
lambda *extra: {sandbox.setup_script_hash: sandbox for sandbox in sandboxes},
lambda extra: {sandbox.setup_script_hash: sandbox for sandbox in sandboxes},
)
monkeypatch.setattr(
templates, "sandbox_client", SimpleNamespace(create_template=create_template)
Expand Down Expand Up @@ -191,7 +193,7 @@ async def test_get_template_id_rejects_missing_template(monkeypatch):
SimpleNamespace(get_template=AsyncMock(side_effect=TemplateNotFound("missing"))),
)

with pytest.raises(TemplateUnavailable, match="missing.*druks doctor"):
with pytest.raises(TemplateUnavailable, match="missing.*druks sandboxes build"):
await templates.get_template_id(sandbox)


Expand All @@ -211,7 +213,7 @@ async def test_get_template_id_rejects_failed_template(monkeypatch):
),
)

with pytest.raises(TemplateUnavailable, match="failed.*druks doctor"):
with pytest.raises(TemplateUnavailable, match="failed.*druks sandboxes build"):
await templates.get_template_id(sandbox)


Expand Down Expand Up @@ -300,11 +302,14 @@ async def test_client_template_primitives_use_sdk_contract(monkeypatch):
listed = SimpleNamespace(
id="template-1", status="available", setup_script_hash="hash-1", base_image="base"
)
previous = SimpleNamespace(
id="previous-digest", status="available", setup_script_hash="hash-1", base_image="base"
)

class FakeAPI:
def __init__(self):
self.create_template = AsyncMock(return_value=created)
self.list_templates = AsyncMock(return_value=[other_base, listed])
self.list_templates = AsyncMock(return_value=[other_base, listed, previous])
self.aclose = AsyncMock()

api = FakeAPI()
Expand Down
14 changes: 3 additions & 11 deletions backend/tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import pytest
from conftest import connect_service
from druks import doctor
from druks.chat.sandbox import CHAT_SANDBOX
from druks.db import db_session
from druks.sandbox.exceptions import TemplateNotFound
from druks.secrets.models import VaultSecret
Expand Down Expand Up @@ -205,9 +204,7 @@ async def test_declared_sandboxes_pass_when_none_are_declared(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request_templates = AsyncMock()
monkeypatch.setattr(doctor, "prepare_sandbox_templates", request_templates)
monkeypatch.setattr(doctor, "get_declared_sandboxes", lambda *extra: {})
monkeypatch.setattr(doctor, "get_declared_sandboxes", lambda extra: {})
settings = make_settings(tmp_path, sandbox={"service_url": "http://drukbox"})

result = await doctor.check_declared_sandboxes(settings)
Expand All @@ -217,7 +214,6 @@ async def test_declared_sandboxes_pass_when_none_are_declared(
ok=True,
detail="no declared sandboxes",
)
request_templates.assert_awaited_once_with(CHAT_SANDBOX)


@pytest.mark.parametrize(
Expand All @@ -236,10 +232,8 @@ async def test_declared_sandboxes_report_template_status(
pending: bool,
) -> None:
declared = {"requirements-1": SimpleNamespace(setup="sandboxes/setup.sh")}
request_templates = AsyncMock()
lookup = AsyncMock(return_value=SimpleNamespace(status=status))
monkeypatch.setattr(doctor, "prepare_sandbox_templates", request_templates)
monkeypatch.setattr(doctor, "get_declared_sandboxes", lambda *extra: declared)
monkeypatch.setattr(doctor, "get_declared_sandboxes", lambda extra: declared)
monkeypatch.setattr(
doctor,
"sandbox_client",
Expand All @@ -249,7 +243,6 @@ async def test_declared_sandboxes_report_template_status(

results = await doctor.check_declared_sandboxes(settings)

request_templates.assert_awaited_once_with(CHAT_SANDBOX)
lookup.assert_awaited_once_with(setup_script_hash="requirements-1")
assert len(results) == 1
assert results[0].ok is ok
Expand All @@ -263,11 +256,10 @@ async def test_declared_sandboxes_report_missing_template(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(doctor, "prepare_sandbox_templates", AsyncMock())
monkeypatch.setattr(
doctor,
"get_declared_sandboxes",
lambda *extra: {"requirements-1": SimpleNamespace(setup="sandboxes/setup.sh")},
lambda extra: {"requirements-1": SimpleNamespace(setup="sandboxes/setup.sh")},
)
monkeypatch.setattr(
doctor,
Expand Down
25 changes: 25 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ docker compose ps
curl -fsS http://127.0.0.1:8001/health
```

Build the sandbox images. Do this after every deploy, as
[Update / redeploy](#update--redeploy) describes:

```bash
docker compose exec web druks sandboxes build
```

Then connect the providers for your selected harnesses from
**Settings → Providers**. Use a subscription or API key, as each provider
supports. A subscription connection opens the provider authorization page.
Expand Down Expand Up @@ -285,6 +292,24 @@ Then the installer pulls the images and
starts the stack. Compose replaces only changed services. To migrate without
the installer, run `docker compose run --rm web druks init-db`.

After every deploy, build the sandbox images:

```bash
docker compose exec web druks sandboxes build
```

The command asks Drukbox to prepare each declared sandbox, including Chat.
Drukbox pulls the base image and reuses a template only when its digest and
setup script match. A changed digest starts a new template build. Docker
Sandboxes also loads the refreshed base image into its separate image store.
Older templates follow Drukbox's unused-template cleanup policy. Existing
hosts keep their image until they are released.

A failed image pull or store load fails the command. `druks doctor` reports a
template that is still building as pending; runs wait for it. Set
`[sandbox].timeout` high enough for the base image download and store load
(180 seconds by default).

Recreating `web` interrupts in-flight execution. DBOS recovers compatible
workflows from completed checkpoints when the process returns. Changes to
workflow structure, step order, step names, or serialized input can break
Expand Down
10 changes: 7 additions & 3 deletions docs/full-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,12 @@ Agent calls refuse before provisioning if their selected harness is not
connected. `druks doctor` reports the connection and token expiry for every
registered harness.

Run the complete preflight:
Build the sandbox images, then run the complete preflight. Build them again
after every re-run of the installer:

```bash
cd ~/druks
docker compose exec web druks sandboxes build
docker compose exec web druks doctor
```

Expand Down Expand Up @@ -184,8 +186,10 @@ Set the image in `~/druks/druks.toml`, then re-run the installer:
image = "druks-sandbox"
```

Existing hosts keep their original image. New acquisitions use the updated
value.
A local build needs no registry. After the installer, run
`docker compose exec web druks sandboxes build` to refresh the image and its
templates. See [Update / redeploy](deployment.md#update--redeploy) for template
reuse and cleanup. Existing hosts keep their original image.

## Webhook caveat

Expand Down
3 changes: 2 additions & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,10 @@ main() {
cat <<MSG

------------------------------------------------------------
Stack is up. Verify with:
Stack is up. Build the sandbox images, then verify:

cd $INSTALL_DIR
docker compose exec web druks sandboxes build
docker compose ps
docker compose exec web druks doctor

Expand Down
Loading