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
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 17 additions & 3 deletions src/hosts/schemas.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/hosts/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 5 additions & 10 deletions src/hosts/tests/test_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,23 +127,18 @@ 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",
headers=AUTH_HEADERS,
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):
Expand Down
Loading