diff --git a/src/agent_env/env/bootstrap.py b/src/agent_env/env/bootstrap.py index 5d42abff3..7cdfc1a59 100644 --- a/src/agent_env/env/bootstrap.py +++ b/src/agent_env/env/bootstrap.py @@ -15,6 +15,7 @@ from dataclasses import dataclass, field, replace from enum import Enum from pathlib import Path +from typing import Any from agent_env.artifact import DockerImageArtifact from agent_env.config import get_config @@ -54,22 +55,29 @@ def _quiet(line: str) -> None: pass -def put_gateway_env(env_id: str, *, platform: str | None, metadata: Mapping[str, str] | None = None, - say: Callable[[str], None] = _quiet, published: Mapping[Path, str] | None = None) -> GatewayEnv: +def put_gateway_env(env_id: str, *, platform: str | None, metadata: Mapping[str, Any] | None = None, + say: Callable[[str], None] = _quiet, published: Mapping[Path, str] | None = None, + version: int | None = None) -> GatewayEnv: """Write the gateway env ``env_id``, its image registered from ``published``, each Dockerfile's published digest, - when given, else built for ``platform`` (this host's when None).""" + when given, else built for ``platform`` (this host's when None). It's stored as ``version`` when given, which must + be the env's next: ConcurrentModificationError otherwise.""" + _check_version(version) artifact = _image("gateway", derive_id(env_id, "env_image"), "Created from agent-env CLI", GATEWAY_DOCKERFILE, GATEWAY_IMAGE_TAG, platform=platform, say=say, published=published) say("Creating GatewayEnv...") - env = GatewayEnv.put(id=env_id, docker_image_artifact=artifact, metadata=_metadata(GATEWAY, metadata)) + env = GatewayEnv.put(id=env_id, docker_image_artifact=artifact, metadata=_metadata(GATEWAY, metadata), + version=version) say(f"Created GatewayEnv: id={env.id} version={env.version}") return env -def put_service_db_env(env_id: str, *, platform: str | None, metadata: Mapping[str, str] | None = None, - say: Callable[[str], None] = _quiet, published: Mapping[Path, str] | None = None) -> ServiceDBEnv: +def put_service_db_env(env_id: str, *, platform: str | None, metadata: Mapping[str, Any] | None = None, + say: Callable[[str], None] = _quiet, published: Mapping[Path, str] | None = None, + version: int | None = None) -> ServiceDBEnv: """Write the service-db env ``env_id``, its service-db, db-web and db-mcp images registered from ``published``, each - Dockerfile's published digest, when given, else built for ``platform`` (this host's when None).""" + Dockerfile's published digest, when given, else built for ``platform`` (this host's when None). It's stored as + ``version`` when given, which must be the env's next: ConcurrentModificationError otherwise.""" + _check_version(version) db = _image("ServiceDB", derive_id(env_id, "db_image"), f"ServiceDB PostgreSQL image for {env_id}", SERVICE_DB_DOCKERFILE, SERVICE_DB_IMAGE_NAME, platform=platform, say=say, published=published) db_web = _image("db-web", derive_id(env_id, "db_web_image"), "db-web lightweight web UI for database inspection", @@ -78,26 +86,35 @@ def put_service_db_env(env_id: str, *, platform: str | None, metadata: Mapping[s DB_MCP_DOCKERFILE, DB_MCP_IMAGE_NAME, platform=platform, say=say, published=published) say("Creating ServiceDBEnv...") env = ServiceDBEnv.put(id=env_id, db_docker_image_artifact=db, db_web_docker_image_artifact=db_web, - db_mcp_docker_image_artifact=db_mcp, metadata=_metadata(SERVICE_DB, metadata)) + db_mcp_docker_image_artifact=db_mcp, metadata=_metadata(SERVICE_DB, metadata), + version=version) say(f"Created ServiceDBEnv: id={env.id} version={env.version}") return env -def put_website_browser_env(env_id: str, *, platform: str | None, metadata: Mapping[str, str] | None = None, - say: Callable[[str], None] = _quiet, - published: Mapping[Path, str] | None = None) -> MCPServerEnv: +def put_website_browser_env(env_id: str, *, platform: str | None, metadata: Mapping[str, Any] | None = None, + say: Callable[[str], None] = _quiet, published: Mapping[Path, str] | None = None, + version: int | None = None) -> MCPServerEnv: """Write the website browser as the MCP server env ``env_id``, its image registered from ``published``, each - Dockerfile's published digest, when given, else built for ``platform`` (this host's when None).""" + Dockerfile's published digest, when given, else built for ``platform`` (this host's when None). It's stored as + ``version`` when given, which must be the env's next: ConcurrentModificationError otherwise.""" + _check_version(version) artifact = _image("website browser", derive_id(env_id, "env_image"), "Website browser MCP server", WEBSITE_BROWSER_DOCKERFILE, WEBSITE_BROWSER_IMAGE_TAG, platform=platform, say=say, published=published) say("Creating MCPServerEnv...") env = MCPServerEnv.put(id=env_id, docker_image_artifact=artifact, environment_name=WEBSITE_BROWSER_ENVIRONMENT_NAME, - metadata=_metadata(WEBSITE_BROWSER, metadata)) + metadata=_metadata(WEBSITE_BROWSER, metadata), version=version) say(f"Created MCPServerEnv: id={env.id} version={env.version} environment_name={env.environment_name}") return env +def _check_version(version: int | None) -> None: + """Refuse a version below 1 before anything is written: the store reads 0 as "the next free version".""" + if version is not None and version < 1: + raise ValueError(f"an env's versions start at 1, so it can't be stored as version {version}") + + def _image(label: str, artifact_id: str, description: str, dockerfile: Path, image_name: str, *, platform: str | None, say: Callable[[str], None], published: Mapping[Path, str] | None) -> DockerImageArtifact: """``dockerfile``'s image, written as ``artifact_id`` with the folder it's in as its build context: registered as @@ -124,7 +141,7 @@ def _build_and_put(label: str, artifact_id: str, description: str, dockerfile: P return artifact -def _metadata(kind: str, extra: Mapping[str, str] | None) -> dict[str, str]: +def _metadata(kind: str, extra: Mapping[str, Any] | None) -> dict[str, Any]: dockerfile = _DOCKERFILES[kind][0] return {**detect_env_metadata(dockerfile, dockerfile.parent), **(extra or {}), BUILD_INPUTS_KEY: build_inputs_digest(kind)} diff --git a/tst/unit/env/bootstrap_test.py b/tst/unit/env/bootstrap_test.py index fa1dcb35d..3d7b4771b 100644 --- a/tst/unit/env/bootstrap_test.py +++ b/tst/unit/env/bootstrap_test.py @@ -14,9 +14,15 @@ from agent_env.env import GatewayEnv from agent_env.env import bootstrap from agent_env.env.bootstrap import GATEWAY, SERVICE_DB, WEBSITE_BROWSER, InfraError, ensure_default_envs +from agent_env.env.env import Env +from agent_env.store.base import ConcurrentModificationError _DIGEST = "sha256:" + "5" * 64 +_INFRA_PUTS = pytest.mark.parametrize("kind, put", [ + (GATEWAY, bootstrap.put_gateway_env), (SERVICE_DB, bootstrap.put_service_db_env), + (WEBSITE_BROWSER, bootstrap.put_website_browser_env), +], ids=[GATEWAY, SERVICE_DB, WEBSITE_BROWSER]) @pytest.fixture @@ -42,10 +48,7 @@ def write(env_id, *, platform, metadata=None, published=None): return calls -@pytest.mark.parametrize("kind, put", [ - (GATEWAY, bootstrap.put_gateway_env), (SERVICE_DB, bootstrap.put_service_db_env), - (WEBSITE_BROWSER, bootstrap.put_website_browser_env), -], ids=[GATEWAY, SERVICE_DB, WEBSITE_BROWSER]) +@_INFRA_PUTS def test_each_infra_image_keeps_the_build_context_it_is_built_from(local_stores, monkeypatch, kind, put): """So a VM or Modal running another platform than this machine builds it again from that; and those contexts are what its recorded build inputs cover. Each Dockerfile is named by its whole path: a bare name is looked up from @@ -257,6 +260,38 @@ def test_a_published_image_is_registered_by_its_tag_and_digest_with_the_context_ assert bootstrap._built_from_stock(env, GATEWAY) +@_INFRA_PUTS +def test_a_put_that_names_a_version_writes_only_that_next_one(local_stores, kind, put): + """So a promotion that read the env at one version writes nothing over a version someone put meanwhile.""" + published = {dockerfile: _DIGEST for dockerfile in bootstrap._DOCKERFILES[kind]} + put("x", platform=None, published=published) + + assert put("x", platform=None, published=published, version=2).version == 2 + with pytest.raises(ConcurrentModificationError): + put("x", platform=None, published=published, version=2) + assert Env.get("x").version == 2 + + +@_INFRA_PUTS +@pytest.mark.parametrize("version", [0, -1]) +def test_a_put_refuses_a_version_below_1_before_writing_anything(local_stores, monkeypatch, kind, put, version): + """The store would read 0 as "the next free version", and write one the caller didn't name.""" + monkeypatch.setattr(bootstrap, "_image", lambda *args, **kwargs: pytest.fail("wrote an image")) + + with pytest.raises(ValueError, match=f"can't be stored as version {version}"): + put("x", platform=None, published={}, version=version) + + +def test_a_put_keeps_metadata_that_isnt_a_string(local_stores): + """Such as the record a promotion keeps on the version it wrote.""" + stamp = {"stage": "dev", "passed": True, "previous": 1} + + env = bootstrap.put_gateway_env("default", platform=None, published={bootstrap.GATEWAY_DOCKERFILE: _DIGEST}, + metadata={"promotion": stamp}) + + assert Env.get("default", env.version).metadata["promotion"] == stamp + + def test_a_published_image_is_registered_from_its_own_dockerfile_whatever_folder_the_run_is_in(local_stores, tmp_path, monkeypatch): """A run started from a folder holding a Dockerfile of its own registers the gateway's, with its sources."""