diff --git a/docs/architecture.md b/docs/architecture.md index 8085fcb..308fd5d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -174,9 +174,9 @@ returns `202 Accepted`. Callers poll until the template becomes and deletes its own templates behind `TemplateCapability`. A host request can name an available template by its ID — the ID that -the create returned. The template's image becomes the host image. An -explicit `image` -wins over the template, and the template wins over the provider default. +the create returned. The template's image becomes the host image in place +of the provider default. A request that names both an `image` and a +template fails with `422`. Host creation never builds a missing or unavailable template. It returns a client error, and the caller decides when to build. diff --git a/src/hosts/schemas.py b/src/hosts/schemas.py index f9bef97..2bf7911 100644 --- a/src/hosts/schemas.py +++ b/src/hosts/schemas.py @@ -1,9 +1,17 @@ import re import uuid from datetime import UTC, datetime -from typing import Annotated +from typing import Annotated, Self -from pydantic import AfterValidator, BaseModel, ConfigDict, Field, ValidationInfo, field_validator +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + ValidationInfo, + field_validator, + model_validator, +) from host_secrets import catalog from host_secrets.schemas import SECRET_NAME_PATTERN, SecretEntry @@ -30,7 +38,7 @@ class HostCreate(BaseModel): image: str | None = None template: uuid.UUID | None = Field( default=None, - description="Template ID to fork from. Used only when the request has no image.", + description="Template ID to fork from. A request names an image or a template, not both.", ) env: dict[str, str] = Field(default_factory=dict) secrets: dict[str, SecretEntry] = Field( @@ -96,6 +104,12 @@ def reject_reserved_env_keys(cls, env: dict[str, str]) -> dict[str, str]: environment.get_persist(env) return env + @model_validator(mode="after") + def reject_image_with_template(self) -> Self: + if self.image and self.template: + raise ValueError("provide an image or a template, not both") + return self + class HostOut(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/src/hosts/service.py b/src/hosts/service.py index b282c5f..0ef6a16 100644 --- a/src/hosts/service.py +++ b/src/hosts/service.py @@ -253,7 +253,7 @@ async def create_host( "SECRETS_PROXY_URL and SECRETS_PROXY_CA_FILE must name the proxy that " "sandboxes dial and its certificate" ) - if template and not image: + if template: image = await self._resolve_template_image(template_id=template, provider=vm.name) uid = uuid7() name = Host.build_name(uid) diff --git a/src/hosts/tests/test_templates.py b/src/hosts/tests/test_templates.py index f59c068..df5fa8d 100644 --- a/src/hosts/tests/test_templates.py +++ b/src/hosts/tests/test_templates.py @@ -127,10 +127,9 @@ async def test_create_host_rejects_unknown_template_id(client): assert response.json()["error_code"] == "UNKNOWN_TEMPLATE" -async def test_create_host_explicit_image_wins_without_touching_template(client, monkeypatch): - """An explicit image bypasses template resolution and leaves usage unstamped.""" - template = await create_template_record(image="derived:ignored") - monkeypatch.setattr("hosts.service.HostService.provision", AsyncMock()) +async def test_create_host_rejects_an_image_with_a_template(client): + """A request names one image source: both would silently drop one of them.""" + template = await create_template_record(image="derived:image") response = await client.post( "/hosts", @@ -138,12 +137,8 @@ async def test_create_host_explicit_image_wins_without_touching_template(client, json={"image": "explicit:image", "template": str(template.id)}, ) - assert response.status_code == 201 - assert response.json()["image"] == "explicit:image" - async with async_session_factory() as session: - untouched_template = await session.get(Template, template.id) - assert untouched_template is not None - assert untouched_template.last_used_at is None + assert response.status_code == 422 + assert "an image or a template" in response.text async def test_create_host_template_request_bypasses_pool(client, monkeypatch):