diff --git a/backend/druks/cli.py b/backend/druks/cli.py index d87d44da..325fced4 100644 --- a/backend/druks/cli.py +++ b/backend/druks/cli.py @@ -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=( @@ -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}") diff --git a/backend/druks/doctor.py b/backend/druks/doctor.py index d6dc9f7e..868a05b2 100644 --- a/backend/druks/doctor.py +++ b/backend/druks/doctor.py @@ -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 @@ -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: @@ -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( diff --git a/backend/druks/sandbox/templates.py b/backend/druks/sandbox/templates.py index 7f11b0e4..c07017d5 100644 --- a/backend/druks/sandbox/templates.py +++ b/backend/druks/sandbox/templates.py @@ -12,8 +12,8 @@ _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: @@ -21,9 +21,9 @@ def get_declared_sandboxes(*sandboxes: Sandbox) -> dict[str, 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( @@ -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": @@ -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`." ) diff --git a/backend/tests/test_declared_sandboxes.py b/backend/tests/test_declared_sandboxes.py index 8b617ec3..73aa4229 100644 --- a/backend/tests/test_declared_sandboxes.py +++ b/backend/tests/test_declared_sandboxes.py @@ -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, @@ -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): @@ -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) @@ -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) @@ -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) @@ -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() diff --git a/backend/tests/test_doctor.py b/backend/tests/test_doctor.py index 40f13720..69706ec1 100644 --- a/backend/tests/test_doctor.py +++ b/backend/tests/test_doctor.py @@ -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 @@ -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) @@ -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( @@ -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", @@ -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 @@ -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, diff --git a/docs/deployment.md b/docs/deployment.md index 9e5686e0..8e56c79a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. @@ -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 diff --git a/docs/full-local.md b/docs/full-local.md index 12051534..2f957640 100644 --- a/docs/full-local.md +++ b/docs/full-local.md @@ -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 ``` @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index 5def84e5..01142fe4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -238,9 +238,10 @@ main() { cat <