From 540e54c6a5b3bc34c7a8eae81cecb69224f14b65 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:03:36 +0000 Subject: [PATCH 1/2] Record the current step of a template build Closes #87. --- alembic/versions/0009_template_build_step.py | 22 +++++++++++++++++++ docs/architecture.md | 4 +++- src/providers/capabilities.py | 4 ++++ src/providers/docker/api.py | 11 +++++++--- src/providers/docker/images.py | 14 +++++++++++- src/providers/docker/provider.py | 4 +++- src/providers/docker/tests/test_api.py | 20 ++++++++++++----- src/providers/docker/tests/test_images.py | 19 +++++++++++++--- src/providers/docker/tests/test_provider.py | 6 +++-- src/providers/docker_sbx/provider.py | 6 ++++- .../docker_sbx/tests/test_provider.py | 12 ++++++++-- src/providers/exe/provider.py | 4 +++- src/providers/exe/tests/test_provider.py | 15 ++++++++----- src/providers/tests/test_capabilities.py | 11 ++++++++-- src/templates/models.py | 1 + src/templates/schemas.py | 1 + src/templates/service.py | 6 +++++ src/templates/tests/conftest.py | 4 +++- src/templates/tests/test_api.py | 10 +++++---- 19 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 alembic/versions/0009_template_build_step.py diff --git a/alembic/versions/0009_template_build_step.py b/alembic/versions/0009_template_build_step.py new file mode 100644 index 0000000..653cf30 --- /dev/null +++ b/alembic/versions/0009_template_build_step.py @@ -0,0 +1,22 @@ +"""Record the step a template build is in.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0009_template_build_step" +down_revision: str | None = "0008_template_base_image_ref" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "templates", sa.Column("build_step", sa.Text(), nullable=False, server_default="") + ) + + +def downgrade() -> None: + with op.batch_alter_table("templates") as templates: + templates.drop_column("build_step") diff --git a/docs/architecture.md b/docs/architecture.md index 60df93e..849527a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,7 +183,9 @@ For example, `{"base_image":"sandbox:latest","setup_script":"echo ready"}` reuses a template only while the tag resolves to the same digest. A new digest creates a `building` record and returns `202 Accepted`. The build uses the saved digest reference even if the tag moves again. Callers poll until the -template becomes `available` or `failed`. A `POST` for a `failed` template +template becomes `available` or `failed`. While it builds, `build_step` names +the current step, such as `running setup script: `; a failed +build keeps the step it failed in. A `POST` for a `failed` template builds the same record again, and its `last_error` stays until that build ends. A pull or store failure returns `502`; a provider without template support returns `400`. diff --git a/src/providers/capabilities.py b/src/providers/capabilities.py index 55687f6..8928f1e 100644 --- a/src/providers/capabilities.py +++ b/src/providers/capabilities.py @@ -1,6 +1,7 @@ import abc import base64 import pathlib +from collections.abc import Awaitable, Callable from typing import ClassVar, TypeVar from cryptography import x509 @@ -13,6 +14,8 @@ from providers.exceptions import CapabilityUnsupportedError, ProviderCommandError CapabilityT = TypeVar("CapabilityT") +# Records the step a template build is in, such as "pushing image". +RecordStep = Callable[[str], Awaitable[None]] # The box itself and the cloud metadata address. NO_PROXY = "localhost,127.0.0.1,::1,169.254.169.254" @@ -119,6 +122,7 @@ async def build_template_image( base_image: str, setup_script: str, label: str, + record_step: RecordStep, ) -> str: ... @abc.abstractmethod diff --git a/src/providers/docker/api.py b/src/providers/docker/api.py index 8ba43da..4413fc5 100644 --- a/src/providers/docker/api.py +++ b/src/providers/docker/api.py @@ -1,4 +1,5 @@ import io +from collections.abc import AsyncIterator from pathlib import Path import aiodocker @@ -93,13 +94,17 @@ async def remove_container(self, name: str) -> None: except aiohttp.ClientError as exc: raise DockerTransportError(str(exc)) from exc - async def build_image(self, image: str, context_tar: bytes) -> None: + async def build_image(self, image: str, context_tar: bytes) -> AsyncIterator[str]: + """Build the image and yield the last line of each output chunk.""" try: - await self._get_client().images.build( + async for event in self._get_client().images.build( fileobj=io.BytesIO(context_tar), encoding="gzip", tag=image, - ) + stream=True, + ): + if output := event.get("stream", "").strip(): + yield output.splitlines()[-1] except (aiodocker.DockerError, aiohttp.ClientError) as exc: raise DockerTransportError(_detail(exc)) from exc diff --git a/src/providers/docker/images.py b/src/providers/docker/images.py index 49b0b0e..09de40c 100644 --- a/src/providers/docker/images.py +++ b/src/providers/docker/images.py @@ -2,13 +2,18 @@ import io import re import tarfile +import time from core.settings import get_settings +from providers.capabilities import RecordStep from providers.exceptions import ProviderNotFoundError, ProviderTransportError from .api import DockerAPI from .exceptions import DockerImageNotFoundError, DockerProviderError +# A setup script can print thousands of lines. The step shows one per interval. +SETUP_OUTPUT_INTERVAL_SECONDS = 3.0 + def derive_image_name( *, @@ -49,6 +54,7 @@ async def build_derived_image( base_image: str, setup_script: str, label: str, + record_step: RecordStep, ) -> str: settings = get_settings() repository = "drukbox-template" @@ -62,8 +68,14 @@ async def build_derived_image( ) context_tar = create_build_context(base_image=base_image, setup_script=setup_script) try: - await docker.build_image(image, context_tar) + await record_step("running setup script") + shown_at = time.monotonic() + async for line in docker.build_image(image, context_tar): + if time.monotonic() - shown_at >= SETUP_OUTPUT_INTERVAL_SECONDS: + await record_step(f"running setup script: {line}") + shown_at = time.monotonic() if settings.template_repository: + await record_step("pushing image") await docker.push_image( image, username=settings.registry_username, diff --git a/src/providers/docker/provider.py b/src/providers/docker/provider.py index 2465853..93b0b5c 100644 --- a/src/providers/docker/provider.py +++ b/src/providers/docker/provider.py @@ -3,7 +3,7 @@ from core.settings import get_settings from providers.base import VMCreateResult, VMProvider -from providers.capabilities import TemplateCapability +from providers.capabilities import RecordStep, TemplateCapability from providers.exceptions import ( ProviderCommandError, ProviderNotFoundError, @@ -143,12 +143,14 @@ async def build_template_image( base_image: str, setup_script: str, label: str, + record_step: RecordStep, ) -> str: return await build_derived_image( self.api, base_image=base_image, setup_script=setup_script, label=label, + record_step=record_step, ) async def delete_template_image(self, image: str) -> None: diff --git a/src/providers/docker/tests/test_api.py b/src/providers/docker/tests/test_api.py index c5679a8..85d5aa1 100644 --- a/src/providers/docker/tests/test_api.py +++ b/src/providers/docker/tests/test_api.py @@ -28,7 +28,7 @@ def _fake_docker(**overrides: object) -> SimpleNamespace: ), images=SimpleNamespace( pull=AsyncMock(return_value=[{"status": "Digest: sha256:" + "a" * 64}]), - build=AsyncMock(), + build=MagicMock(), delete=AsyncMock(), push=AsyncMock(), ), @@ -111,18 +111,27 @@ async def test_remove_container_forces_and_drops_volumes() -> None: fake.containers.container.return_value.delete.assert_awaited_once_with(force=True, v=True) -async def test_build_image_sends_the_context_tar_under_the_tag() -> None: +async def test_build_image_sends_the_context_tar_and_yields_its_output() -> None: fake = _fake_docker() + fake.images.build.return_value.__aiter__.return_value = [ + {"stream": "Step 3/3 : RUN sh /drukbox-setup.sh\n"}, + {"aux": {"ID": "sha256:abc"}}, + {"stream": "Reading package lists...\nInstalling nodejs\n"}, + ] buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w:gz"): pass context_tar = buffer.getvalue() - await _api(fake).build_image("drukbox-template:123456789abc", context_tar) + output = [ + line async for line in _api(fake).build_image("drukbox-template:123456789abc", context_tar) + ] - kwargs = fake.images.build.await_args.kwargs + assert output == ["Step 3/3 : RUN sh /drukbox-setup.sh", "Installing nodejs"] + kwargs = fake.images.build.call_args.kwargs assert kwargs["tag"] == "drukbox-template:123456789abc" assert kwargs["encoding"] == "gzip" + assert kwargs["stream"] is True assert kwargs["fileobj"].getvalue() == context_tar @@ -131,7 +140,8 @@ async def test_build_failure_keeps_the_engine_detail() -> None: fake.images.build.side_effect = DockerError(0, "RUN sh /drukbox-setup.sh: exit code 127") with pytest.raises(DockerTransportError, match="exit code 127"): - await _api(fake).build_image("drukbox-template:123456789abc", b"") + async for _ in _api(fake).build_image("drukbox-template:123456789abc", b""): + pass @pytest.mark.parametrize( diff --git a/src/providers/docker/tests/test_images.py b/src/providers/docker/tests/test_images.py index d87b55d..184a19b 100644 --- a/src/providers/docker/tests/test_images.py +++ b/src/providers/docker/tests/test_images.py @@ -6,6 +6,7 @@ from pydantic import SecretStr from core.settings import get_settings +from providers.docker import images from providers.docker.images import ( build_derived_image, create_build_context, @@ -68,12 +69,24 @@ async def test_build_derived_image_publishes_to_the_template_repository( monkeypatch.setattr(get_settings(), "registry_username", "bot") monkeypatch.setattr(get_settings(), "registry_password", SecretStr("secret")) monkeypatch.setattr(get_settings(), "template_repository", "acme/templates") - docker = MagicMock(build_image=AsyncMock(), push_image=AsyncMock()) + monkeypatch.setattr(images, "SETUP_OUTPUT_INTERVAL_SECONDS", 0) + docker = MagicMock(push_image=AsyncMock()) + docker.build_image.return_value.__aiter__.return_value = ["Installing nodejs"] + record_step = AsyncMock() image = await build_derived_image( - docker, base_image="sandbox:base", setup_script="apt-get update", label="node-tools" + docker, + base_image="sandbox:base", + setup_script="apt-get update", + label="node-tools", + record_step=record_step, ) assert image.startswith("ghcr.io/acme/templates:node-tools-") - assert docker.build_image.await_args.args[0] == image + assert docker.build_image.call_args.args[0] == image docker.push_image.assert_awaited_once_with(image, username="bot", password="secret") + assert [call.args[0] for call in record_step.await_args_list] == [ + "running setup script", + "running setup script: Installing nodejs", + "pushing image", + ] diff --git a/src/providers/docker/tests/test_provider.py b/src/providers/docker/tests/test_provider.py index d3a775a..e4f2017 100644 --- a/src/providers/docker/tests/test_provider.py +++ b/src/providers/docker/tests/test_provider.py @@ -28,7 +28,7 @@ def _api_mock() -> MagicMock: api.run_container = AsyncMock(return_value="container-id") api.published_ssh_port = AsyncMock(return_value=49160) api.remove_container = AsyncMock() - api.build_image = AsyncMock() + api.build_image = MagicMock() api.remove_image = AsyncMock() api.server_version = AsyncMock(return_value="27.0.3") return api @@ -174,10 +174,11 @@ async def test_build_template_image_builds_and_returns_the_derived_tag(): base_image="sandbox:base", setup_script="apt-get update", label="Node tools", + record_step=AsyncMock(), ) assert image.startswith("drukbox-template:node-tools-") - assert api.build_image.await_args.args[0] == image + assert api.build_image.call_args.args[0] == image @pytest.mark.asyncio @@ -191,6 +192,7 @@ async def test_build_template_image_translates_build_failure(): base_image="sandbox:base", setup_script="apt-get update", label="Node tools", + record_step=AsyncMock(), ) diff --git a/src/providers/docker_sbx/provider.py b/src/providers/docker_sbx/provider.py index 2a3a7e7..a1727fc 100644 --- a/src/providers/docker_sbx/provider.py +++ b/src/providers/docker_sbx/provider.py @@ -10,7 +10,7 @@ from core.settings import get_settings from providers import environment from providers.base import VMCreateResult, VMProvider -from providers.capabilities import TemplateCapability +from providers.capabilities import RecordStep, TemplateCapability from providers.docker.api import DockerAPI from providers.docker.exceptions import DockerProviderError from providers.docker.images import build_derived_image @@ -188,18 +188,22 @@ async def build_template_image( base_image: str, setup_script: str, label: str, + record_step: RecordStep, ) -> str: image = await build_derived_image( self.docker, base_image=base_image, setup_script=setup_script, label=label, + record_step=record_step, ) # sbx keeps its own image store. The Docker image only carries the build. try: with tempfile.TemporaryDirectory() as directory: archive = Path(directory) / "template.tar" + await record_step("saving image") await self.docker.save_image(image, archive) + await record_step("loading into sbx") await self.api.load_template(archive) await self.docker.remove_image(image) except (OSError, DockerProviderError, DockerSbxProviderError) as exc: diff --git a/src/providers/docker_sbx/tests/test_provider.py b/src/providers/docker_sbx/tests/test_provider.py index c3febe7..7d9b185 100644 --- a/src/providers/docker_sbx/tests/test_provider.py +++ b/src/providers/docker_sbx/tests/test_provider.py @@ -36,7 +36,7 @@ def _api_mock() -> MagicMock: def _docker_mock() -> MagicMock: docker = MagicMock() - docker.build_image = AsyncMock() + docker.build_image = MagicMock() docker.remove_image = AsyncMock() docker.save_image = AsyncMock() return docker @@ -222,19 +222,26 @@ async def test_build_template_image_moves_the_built_image_into_sbx(tmp_path): docker = _docker_mock() provider = _provider(api, _settings(tmp_path), docker=docker) + record_step = AsyncMock() image = await provider.build_template_image( base_image="sandbox:base", setup_script="apt-get update", label="Node tools", + record_step=record_step, ) assert image.startswith("drukbox-template:") - assert docker.build_image.await_args.args[0] == image + assert docker.build_image.call_args.args[0] == image saved, archive = docker.save_image.await_args.args assert saved == image api.load_template.assert_awaited_once_with(archive) assert not archive.exists() docker.remove_image.assert_awaited_once_with(image) + assert [call.args[0] for call in record_step.await_args_list] == [ + "running setup script", + "saving image", + "loading into sbx", + ] @pytest.mark.asyncio @@ -248,6 +255,7 @@ async def test_build_template_image_translates_a_failed_load(tmp_path): base_image="sandbox:base", setup_script="apt-get update", label="Node tools", + record_step=AsyncMock(), ) diff --git a/src/providers/exe/provider.py b/src/providers/exe/provider.py index 4ffa35e..ad889dd 100644 --- a/src/providers/exe/provider.py +++ b/src/providers/exe/provider.py @@ -2,7 +2,7 @@ from core.settings import get_settings from providers.base import VMCreateResult, VMProvider -from providers.capabilities import TemplateCapability +from providers.capabilities import RecordStep, TemplateCapability from providers.docker.api import DockerAPI from providers.docker.exceptions import DockerProviderError from providers.docker.images import build_derived_image, remove_derived_image @@ -115,6 +115,7 @@ async def build_template_image( base_image: str, setup_script: str, label: str, + record_step: RecordStep, ) -> str: # exe.dev pulls every image from a registry, so a local template cannot boot. if not get_settings().template_repository: @@ -127,6 +128,7 @@ async def build_template_image( base_image=base_image, setup_script=setup_script, label=label, + record_step=record_step, ) async def delete_template_image(self, image: str) -> None: diff --git a/src/providers/exe/tests/test_provider.py b/src/providers/exe/tests/test_provider.py index 57e939c..996b71b 100644 --- a/src/providers/exe/tests/test_provider.py +++ b/src/providers/exe/tests/test_provider.py @@ -1,6 +1,6 @@ from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest from pydantic import SecretStr @@ -22,7 +22,7 @@ def _settings(**overrides: Any) -> ExeSettings: def _docker_mock() -> SimpleNamespace: return SimpleNamespace( - build_image=AsyncMock(), + build_image=MagicMock(), remove_image=AsyncMock(), push_image=AsyncMock(), aclose=AsyncMock(), @@ -101,7 +101,7 @@ async def test_create_vm_keeps_credentials_off_other_registry_hosts( await provider.create_vm(name="sb-1", image=image) - assert api.create_vm.await_args.kwargs["registry_auth"] is None + assert not api.create_vm.await_args.kwargs["registry_auth"] async def test_create_vm_omits_registry_auth_when_registry_is_not_configured() -> None: @@ -110,7 +110,7 @@ async def test_create_vm_omits_registry_auth_when_registry_is_not_configured() - await provider.create_vm(name="sb-1", image="img:latest") - assert api.create_vm.await_args.kwargs["registry_auth"] is None + assert not api.create_vm.await_args.kwargs["registry_auth"] async def test_delete_vm_delegates_to_api() -> None: @@ -187,10 +187,11 @@ async def test_build_template_image_builds_logs_in_and_pushes( base_image="exe/base:latest", setup_script="apt-get update", label="Node tools", + record_step=AsyncMock(), ) assert image.startswith("ghcr.io/acme/drukbox-templates:node-tools-") - assert docker.build_image.await_args.args[0] == image + assert docker.build_image.call_args.args[0] == image docker.push_image.assert_awaited_once_with(image, username="bot", password="secret") @@ -209,9 +210,10 @@ async def test_build_template_image_requires_a_template_repository( base_image="exe/base:latest", setup_script="apt-get update", label="Node tools", + record_step=AsyncMock(), ) - docker.build_image.assert_not_awaited() + docker.build_image.assert_not_called() async def test_build_template_image_translates_push_failure( @@ -231,6 +233,7 @@ async def test_build_template_image_translates_push_failure( base_image="exe/base:latest", setup_script="apt-get update", label="Node tools", + record_step=AsyncMock(), ) diff --git a/src/providers/tests/test_capabilities.py b/src/providers/tests/test_capabilities.py index 50c452f..285c868 100644 --- a/src/providers/tests/test_capabilities.py +++ b/src/providers/tests/test_capabilities.py @@ -9,7 +9,12 @@ from host_secrets.placeholder import Placeholder from providers.aws.provider import AWSProvider from providers.base import VMCreateResult, VMProvider -from providers.capabilities import ProxyInjection, TemplateCapability, resolve_capability +from providers.capabilities import ( + ProxyInjection, + RecordStep, + TemplateCapability, + resolve_capability, +) from providers.docker.provider import DockerProvider from providers.docker_sbx.provider import DockerSbxProvider from providers.environment import get_persist @@ -66,7 +71,9 @@ class StubTemplateProvider(StubProvider, TemplateCapability): async def refresh_base_image(self, image: str) -> str: return "stub@sha256:" + "a" * 64 - async def build_template_image(self, *, base_image: str, setup_script: str, label: str) -> str: + async def build_template_image( + self, *, base_image: str, setup_script: str, label: str, record_step: RecordStep + ) -> str: return f"{base_image}:{label}" async def delete_template_image(self, image: str) -> None: diff --git a/src/templates/models.py b/src/templates/models.py index 70e8c3c..34fcc45 100644 --- a/src/templates/models.py +++ b/src/templates/models.py @@ -39,6 +39,7 @@ class Template(Base): image: Mapped[str] = mapped_column(Text, default="") status: Mapped[str] = mapped_column(String(32), default=TemplateStatus.BUILDING.value) last_error: Mapped[str] = mapped_column(Text, default="") + build_step: Mapped[str] = mapped_column(Text, default="") created_at: Mapped[datetime] = mapped_column(UTCDateTime) updated_at: Mapped[datetime] = mapped_column(UTCDateTime) last_used_at: Mapped[datetime | None] = mapped_column( diff --git a/src/templates/schemas.py b/src/templates/schemas.py index 12f3efe..0ec9cc7 100644 --- a/src/templates/schemas.py +++ b/src/templates/schemas.py @@ -42,6 +42,7 @@ class TemplateOut(BaseModel): image: str status: str last_error: str + build_step: str created_at: datetime updated_at: datetime last_used_at: datetime | None diff --git a/src/templates/service.py b/src/templates/service.py index 0edb40c..11286ba 100644 --- a/src/templates/service.py +++ b/src/templates/service.py @@ -113,6 +113,10 @@ async def build(self, template_id: uuid.UUID) -> None: # Release the read transaction before a minutes-long provider build. await session.commit() + async def record_step(step: str) -> None: + template.build_step = step + await session.commit() + try: capability = resolve_capability( get_vm_provider(template.provider), @@ -122,6 +126,7 @@ async def build(self, template_id: uuid.UUID) -> None: base_image=template.base_image_ref, setup_script=template.setup_script, label=template.label, + record_step=record_step, ) except Exception as exc: logger.exception( @@ -135,6 +140,7 @@ async def build(self, template_id: uuid.UUID) -> None: template.image = image template.status = TemplateStatus.AVAILABLE.value template.last_error = "" + template.build_step = "" template.updated_at = utc_now() await session.commit() diff --git a/src/templates/tests/conftest.py b/src/templates/tests/conftest.py index bb36d16..58f7d66 100644 --- a/src/templates/tests/conftest.py +++ b/src/templates/tests/conftest.py @@ -4,7 +4,7 @@ from providers import registry as registry_module from providers.base import VMCreateResult, VMProvider -from providers.capabilities import TemplateCapability +from providers.capabilities import RecordStep, TemplateCapability from providers.docker.images import derive_image_name from providers.exceptions import ProviderError @@ -67,8 +67,10 @@ async def build_template_image( base_image: str, setup_script: str, label: str, + record_step: RecordStep, ) -> str: self.built.append((base_image, setup_script, label)) + await record_step("running setup script") if self.build_error: raise self.build_error return derive_image_name(base_image=base_image, setup_script=setup_script, label=label) diff --git a/src/templates/tests/test_api.py b/src/templates/tests/test_api.py index 626221c..50b513e 100644 --- a/src/templates/tests/test_api.py +++ b/src/templates/tests/test_api.py @@ -44,13 +44,14 @@ async def test_create_template_returns_building_then_becomes_available(client, t assert payload["image"] == "" assert payload["status"] == TemplateStatus.BUILDING.value assert payload["last_error"] == "" - assert payload["last_used_at"] is None + assert not payload["last_used_at"] assert "setup_script" not in payload polled = await client.get(f"/templates/{payload['id']}", headers=AUTH_HEADERS) assert polled.status_code == 200 assert polled.json()["status"] == TemplateStatus.AVAILABLE.value + assert polled.json()["build_step"] == "" assert polled.json()["image"] == derive_image_name( base_image=template_provider.base_image_ref, setup_script=SETUP_SCRIPT, @@ -76,6 +77,7 @@ async def test_unexpected_build_crash_is_pollable(client, template_provider): assert response.status_code == 202 assert polled.json()["status"] == TemplateStatus.FAILED.value assert polled.json()["last_error"] == "OSError: builder crashed" + assert polled.json()["build_step"] == "running setup script" async def test_unsupported_capability_refuses_template_creation(client, monkeypatch): @@ -276,7 +278,7 @@ async def test_delete_available_template_removes_provider_artifact(client, templ assert response.content == b"" assert template_provider.deleted == ["stub-template:available"] async with async_session_factory() as session: - assert await session.get(Template, template.id) is None + assert not await session.get(Template, template.id) async def test_delete_tolerates_missing_provider_artifact(client, template_provider): @@ -292,7 +294,7 @@ async def test_delete_tolerates_missing_provider_artifact(client, template_provi assert response.status_code == 204 async with async_session_factory() as session: - assert await session.get(Template, template.id) is None + assert not await session.get(Template, template.id) async def test_delete_provider_failure_preserves_record(client, template_provider): @@ -309,7 +311,7 @@ async def test_delete_provider_failure_preserves_record(client, template_provide assert response.status_code == 503 assert response.json()["detail"] == "template teardown could not be completed" async with async_session_factory() as session: - assert await session.get(Template, template.id) is not None + assert await session.get(Template, template.id) async def test_create_template_rejects_unknown_provider(client): From dfcb39aecf2445c7183187a3b9708e7aafdb8800 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:09:53 +0000 Subject: [PATCH 2/2] Follow the house rules in the touched files --- src/providers/capabilities.py | 1 + .../docker_sbx/tests/test_provider.py | 1 + src/providers/exe/provider.py | 27 ++++++---------- src/providers/exe/tests/test_provider.py | 2 ++ src/providers/tests/test_capabilities.py | 32 +++++++++---------- src/templates/schemas.py | 1 + 6 files changed, 31 insertions(+), 33 deletions(-) diff --git a/src/providers/capabilities.py b/src/providers/capabilities.py index 8928f1e..2844ed6 100644 --- a/src/providers/capabilities.py +++ b/src/providers/capabilities.py @@ -24,6 +24,7 @@ def resolve_capability(provider, capability: type[CapabilityT]) -> CapabilityT: + # ast-grep-ignore: rule-3 if not isinstance(provider, capability): raise CapabilityUnsupportedError( f"VM provider '{provider.name}' does not support {capability.__name__}", diff --git a/src/providers/docker_sbx/tests/test_provider.py b/src/providers/docker_sbx/tests/test_provider.py index 7d9b185..cf77032 100644 --- a/src/providers/docker_sbx/tests/test_provider.py +++ b/src/providers/docker_sbx/tests/test_provider.py @@ -172,6 +172,7 @@ def test_the_value_files_live_beside_the_workspaces(tmp_path): provider = _provider(_api_mock(), _settings(tmp_path)) injection = provider.secrets + # ast-grep-ignore: rule-3 assert isinstance(injection, SbxInjection) assert injection.secrets_root == provider.secrets_root == tmp_path / "secrets" diff --git a/src/providers/exe/provider.py b/src/providers/exe/provider.py index ad889dd..381b374 100644 --- a/src/providers/exe/provider.py +++ b/src/providers/exe/provider.py @@ -1,19 +1,12 @@ from typing import ClassVar, Self from core.settings import get_settings +from providers import exceptions from providers.base import VMCreateResult, VMProvider from providers.capabilities import RecordStep, TemplateCapability from providers.docker.api import DockerAPI from providers.docker.exceptions import DockerProviderError from providers.docker.images import build_derived_image, remove_derived_image -from providers.exceptions import ( - ProviderCommandError, - ProviderHttpProxyExistsError, - ProviderHttpProxyNotFoundError, - ProviderNotFoundError, - ProviderTargetVMNotFoundError, - ProviderTransportError, -) from providers.exe.api import ExeAPI from providers.exe.exceptions import ( ExeIntegrationAlreadyExistsError, @@ -99,7 +92,7 @@ async def delete_vm(self, name: str) -> None: try: await self.api.delete_vm(name) except ExeVMNotFoundError as exc: - raise ProviderNotFoundError(str(exc)) from exc + raise exceptions.ProviderNotFoundError(str(exc)) from exc async def refresh_base_image(self, image: str) -> str: try: @@ -107,7 +100,7 @@ async def refresh_base_image(self, image: str) -> str: image, registry_auth=get_settings().get_registry_auth(image) ) except DockerProviderError as exc: - raise ProviderTransportError(str(exc)) from exc + raise exceptions.ProviderTransportError(str(exc)) from exc async def build_template_image( self, @@ -119,7 +112,7 @@ async def build_template_image( ) -> str: # exe.dev pulls every image from a registry, so a local template cannot boot. if not get_settings().template_repository: - raise ProviderCommandError( + raise exceptions.ProviderCommandError( "Template repository is not configured. Set TEMPLATE_REPOSITORY." ) @@ -154,26 +147,26 @@ async def create_http_proxy( try: await self.api.create_http_proxy(name=name, target=target, headers=headers) except ExeIntegrationAlreadyExistsError as exc: - raise ProviderHttpProxyExistsError(str(exc)) from exc + raise exceptions.ProviderHttpProxyExistsError(str(exc)) from exc async def delete_http_proxy(self, name: str) -> None: try: await self.api.delete_http_proxy(name) except ExeIntegrationNotFoundError as exc: - raise ProviderHttpProxyNotFoundError(str(exc)) from exc + raise exceptions.ProviderHttpProxyNotFoundError(str(exc)) from exc async def attach_http_proxy(self, name: str, *, attach_vm: str) -> None: try: await self.api.attach_http_proxy(name, attach_vm=attach_vm) except ExeVMNotFoundError as exc: - raise ProviderTargetVMNotFoundError(str(exc)) from exc + raise exceptions.ProviderTargetVMNotFoundError(str(exc)) from exc except ExeIntegrationNotFoundError as exc: - raise ProviderHttpProxyNotFoundError(str(exc)) from exc + raise exceptions.ProviderHttpProxyNotFoundError(str(exc)) from exc async def detach_http_proxy(self, name: str, *, attach_vm: str) -> None: try: await self.api.detach_http_proxy(name, attach_vm=attach_vm) except ExeVMNotFoundError as exc: - raise ProviderTargetVMNotFoundError(str(exc)) from exc + raise exceptions.ProviderTargetVMNotFoundError(str(exc)) from exc except ExeIntegrationNotFoundError as exc: - raise ProviderHttpProxyNotFoundError(str(exc)) from exc + raise exceptions.ProviderHttpProxyNotFoundError(str(exc)) from exc diff --git a/src/providers/exe/tests/test_provider.py b/src/providers/exe/tests/test_provider.py index 996b71b..b594b0f 100644 --- a/src/providers/exe/tests/test_provider.py +++ b/src/providers/exe/tests/test_provider.py @@ -168,7 +168,9 @@ async def test_http_proxy_methods_delegate_to_api(method_name: str, kwargs: dict def test_from_settings_constructs_with_exeapi() -> None: provider = ExeProvider.from_settings() + # ast-grep-ignore: rule-3 assert isinstance(provider.api, ExeAPI) + # ast-grep-ignore: rule-3 assert isinstance(provider.docker, DockerAPI) diff --git a/src/providers/tests/test_capabilities.py b/src/providers/tests/test_capabilities.py index 285c868..0ed734b 100644 --- a/src/providers/tests/test_capabilities.py +++ b/src/providers/tests/test_capabilities.py @@ -7,14 +7,9 @@ from core.settings import get_settings from host_secrets.catalog import CATALOG from host_secrets.placeholder import Placeholder +from providers import capabilities from providers.aws.provider import AWSProvider from providers.base import VMCreateResult, VMProvider -from providers.capabilities import ( - ProxyInjection, - RecordStep, - TemplateCapability, - resolve_capability, -) from providers.docker.provider import DockerProvider from providers.docker_sbx.provider import DockerSbxProvider from providers.environment import get_persist @@ -31,7 +26,7 @@ class StubProvider(VMProvider): name = "stub" diagnose_hint = "check_stub" - secrets = ProxyInjection() + secrets = capabilities.ProxyInjection() @classmethod def from_settings(cls) -> "StubProvider": @@ -67,12 +62,17 @@ async def aclose(self) -> None: return -class StubTemplateProvider(StubProvider, TemplateCapability): +class StubTemplateProvider(StubProvider, capabilities.TemplateCapability): async def refresh_base_image(self, image: str) -> str: return "stub@sha256:" + "a" * 64 async def build_template_image( - self, *, base_image: str, setup_script: str, label: str, record_step: RecordStep + self, + *, + base_image: str, + setup_script: str, + label: str, + record_step: capabilities.RecordStep, ) -> str: return f"{base_image}:{label}" @@ -82,12 +82,12 @@ async def delete_template_image(self, image: str) -> None: def test_resolve_capability_returns_implementing_provider() -> None: provider = StubTemplateProvider() - assert resolve_capability(provider, TemplateCapability) is provider + assert capabilities.resolve_capability(provider, capabilities.TemplateCapability) is provider def test_resolve_capability_refuses_provider_without_capability() -> None: with pytest.raises(CapabilityUnsupportedError, match="'stub' does not support"): - resolve_capability(StubProvider(), TemplateCapability) + capabilities.resolve_capability(StubProvider(), capabilities.TemplateCapability) async def test_the_proxy_injection_hands_the_box_its_placeholder_the_proxy_and_the_ca( @@ -99,7 +99,7 @@ async def test_the_proxy_injection_hands_the_box_its_placeholder_the_proxy_and_t monkeypatch.setattr(get_settings(), "secrets_proxy_ca_file", str(certificate)) placeholder = Placeholder.mint(uuid.uuid4(), "anthropic") - environment = await ProxyInjection().put_secret( + environment = await capabilities.ProxyInjection().put_secret( vm="sb-one", service=CATALOG["anthropic"], placeholder=placeholder, value="" ) @@ -142,7 +142,7 @@ async def test_the_proxy_injection_hands_out_a_public_certificate_and_nothing_el monkeypatch.setattr(get_settings(), "secrets_proxy_ca_file", str(path)) with pytest.raises(ProviderCommandError, match=reason): - await ProxyInjection().put_secret( + await capabilities.ProxyInjection().put_secret( vm="sb-one", service=CATALOG["anthropic"], placeholder=Placeholder.mint(uuid.uuid4(), "anthropic"), @@ -151,9 +151,9 @@ async def test_the_proxy_injection_hands_out_a_public_certificate_and_nothing_el async def test_the_proxy_injection_holds_no_value_and_has_nothing_to_delete() -> None: - assert ProxyInjection.needs_value is False + assert capabilities.ProxyInjection.needs_value is False - await ProxyInjection().delete_secrets(vm="sb-one") + await capabilities.ProxyInjection().delete_secrets(vm="sb-one") @pytest.mark.parametrize( @@ -163,7 +163,7 @@ async def test_the_proxy_injection_holds_no_value_and_has_nothing_to_delete() -> def test_every_provider_but_docker_sbx_injects_through_the_proxy( provider: type[VMProvider], ) -> None: - assert type(provider.secrets) is ProxyInjection + assert type(provider.secrets) is capabilities.ProxyInjection def test_docker_sbx_declares_its_own_injection_per_instance() -> None: diff --git a/src/templates/schemas.py b/src/templates/schemas.py index 0ec9cc7..765ef69 100644 --- a/src/templates/schemas.py +++ b/src/templates/schemas.py @@ -19,6 +19,7 @@ class TemplateCreate(BaseModel): @field_validator("base_image") @classmethod def reject_blank_base_image(cls, value: str | None) -> str | None: + # ast-grep-ignore: rule-2 if value is not None and not value.strip(): raise ValueError("base_image must not be blank") return value