From c9fef656ceeec4f3d7130fab5397fecd6ffd3dba Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 10:49:31 -0700 Subject: [PATCH 1/6] feat(bundle): keep what an interrupted run wrote, and rebuild an image when an executable bit changes Three changes to the bundle ledger, which envs written from env.toml will build on. A version an interrupted run wrote is kept. The ledger now stamps a write's version on its pending row as soon as the write returns, before it re-checks what the write held, which can take seconds. The next run of the bundle keeps that version, rather than writing it again, when it's still the store's latest and its inputs haven't changed. The run and the dry run report it as "unchanged (written by an interrupted run that didn't record it)". A version someone else wrote since is written over, as before. A built image's executable bits are inputs. A build copies each file's mode into the image, so a chmod alone now rebuilds it. A file with no executable bit is hashed as before, so the images a bundle built without one are still reused. One helper, unpinned_store_refs, names the store refs a write gives no version. Its writer pins them and the ledger hashes them from the same mapping, for every kind whose writer pins, not only envs and agents. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/ledger.py | 55 ++++++++++++---- src/agent_env/bundle/materialize.py | 9 ++- src/agent_env/bundle/plan.py | 10 +++ src/agent_env/bundle/run.py | 4 +- tst/unit/bundle/ledger_test.py | 97 ++++++++++++++++++++++++++++- tst/unit/bundle/materialize_test.py | 35 +++++++++++ 6 files changed, 194 insertions(+), 16 deletions(-) diff --git a/src/agent_env/bundle/ledger.py b/src/agent_env/bundle/ledger.py index 0b7eefca..f7c83337 100644 --- a/src/agent_env/bundle/ledger.py +++ b/src/agent_env/bundle/ledger.py @@ -32,7 +32,7 @@ from .authoring import build_context_files, entry_files from .parse import Bundle, BundleKind -from .plan import Plan, Write, folder_walk, keeps_base_from_toml +from .plan import Plan, Write, folder_walk, keeps_base_from_toml, unpinned_store_refs from .resolve import BuiltImage LEDGER_COLLECTION = "bundle_ledger" @@ -61,6 +61,7 @@ class Check: reasons: tuple[str, ...] stored: int | None # the store's latest version of the id when checked needs: Mapping[tuple[str, str], int] # the version hashed for each earlier write it needs, by (store, id) + adopted: bool = False # ``version`` was written by an interrupted run of this bundle, which didn't record it @property def unchanged(self) -> bool: @@ -93,6 +94,10 @@ def check(self, write: Write, needs: Mapping[tuple[str, str], int]) -> Check: if digest is None: untracked = ("its inputs aren't tracked yet, so it is written every run",) return Check(write, None, None, untracked, stored, needs) + orphan = self._orphan(write, stored, digest) + if orphan is not None: + return Check(write, digest, orphan, ("written by an interrupted run that didn't record it",), stored, needs, + adopted=True) row = self._latest(write.kind.store, write.id) recorded = row["version"] if row else None reasons = [] @@ -115,24 +120,26 @@ def digest(self, write: Write, needs: Mapping[tuple[str, str], int]) -> Digest | inputs = {"type": _type(write), "config": _sha256(_canonical(config)), "files": {}, "needs": {}, "store_refs": {}} if write.kind is BundleKind.ARTIFACT: - files = build_context_files if isinstance(write.source, BuiltImage) else entry_files - for key, path in files(self._plan.bundle.bundle, write.source.entry).items(): - inputs["files"][key] = _file_sha256(path) + built = isinstance(write.source, BuiltImage) + for key, path in (build_context_files if built else entry_files)(self._plan.bundle.bundle, + write.source.entry).items(): + # A build copies each file's mode into the image, so an executable bit is part of what it's made from. + inputs["files"][key] = _file_sha256(path) + (_EXECUTABLE if built and _executable(path) else "") elif write.kind in (BundleKind.ENV, BundleKind.AGENT): - # An env's or agent's document records the versions of what it references, so one written anew, or - # a store entity it names without a version getting a new one, means it must be written again. A - # task or eval names its references without a version. + # An env's or agent's document records the versions of what it references, so one written anew means + # it must be written again. A task or eval names its references without a version. for store, id in write.needs: inputs["needs"][f"{store} {id}"] = str(needs[store, id]) - for ref in write.source.references: - if ref.local is None and ref.version is None: - inputs["store_refs"][f"{ref.kind} {ref.id}"] = str(self._plan.store_latest[ref.kind, ref.id]) + if write.kind not in (BundleKind.TASK, BundleKind.EVAL): + for (kind, id), version in unpinned_store_refs(self._plan, write).items(): + inputs["store_refs"][f"{kind} {id}"] = str(version) value = _sha256(_canonical({"scheme": SCHEME, "store": write.kind.store, "id": write.id, "inputs": inputs})) return Digest(value, inputs) def record(self, check: Check, write: Callable[[], int]) -> int: """Write ``check``'s entity with ``write``, which returns the version it wrote, and record it. - A pending row marks the attempt until it's done; one a crash of this bundle left is dropped here.""" + A pending row marks the attempt until it's done, with its version once ``write`` returns. One an + interrupted run of this bundle left is kept by ``check`` when its version can be, and dropped here.""" key = {"store": check.write.kind.store, "id": check.write.id, "bundle": self._bundle} self._store.ensure_index(LEDGER_COLLECTION, ["store", "id", "status"]) while self._store.delete(LEDGER_COLLECTION, Filter.of(**key, status="pending")): @@ -142,6 +149,9 @@ def record(self, check: Check, write: Callable[[], int]) -> int: "inputs": check.digest.inputs if check.digest else None, "at": _now()} self._store.insert(LEDGER_COLLECTION, pending) version = write() + # Stamped before the check below, which can take seconds, so a run interrupted during it leaves the version + # this attempt wrote, and the next run keeps it rather than writing it again. + self._store.replace(LEDGER_COLLECTION, Filter.of(**key, status="pending"), {**pending, "version": version}) done = {**pending, "status": "done", "version": version, "at": _now()} if check.digest is not None and self.digest(check.write, check.needs) != check.digest: # A file changed during the write, so what the version holds is unknown: the next run writes it again. @@ -149,6 +159,22 @@ def record(self, check: Check, write: Callable[[], int]) -> int: self._store.replace(LEDGER_COLLECTION, Filter.of(**key, status="pending"), done, upsert=True) return version + def adopt(self, check: Check) -> None: + """Record the version an interrupted run wrote, which ``check`` keeps, as that run would have.""" + key = {"store": check.write.kind.store, "id": check.write.id, "bundle": self._bundle} + done = {**key, "status": "done", "scheme": SCHEME, "digest": check.digest.value, "inputs": check.digest.inputs, + "version": check.version, "at": _now()} + self._store.replace(LEDGER_COLLECTION, Filter.of(**key, status="pending"), done, upsert=True) + + def _orphan(self, write: Write, stored: int | None, digest: Digest) -> int | None: + """The version an interrupted run of this bundle wrote and didn't record, when it's still the store's + latest and was made from what ``write`` is now made from.""" + pending = self._find(LEDGER_COLLECTION, Filter.of(store=write.kind.store, id=write.id, bundle=self._bundle, + status="pending")) + if pending is None or pending.get("version") is None or pending["version"] != stored: + return None + return stored if (pending["scheme"], pending["digest"]) == (SCHEME, digest.value) else None + def _latest(self, store: str, id: str) -> dict | None: return self._find(LEDGER_COLLECTION, Filter.of(store=store, id=id, status="done")) @@ -245,6 +271,13 @@ def _sha256(data: bytes) -> str: return "sha256:" + hashlib.sha256(data).hexdigest() +_EXECUTABLE = "+x" + + +def _executable(path: Path) -> bool: + return bool(path.stat().st_mode & 0o111) + + def _file_sha256(path: Path) -> str: digest = hashlib.sha256() with open(path, "rb") as file: diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index c4fefd60..84bc69c3 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -38,7 +38,7 @@ from .authoring import AuthoringContext, build_context_files from .ledger import Ledger, materializing from .parse import BundleError, BundleKind -from .plan import Plan, Write, folder_walk +from .plan import Plan, Write, folder_walk, unpinned_store_refs from .resolve import BuiltImage, build_step @dataclass(frozen=True) @@ -99,6 +99,8 @@ def through_ledger(write: Write, write_fn: Callable[[], int]) -> None: check = ledger.check(write, {need: done[need].version for need in write.needs}) if check.unchanged: version = check.version + if check.adopted and not dry_run: + ledger.adopt(check) elif dry_run: version = check.next_version else: @@ -184,10 +186,11 @@ def _write_agent(plan: Plan, write: Write) -> int: def _pinned(plan: Plan, write: Write, refs: tuple[EntityRef, ...]) -> Any: """A copy of ``write``'s resolved toml with each store ref that names no version pinned to the version - the plan checked, which the ledger hashed, so one the store gains before the write isn't written.""" + the plan read, which the ledger hashed (``unpinned_store_refs``).""" config = copy.deepcopy(write.source.config) + pins = unpinned_store_refs(plan, write) for site in ref_sites(refs, config, inline_pins=True): - planned = plan.store_latest.get((site.ref.kind, site.value)) if site.version is None else None + planned = pins.get((site.ref.kind, site.value)) if site.version is None else None if planned is None: continue if site.version_key is None: diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index b9ba3358..4d3e48fb 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -341,6 +341,16 @@ def _path(self, entry: BundleEntry) -> str: return relative(self.resolved.bundle.root, entry.path) +def unpinned_store_refs(plan: Plan, write: Write) -> dict[tuple[EntityKind, str], int]: + """The version the plan read for each store entity ``write`` names without a version, by (kind, id). Its writer + pins each to that version and the ledger hashes them, so a version the store gains in between isn't written, + and one it gains later rewrites ``write``.""" + if isinstance(write.source, BuiltImage): + return {} + return {(ref.kind, ref.id): plan.store_latest[ref.kind, ref.id] + for ref in write.source.references if ref.local is None and ref.version is None} + + def folder_walk(cls: type | None) -> Any: """The listing a type's write runs: a subclass keeping its base's ``from_toml`` lists the same way, one that overrides it may read its folder differently, so it gets none.""" diff --git a/src/agent_env/bundle/run.py b/src/agent_env/bundle/run.py index c4bc7e8b..93f00fa8 100644 --- a/src/agent_env/bundle/run.py +++ b/src/agent_env/bundle/run.py @@ -422,7 +422,9 @@ def _task_run_command(ref: Reference) -> str: def _written(plan: Plan, done: Materialized) -> str: what = f"{_label(plan, done.write)}: v{done.version}" - return f"{what}, unchanged" if done.reused else f"{what} ({'; '.join(done.reasons)})" + if done.reused: + return f"{what}, unchanged" + (f" ({'; '.join(done.reasons)})" if done.reasons else "") + return f"{what} ({'; '.join(done.reasons)})" def _label(plan: Plan, write: Write) -> str: diff --git a/tst/unit/bundle/ledger_test.py b/tst/unit/bundle/ledger_test.py index fa647abe..ba87d653 100644 --- a/tst/unit/bundle/ledger_test.py +++ b/tst/unit/bundle/ledger_test.py @@ -1,6 +1,7 @@ """The bundle ledger: a re-run reuses every version whose inputs haven't changed, and says why the rest are written anew.""" +import hashlib import json import shutil import subprocess @@ -11,13 +12,15 @@ import pytest +from agent_env.a2a_agent import A2AAgent from agent_env.artifact.artifacts.file import FileArtifact from agent_env.bundle import parse_bundle from agent_env.bundle import plan as plan_module from agent_env.bundle import resolve as resolve_module from agent_env.bundle.ledger import LEDGER_COLLECTION, Ledger, materializing +from agent_env.bundle.materialize import _pinned from agent_env.config import configure, get_config -from agent_env.entity_refs import EntityRef +from agent_env.entity_refs import EntityKind, EntityRef from agent_env.env.env import Env from agent_env.store import Filter, Sort, UpdateSpec from tst.unit.bundle._support import RefusingStore, local_store, plan_of @@ -123,6 +126,8 @@ def _record_changed(ledger, writes): checks, versions = {}, {} for write in writes: check = ledger.check(write, {need: versions[need] for need in write.needs}) + if check.adopted: + ledger.adopt(check) version = check.version if check.unchanged else ledger.record(check, lambda: _written(write)) versions[write.kind.store, write.id] = version checks[write.id] = check @@ -379,6 +384,21 @@ def test_an_agent_is_rewritten_when_its_toml_changes_or_its_unpinned_store_image assert checks[f"{ROOT}/t"].unchanged +def test_a_store_ref_named_without_a_version_is_pinned_at_the_version_the_ledger_hashes(bundle_dir): + get_config().get_document_store().insert("artifacts", _image_document(3)) + layout = {"agents/solver/agent.toml": 'image = "claude-image"\n', + "tasks/t.json": _steps({"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"})} + for rel, text in layout.items(): + (bundle_dir / rel).parent.mkdir(parents=True, exist_ok=True) + (bundle_dir / rel).write_text(text) + plan = plan_of(bundle_dir) + agent = next(write for write in plan.writes if write.id == f"{ROOT}/solver") + + assert plan_module.unpinned_store_refs(plan, agent) == {(EntityKind.ARTIFACT, "claude-image"): 3} + assert _pinned(plan, agent, A2AAgent.toml_refs)["image"] == {"artifact": "claude-image", "version": 3} + assert Ledger.for_plan(plan).digest(agent, {}).inputs["store_refs"] == {"artifact claude-image": "3"} + + def _image_document(version): return {"id": "claude-image", "version": version, "type": "docker_image", "description": "claude", "image_name": f"claude:v{version}", "tar_gz_s3_url": f"file:///claude-v{version}.tar.gz"} @@ -443,6 +463,81 @@ def test_a_built_image_is_made_from_every_file_of_its_folder_the_toml_too(bundle assert not rebuilt[agent].unchanged +def test_a_built_images_executable_bits_are_inputs_and_a_file_artifacts_arent(bundle_dir): + (bundle_dir / "agents/solver").mkdir(parents=True) + (bundle_dir / "agents/solver/Dockerfile").write_text("FROM scratch\nCOPY run.sh /\n") + run_sh = bundle_dir / "agents/solver/run.sh" + run_sh.write_text("echo hi\n") + (bundle_dir / "tasks/t.json").write_text( + _steps({"id": "agent", "type": "deploy_agent", "env_ids": ["tickets"], "a2a_agent_id": "solver"})) + image = f"{ROOT}/solver__agent_image" + plan = plan_of(bundle_dir) + built = next(write for write in plan.writes if write.id == image) + # A file with no executable bit is hashed as its content alone, as before, so earlier rows stay reused. + assert Ledger.for_plan(plan).digest(built, {}).inputs["files"]["run.sh"] == _file_digest(run_sh) + _run(bundle_dir) + + run_sh.chmod(0o755) + (bundle_dir / "artifacts/greeting/hello.txt").chmod(0o755) + checks = _run(bundle_dir) + + assert checks[image].reasons == ("files changed: run.sh",) + assert checks[GREETING].unchanged + assert _run(bundle_dir)[image].unchanged + + +def _file_digest(path): + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + +def _interrupted_after_its_write(ledger, write, monkeypatch): + """Record ``write`` as a run does, interrupted once its write has returned, while the ledger checks what + the write held.""" + def interrupted(*_): + raise KeyboardInterrupt + + check = ledger.check(write, {}) + monkeypatch.setattr(ledger, "digest", interrupted) + with pytest.raises(KeyboardInterrupt): + ledger.record(check, lambda: _written(write)) + + +def test_a_version_an_interrupted_run_wrote_is_kept_and_recorded_not_written_again(bundle_dir, monkeypatch): + plan = plan_of(bundle_dir) + write = next(w for w in plan.writes if w.id == GREETING) + _interrupted_after_its_write(Ledger.for_plan(plan), write, monkeypatch) + + predicted = _checked(Ledger.for_plan(plan), plan.writes)[GREETING] + kept = _record_changed(Ledger.for_plan(plan), plan.writes)[GREETING] + + for check in (predicted, kept): + assert (check.version, check.adopted) == (1, True) + assert check.reasons == ("written by an interrupted run that didn't record it",) + assert _latest_version("artifacts", GREETING) == 1 + after = _record_changed(Ledger.for_plan(plan), plan.writes)[GREETING] + assert (after.version, after.adopted, after.reasons) == (1, False, ()) + assert [row["status"] for row in local_store().query(LEDGER_COLLECTION, Filter.of(id=GREETING))] == ["done"] + + +@pytest.mark.parametrize("since", ["another-write", "an-edit"]) +def test_an_interrupted_runs_version_is_written_over_once_the_store_or_the_files_have_moved_on( + bundle_dir, monkeypatch, since, +): + plan = plan_of(bundle_dir) + write = next(w for w in plan.writes if w.id == GREETING) + _interrupted_after_its_write(Ledger.for_plan(plan), write, monkeypatch) + if since == "another-write": + _written(write) + else: + (bundle_dir / "artifacts/greeting/hello.txt").write_text("edited\n") + + check = Ledger.for_plan(plan).check(write, {}) + + stored = 2 if since == "another-write" else 1 + assert not check.unchanged and not check.adopted + assert check.reasons == (f"the store's latest, v{stored}, wasn't recorded by this bundle",) + + @pytest.mark.parametrize("other", [ "the-same-folder", "another-folder-with-its-id-root", "another-bundle-declaring-its-id", ]) diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index 6a082343..c912d39b 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -21,6 +21,8 @@ from agent_env.bundle import materialize as materialize_module from agent_env.bundle.ledger import LEDGER_COLLECTION, Ledger from agent_env.bundle.materialize import materialize +from agent_env.bundle.parse import BundleKind +from agent_env.bundle.run import _written from agent_env.config import configure from agent_env.config.paths import state_root from agent_env.config.runtime import Config @@ -712,6 +714,39 @@ def agree(): assert agree()[f"{ROOT}/greeting"] == (3, False, ("the store's latest, v2, wasn't recorded by this bundle",)) +def test_a_version_a_run_wrote_before_it_was_interrupted_is_kept_by_the_next_run_and_its_dry_run( + bundle_dir, monkeypatch, +): + interrupted, write_artifact, digest = [], materialize_module._WRITERS[BundleKind.ARTIFACT], Ledger.digest + + def written(plan, write): # the greeting's write lands, and then the run is interrupted + version = write_artifact(plan, write) + interrupted.append(write.id == f"{ROOT}/greeting") + return version + + def digest_until_interrupted(self, *args): + if any(interrupted): + raise KeyboardInterrupt + return digest(self, *args) + + monkeypatch.setitem(materialize_module._WRITERS, BundleKind.ARTIFACT, written) + monkeypatch.setattr(Ledger, "digest", digest_until_interrupted) + with pytest.raises(KeyboardInterrupt): + _run(bundle_dir) + monkeypatch.setitem(materialize_module._WRITERS, BundleKind.ARTIFACT, write_artifact) + monkeypatch.setattr(Ledger, "digest", digest) + + kept = (1, True, ("written by an interrupted run that didn't record it",)) + assert _summary(_run(bundle_dir, dry_run=True))[f"{ROOT}/greeting"] == kept + done = _run(bundle_dir) + assert _summary(done)[f"{ROOT}/greeting"] == kept + greeting = next(item for item in done.writes if item.write.id == f"{ROOT}/greeting") + assert _written(done.plan, greeting) == ( + "artifacts/greeting: v1, unchanged (written by an interrupted run that didn't record it)") + assert _summary(_run(bundle_dir))[f"{ROOT}/greeting"] == (1, True, ()) + assert [d["version"] for d in local_store().query("artifacts", Filter.of(id=f"{ROOT}/greeting"))] == [1] + + def test_a_dry_run_calls_no_writer_and_takes_no_lock(bundle_dir, monkeypatch): layout(bundle_dir, {"evals/regression.toml": 'tasks = ["t"]\n'}) From 86b6710b894c3d4d6a6a09f620f54c6f9043fa11 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 11:23:42 -0700 Subject: [PATCH 2/6] fix(bundle): hash a built image's permission bits, not only whether a file is executable A build copies each file's permission bits into the image, so 0755 to 0700 changes what runs in it, but both were hashed as executable. A file's mode now joins its digest whenever it isn't the usual 0644, so images built from such files are reused as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/ledger.py | 13 ++++++++----- tst/unit/bundle/ledger_test.py | 17 ++++++++++------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/agent_env/bundle/ledger.py b/src/agent_env/bundle/ledger.py index f7c83337..49a05400 100644 --- a/src/agent_env/bundle/ledger.py +++ b/src/agent_env/bundle/ledger.py @@ -10,6 +10,7 @@ import hashlib import json +import stat from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager from dataclasses import dataclass @@ -123,8 +124,7 @@ def digest(self, write: Write, needs: Mapping[tuple[str, str], int]) -> Digest | built = isinstance(write.source, BuiltImage) for key, path in (build_context_files if built else entry_files)(self._plan.bundle.bundle, write.source.entry).items(): - # A build copies each file's mode into the image, so an executable bit is part of what it's made from. - inputs["files"][key] = _file_sha256(path) + (_EXECUTABLE if built and _executable(path) else "") + inputs["files"][key] = _file_sha256(path) + (_mode(path) if built else "") elif write.kind in (BundleKind.ENV, BundleKind.AGENT): # An env's or agent's document records the versions of what it references, so one written anew means # it must be written again. A task or eval names its references without a version. @@ -271,11 +271,14 @@ def _sha256(data: bytes) -> str: return "sha256:" + hashlib.sha256(data).hexdigest() -_EXECUTABLE = "+x" +# A build copies each file's permission bits into the image, so they're part of what it's made from; a file with the +# usual rw-r--r-- is hashed as its content alone. +_USUAL_MODE = 0o644 -def _executable(path: Path) -> bool: - return bool(path.stat().st_mode & 0o111) +def _mode(path: Path) -> str: + mode = stat.S_IMODE(path.stat().st_mode) + return "" if mode == _USUAL_MODE else f"+{mode:o}" def _file_sha256(path: Path) -> str: diff --git a/tst/unit/bundle/ledger_test.py b/tst/unit/bundle/ledger_test.py index ba87d653..c66e9820 100644 --- a/tst/unit/bundle/ledger_test.py +++ b/tst/unit/bundle/ledger_test.py @@ -463,26 +463,29 @@ def test_a_built_image_is_made_from_every_file_of_its_folder_the_toml_too(bundle assert not rebuilt[agent].unchanged -def test_a_built_images_executable_bits_are_inputs_and_a_file_artifacts_arent(bundle_dir): +def test_a_built_images_permission_bits_are_inputs_and_a_file_artifacts_arent(bundle_dir): (bundle_dir / "agents/solver").mkdir(parents=True) (bundle_dir / "agents/solver/Dockerfile").write_text("FROM scratch\nCOPY run.sh /\n") run_sh = bundle_dir / "agents/solver/run.sh" run_sh.write_text("echo hi\n") + run_sh.chmod(0o644) (bundle_dir / "tasks/t.json").write_text( _steps({"id": "agent", "type": "deploy_agent", "env_ids": ["tickets"], "a2a_agent_id": "solver"})) image = f"{ROOT}/solver__agent_image" plan = plan_of(bundle_dir) built = next(write for write in plan.writes if write.id == image) - # A file with no executable bit is hashed as its content alone, as before, so earlier rows stay reused. + # A file with the usual rw-r--r-- is hashed as its content alone, as before, so earlier rows stay reused. assert Ledger.for_plan(plan).digest(built, {}).inputs["files"]["run.sh"] == _file_digest(run_sh) _run(bundle_dir) - run_sh.chmod(0o755) - (bundle_dir / "artifacts/greeting/hello.txt").chmod(0o755) - checks = _run(bundle_dir) + rebuilt = [] + for mode in (0o755, 0o700): + run_sh.chmod(mode) + (bundle_dir / "artifacts/greeting/hello.txt").chmod(mode) + rebuilt.append(_run(bundle_dir)) - assert checks[image].reasons == ("files changed: run.sh",) - assert checks[GREETING].unchanged + assert [checks[image].reasons for checks in rebuilt] == [("files changed: run.sh",)] * 2 + assert all(checks[GREETING].unchanged for checks in rebuilt) assert _run(bundle_dir)[image].unchanged From 0cf11119de2ae261132dac6244762dd6c9d2fd27 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 11:19:14 -0700 Subject: [PATCH 3/6] feat(bundle): write MCP server, website and multi envs from their env.toml An env folder becomes an env: an mcp_server (a Dockerfile alone is enough), a website or a multi, written over the images built from the folder or named in the store. - Images: an image key left out builds the folder's Dockerfile, or Dockerfile.backend and Dockerfile.frontend for a website, as __env_image or __backend_image / __frontend_image; { dockerfile = "..." } names another; an image table takes only dockerfile. - environment_name: env.toml's, else the one @environment_card(name=...) in the source the image is built from, else refused. A store image needs it set. - env.toml takes a closed set of keys per type and no [metadata]; env_provider_type is checked as the put commands check it, and a name a gateway deploy gives its own containers is refused. - A multi's mcp_server_envs and website_envs are typed refs, checked before any write for bundle and store envs alike, and two of its MCP servers or websites can't share a name. - gateway_server and service_db folders are refused: config names those, and a run builds them. - An env folder whose id is another type of env in the store is refused before any write. - The env types the core's from_toml writes are tracked by the ledger, so an unchanged env is reused and one whose image or child is written anew is rewritten. A plugin env type with a from_toml of its own is refused, as before. - The run's preflight checks a bundle env from its planned config: the infra its gateway deploy needs, and an image the bundle builds as one only this machine has. A conformance test checks that each type's toml_refs name keys it takes, that its from_toml loads exactly what they declare, and that what it writes references nothing else. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/authoring.py | 22 +- src/agent_env/bundle/ledger.py | 10 +- src/agent_env/bundle/materialize.py | 21 +- src/agent_env/bundle/plan.py | 80 +++++- src/agent_env/bundle/preflight.py | 120 +++++++-- src/agent_env/bundle/resolve.py | 21 +- src/agent_env/entity_refs.py | 3 + src/agent_env/env/envs/_toml.py | 59 +++++ src/agent_env/env/envs/mcp_server.py | 23 ++ src/agent_env/env/envs/multi_env.py | 32 +++ src/agent_env/env/envs/website.py | 27 ++ .../providers/env_providers/constants.py | 4 + src/agent_env/utils/card_naming.py | 19 ++ .../cli/run_bundle_built_envs_test.py | 93 +++++++ tst/unit/bundle/_support.py | 8 + tst/unit/bundle/authoring_test.py | 1 + tst/unit/bundle/conftest.py | 32 +++ tst/unit/bundle/env_toml_test.py | 248 ++++++++++++++++++ tst/unit/bundle/ledger_test.py | 3 +- tst/unit/bundle/materialize_test.py | 51 +--- tst/unit/bundle/plan_test.py | 12 +- tst/unit/bundle/preflight_test.py | 53 ++++ tst/unit/bundle/resolve_test.py | 22 +- tst/unit/bundle/toml_refs_test.py | 80 ++++++ 24 files changed, 946 insertions(+), 98 deletions(-) create mode 100644 src/agent_env/env/envs/_toml.py create mode 100644 tst/integration/cli/run_bundle_built_envs_test.py create mode 100644 tst/unit/bundle/conftest.py create mode 100644 tst/unit/bundle/env_toml_test.py create mode 100644 tst/unit/bundle/toml_refs_test.py diff --git a/src/agent_env/bundle/authoring.py b/src/agent_env/bundle/authoring.py index 521953ee..d4d7cba4 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -12,6 +12,7 @@ from agent_env.artifact.artifact import Artifact from agent_env.entity_refs import EntityKind, parse_toml_ref from agent_env.env.env import Env +from agent_env.utils.card_naming import card_names_in_files from ._fs import os_reason, relative, show, with_article from .parse import CONFIG_FILES, LEAVINGS, NAMED_BY, Bundle, BundleEntry, BundleError @@ -20,7 +21,13 @@ E = TypeVar("E", bound=Env) _BRING_IT_IN = "copy what it points to into the bundle, or put it in a store and refer to it by id" -_TOML_TYPES = {str: "a string", dict: "a table"} +_TOML_TYPES = {str: "a string", dict: "a table", list: "a list"} + + +def default_dockerfile(key: str) -> str: + """The Dockerfile an image key left out builds from the entry's folder: ``Dockerfile`` for ``image``, and + ``Dockerfile.`` for ``_image``, as a website's ``backend_image`` and ``frontend_image``.""" + return "Dockerfile" if key == "image" else f"Dockerfile.{key.removesuffix('_image')}" @dataclass(frozen=True) @@ -50,6 +57,19 @@ def files(self) -> dict[str, Path]: def file(self) -> tuple[str, Path]: return entry_file(self.bundle, self.entry) + def card_names(self, image_key: str) -> list[str] | None: + """The names ``@environment_card(name=...)`` gives in the source of the image ``image_key`` builds from + this folder: the ``.py`` files of its build context, those in its Dockerfile's folder first. None when the + key names an image rather than building one.""" + value = self.entry.config.get(image_key) if isinstance(self.entry.config, dict) else None + if value is None: + dockerfile = default_dockerfile(image_key) + elif isinstance(value, dict) and value.keys() == {"dockerfile"} and isinstance(value["dockerfile"], str): + dockerfile = value["dockerfile"] + else: + return None + return card_names_in_files(build_context_files(self.bundle, self.entry), dockerfile) + def accept(self, data: dict, /, **takes: type) -> dict: """The keys of ``data`` a type takes, each checked against the type given; any other key but ``type`` and ``id`` is refused.""" diff --git a/src/agent_env/bundle/ledger.py b/src/agent_env/bundle/ledger.py index 49a05400..ccf3bc09 100644 --- a/src/agent_env/bundle/ledger.py +++ b/src/agent_env/bundle/ledger.py @@ -22,7 +22,6 @@ from agent_env.artifact.registry import canonical_type, get_artifact_registry from agent_env.artifact.store import ARTIFACTS_COLLECTION from agent_env.config import get_config -from agent_env.env.env import Env from agent_env.env.registry import get_env_registry from agent_env.env.store import ENVS_COLLECTION from agent_env.eval.store import EVALS_COLLECTION @@ -33,7 +32,7 @@ from .authoring import build_context_files, entry_files from .parse import Bundle, BundleKind -from .plan import Plan, Write, folder_walk, keeps_base_from_toml, unpinned_store_refs +from .plan import Plan, Write, env_writer, folder_walk, unpinned_store_refs from .resolve import BuiltImage LEDGER_COLLECTION = "bundle_ledger" @@ -200,8 +199,8 @@ def materializing(bundle: Bundle, on_wait: Callable[[], None] | None = None) -> def _tracked(write: Write) -> bool: """Whether the ledger can list everything ``write`` is made from. Not yet for a skill, nor for a type with - a ``from_toml`` of its own, which may read its folder in ways it can't see. An agent is made from its - agent.toml alone: its image is a reference. A built image is made from its build context, + a ``from_toml`` of its own, which may read its folder in ways it can't see. An agent or env is made from its + toml alone: its images and envs are references. A built image is made from its build context, ``build_context_files``; what the build fetches (its base image, packages) isn't an input.""" if isinstance(write.source, BuiltImage): return True @@ -210,8 +209,7 @@ def _tracked(write: Write) -> bool: if write.kind is BundleKind.ARTIFACT: return folder_walk(get_artifact_registry().get(_type(write))) is not None if write.kind is BundleKind.ENV: - cls = get_env_registry().get(_type(write)) - return cls is not None and keeps_base_from_toml(cls, Env) + return env_writer(get_env_registry().get(_type(write))) return True diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index 84bc69c3..d49d3938 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -26,6 +26,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.registry import canonical_type, get_artifact_registry from agent_env.entity_refs import EntityRef, RefRole, ref_sites +from agent_env.env.registry import get_env_registry from agent_env.eval import Eval, EvalTask from agent_env.store.ids import image_repository from agent_env.store.routing import namespace_routing_enabled @@ -38,7 +39,7 @@ from .authoring import AuthoringContext, build_context_files from .ledger import Ledger, materializing from .parse import BundleError, BundleKind -from .plan import Plan, Write, folder_walk, unpinned_store_refs +from .plan import Plan, Write, env_writer, folder_walk, unpinned_store_refs from .resolve import BuiltImage, build_step @dataclass(frozen=True) @@ -184,6 +185,12 @@ def _write_agent(plan: Plan, write: Write) -> int: AuthoringContext(plan.bundle.bundle, entry)).version +def _write_env(plan: Plan, write: Write) -> int: + entry = write.source.entry + cls = get_env_registry()[entry.type] + return cls.from_toml(_pinned(plan, write, cls.toml_refs), AuthoringContext(plan.bundle.bundle, entry)).version + + def _pinned(plan: Plan, write: Write, refs: tuple[EntityRef, ...]) -> Any: """A copy of ``write``'s resolved toml with each store ref that names no version pinned to the version the plan read, which the ledger hashed (``unpinned_store_refs``).""" @@ -210,7 +217,8 @@ def _write_eval(plan: Plan, write: Write) -> int: # The writers this release has, by kind; any other kind is refused before anything is written. Tasks are # written separately, once every one of them is preflighted, and evals after them, since they name the tasks. _WRITERS: dict[BundleKind, Callable[[Plan, Write], int]] = { - BundleKind.ARTIFACT: _write_artifact, BundleKind.AGENT: _write_agent, BundleKind.EVAL: _write_eval, + BundleKind.ARTIFACT: _write_artifact, BundleKind.AGENT: _write_agent, BundleKind.ENV: _write_env, + BundleKind.EVAL: _write_eval, } @@ -238,10 +246,11 @@ def _unwritable(write: Write) -> str | None: return None if write.kind not in _WRITERS: return with_article(write.kind.value.removesuffix("s")) - if write.kind is BundleKind.ARTIFACT: - type_ = write.source.entry.type - if folder_walk(get_artifact_registry().get(canonical_type(type_))) is None: - return with_article(f"{type_} artifact") + type_ = write.source.entry.type + if write.kind is BundleKind.ARTIFACT and folder_walk(get_artifact_registry().get(canonical_type(type_))) is None: + return with_article(f"{type_} artifact") + if write.kind is BundleKind.ENV and not env_writer(get_env_registry().get(type_)): + return with_article(f"{type_} env") return None diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 4d3e48fb..3dd97a89 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -18,8 +18,15 @@ from agent_env.a2a_agent import A2AAgent from agent_env.artifact import Artifact, FileArtifact, FileArtifactUniverse from agent_env.artifact.registry import canonical_type, get_artifact_registry +from agent_env.config import get_config from agent_env.entity_refs import EntityKind from agent_env.env.env import Env +from agent_env.env.envs.mcp_server import MCPServerEnv +from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.website import WebsiteEnv +from agent_env.env.registry import get_env_registry +from agent_env.env.store import ENVS_COLLECTION +from agent_env.store import Filter, Sort from agent_env.store.base import NotFoundError from agent_env.store.ids import LOCAL_PREFIX from agent_env.store.routing import namespace_routing_enabled @@ -38,6 +45,9 @@ # The artifact types written from their folder's files, and how each lists them. _FOLDER_WALKS = {FileArtifact: entry_file, FileArtifactUniverse: entry_files} +# The env types whose from_toml reads only the toml and what it names, so a type built by one of them is written from +# its env.toml and the ledger can list what it's made from. +_ENV_FROM_TOMLS = (Env, MCPServerEnv, WebsiteEnv, MultiEnv) _NOTHING_TO_RUN = "this bundle has no tasks or evals to run" @@ -94,6 +104,7 @@ def __init__(self, resolved: ResolvedBundle): self.identified = {entry.entry.id: entry.entry for entry in resolved.entries} self.outputs = {(output.kind.value, output.id): (entry.entry, output) for entry in resolved.entries for output in entry.outputs} + self.accepted: dict[_Key, dict] = {} # each agent's and env's toml, as its type accepted it def _add(self, key: _Key, source: ResolvedEntry | BuiltImage, needs: list[_Key]) -> None: """Record a write in bundle order, a built image just before the entry that builds it.""" @@ -107,6 +118,8 @@ def plan(self, tasks: Sequence[str], evals: Sequence[str]) -> Plan: store_refs, store_latest = self._check_store_refs(closure) self._build_tasks(closure) self._check_files(closure) + self._check_env_types(closure) + self._check_multi_names(closure, store_latest) if self.problems: raise BundleError(self.problems) return Plan(self.resolved, self._ordered(closure), tuple(selected_tasks), tuple(selected_evals), store_refs, @@ -265,17 +278,20 @@ def _store_ref_problem(self, ref: Reference, read: dict) -> str | None: return None def _check_files(self, closure: set[_Key]) -> None: - """List the folder of each file artifact, and read its toml and each agent's the way their writes - will, so a link that leaves the bundle or a key the type doesn't take fails before anything is + """List the folder of each file artifact, and read its toml and each agent's and env's the way their + writes will, so a link that leaves the bundle or a key the type doesn't take fails before anything is written.""" registry = get_artifact_registry() for key in sorted(closure, key=self.rank.__getitem__): source = self.nodes[key] if isinstance(source, BuiltImage): continue - if source.entry.kind is BundleKind.AGENT: + if source.entry.kind in (BundleKind.AGENT, BundleKind.ENV): + agent = source.entry.kind is BundleKind.AGENT + accept = A2AAgent.accept_toml if agent else self._env_accept(source.entry) try: - A2AAgent.accept_toml(source.config, AuthoringContext(self.resolved.bundle, source.entry)) + if accept is not None: + self.accepted[key] = accept(source.config, AuthoringContext(self.resolved.bundle, source.entry)) except BundleError as e: self.problems.extend(e.problems) continue @@ -294,6 +310,56 @@ def _check_files(self, closure: set[_Key]) -> None: except BundleError as e: self.problems.extend(e.problems) + def _env_accept(self, entry: BundleEntry) -> Any: + """How ``entry``'s env type checks its env.toml, when it's an env written from one and checks it at all.""" + cls = get_env_registry().get(entry.type) if entry.kind is BundleKind.ENV else None + return getattr(cls, "accept_toml", None) if env_writer(cls) else None + + def _check_env_types(self, closure: set[_Key]) -> None: + """An env keeps its type across versions, so an env folder whose id is another type of env in the store + is refused here, not by the store once earlier entities are written.""" + store = get_config().get_document_store() + for key in sorted(closure, key=self.rank.__getitem__): + source = self.nodes[key] + if isinstance(source, BuiltImage) or source.entry.kind is not BundleKind.ENV: + continue + latest = Sort.by("version", descending=True) + stored = store.find_one(ENVS_COLLECTION, Filter.of(id=source.entry.id), sort=latest) + if stored is not None and stored.get("type") not in (None, source.entry.type): + self.problems.append(f"{self._path(source.entry)}: {source.entry.id!r} is " + f"{with_article(stored.get('type'))} env in the store, and an env keeps its type " + "across versions; rename the folder") + + def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[EntityKind, str], int]) -> None: + """A multi's MCP servers each need their own environment_name, as its websites do: each is a container of + its deploy, named by it.""" + for key in sorted(closure, key=self.rank.__getitem__): + source = self.nodes[key] + if isinstance(source, BuiltImage) or source.entry.kind is not BundleKind.ENV: + continue + if not issubclass(get_env_registry().get(source.entry.type, Env), MultiEnv): + continue + first: dict[tuple[str, str], str] = {} + for ref in source.references: + name = self._environment_name(ref, store_latest) + group = ref.where.partition("[")[0] + if name is None: + continue + if (group, name) in first: + self.problems.append(f"{self._path(source.entry)}: {ref.where}: environment_name {name!r} is also " + f"{first[group, name]}'s; each of a multi's {group} needs its own") + else: + first[group, name] = ref.where + + def _environment_name(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int]) -> str | None: + if ref.local is not None: + return self.accepted.get(_key(ref.local), {}).get("environment_name") + try: + env = Env.get(ref.id, ref.version if ref.version is not None else store_latest.get((ref.kind, ref.id))) + except (NotFoundError, ValueError, KeyError, TypeError): + return None # reported by the store ref check + return getattr(env, "environment_name", None) + def _build_tasks(self, closure: set[_Key]) -> None: """Build each task's steps the way a write will, so a bad step fails before anything is written.""" registry = get_task_step_registry() @@ -351,6 +417,12 @@ def unpinned_store_refs(plan: Plan, write: Write) -> dict[tuple[EntityKind, str] for ref in write.source.references if ref.local is None and ref.version is None} +def env_writer(cls: type | None) -> bool: + """Whether an env of type ``cls`` is written from its env.toml, by a ``from_toml`` that reads only the toml and + what it names: its own, or one it inherits from a core env type.""" + return cls is not None and next(c for c in cls.__mro__ if "from_toml" in vars(c)) in _ENV_FROM_TOMLS + + def folder_walk(cls: type | None) -> Any: """The listing a type's write runs: a subclass keeping its base's ``from_toml`` lists the same way, one that overrides it may read its folder differently, so it gets none.""" diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index f3ed87f8..8f4db136 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -6,7 +6,8 @@ pass. A run is refused when a deploy would fail on its provider: an image only this machine has (in its local registry, or saved in its local object store) on a provider that isn't the local one, a VM asked of a provider that can't create one, or containers on the local provider without a Docker daemon. A deploy_agent step that names no agent, and a judge -that names none, deploy the configured default agent, which must be in the store. +that names none, deploy the configured default agent, which must be in the store. An env the bundle writes is +checked from its planned env.toml: an image the bundle builds is one only this machine has. A gateway deploy on the local provider runs on infra envs the store may not hold yet: the gateway, the service-db its local Postgres state runs from, and the website browser it adds for websites. The run builds those once its writes are @@ -39,7 +40,9 @@ from agent_env.env.envs.multi_env import MultiEnv from agent_env.env.envs.service_db import ServiceDBEnv from agent_env.env.envs.website import WebsiteEnv +from agent_env.env.registry import get_env_registry from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider, _gateway_topology +from agent_env.providers.env_providers.env_provider import _env_provider_class from agent_env.providers.env_providers.env_server_provider import EnvironmentServerProvider from agent_env.providers.env_state.env_state_provider import LOCAL_POSTGRES_STATE_TYPE from agent_env.providers.env_state.store import get_env_state_instance_store @@ -64,7 +67,7 @@ from ._fs import relative from .parse import BundleError, BundleKind from .plan import Plan -from .resolve import BuiltImage, ResolvedEntry, build_step +from .resolve import BuiltImage, Reference, ResolvedEntry, build_step _SHOWN_DOCKER_USERS = 3 @@ -94,6 +97,16 @@ class _Image: local_only: str | None # why only this machine has it, or None +@dataclass(frozen=True) +class _Deployment: + """How a deploy_env step's env deploys, as far as the walk checks it.""" + + provider: type # the provider class its env_provider_type names + images: list[_Image] # what it runs, its children's included + websites: bool + one_server: bool # a single MCP server, which a provider that deploys one server can take + + class _Walk: def __init__(self, plan: Plan, sandbox: str | None): self.plan, self.sandbox = plan, sandbox @@ -105,6 +118,7 @@ def __init__(self, plan: Plan, sandbox: str | None): self.default_agent_users: list[tuple[str, str]] = [] # (where, what names no agent) self.written = {write.id for write in plan.writes} self.agents = {write.id: write.source for write in plan.writes if write.kind is BundleKind.AGENT} + self.envs = {write.id: write.source for write in plan.writes if write.kind is BundleKind.ENV} self.built = {write.id: write.source for write in plan.writes if isinstance(write.source, BuiltImage)} def task(self, task: ResolvedEntry) -> None: @@ -151,42 +165,30 @@ def finish(self) -> Preflight: def _env(self, where: str, step: DeployEnvTaskStep) -> None: provider = _provider(self.sandbox or step.sandbox_type, get_env_sandbox_provider) - if step.env_id in self.written: - return # a bundle env, refused at materialize until envs can be written - try: - env = Env.get(step.env_id, step.env_version) - except NotFoundError: - return # the plan reports a store env that isn't there - if not isinstance(env, (MCPServerEnv, WebsiteEnv, MultiEnv)): - return # an env type that deploys itself, as deploy_env's own preflight leaves it - try: - provider_class = provider_or_class(env) - except (ValueError, KeyError): - return # deploy_env's own preflight reports a provider type this process can't load - if not isinstance(provider_class, type): - provider_class = type(provider_class) - if issubclass(provider_class, EnvironmentGatewayProvider): - topology = _gateway_topology(env) - images = [*topology.mcp_server_images, *(topology.website_images or [])] + deployment = self._planned(step.env_id) if step.env_id in self.envs else self._stored(step) + if deployment is None: + return + if issubclass(deployment.provider, EnvironmentGatewayProvider): kinds = {GATEWAY} if _state_type(step) == LOCAL_POSTGRES_STATE_TYPE: kinds.add(SERVICE_DB) - if topology.website_configs: + if deployment.websites: kinds.add(WEBSITE_BROWSER) - elif issubclass(provider_class, EnvironmentServerProvider) and isinstance(env, MCPServerEnv): - images, kinds = [env.docker_image_artifact], set() + elif issubclass(deployment.provider, EnvironmentServerProvider) and deployment.one_server: + kinds = set() else: # a plugin's provider, or one deploy_env's own preflight refuses for this env return + env_id, images = step.env_id, deployment.images if _local_link(provider): self.infra |= kinds self.docker_users.append(where) if remote := _remote_links(provider): - self._reachable(where, remote, [_Image(f"env {env.id!r}'s image {image.id!r}", _local_only(image)) for image in images]) + self._reachable(where, remote, images) # Modal's gateway runs each server in a container of its own, and can't serve websites. containers = [link for link in remote if isinstance(link, ModalSandboxProvider)] vms = [link for link in remote if link not in containers] if WEBSITE_BROWSER in kinds and containers: - self._problem(where, f"deploys env {env.id!r}, which has websites, on the {_shown(containers[0])} " + self._problem(where, f"deploys env {env_id!r}, which has websites, on the {_shown(containers[0])} " "sandbox provider, whose gateway runs in containers and can't serve websites; run it " "on a VM provider, such as --sandbox local") if containers: @@ -194,6 +196,68 @@ def _env(self, where: str, step: DeployEnvTaskStep) -> None: if vms: self.remote_infra.append((where, vms[0], kinds)) + def _stored(self, step: DeployEnvTaskStep) -> _Deployment | None: + """How a store env deploys, or None when the walk leaves it to deploy_env's own preflight.""" + try: + env = Env.get(step.env_id, step.env_version) + except NotFoundError: + return None # the plan reports a store env that isn't there + if not isinstance(env, (MCPServerEnv, WebsiteEnv, MultiEnv)): + return None # an env type that deploys itself, as deploy_env's own preflight leaves it + try: + provider_class = provider_or_class(env) + except (ValueError, KeyError): + return None # deploy_env's own preflight reports a provider type this process can't load + if not isinstance(provider_class, type): + provider_class = type(provider_class) + images, websites = _stored_images(env) + return _Deployment(provider_class, images, websites, isinstance(env, MCPServerEnv)) + + def _planned(self, env_id: str) -> _Deployment | None: + """How an env the bundle writes deploys, from its planned config: what deploys it, and its images and its + children's. None for a type the walk doesn't model, or a provider type the plan has refused.""" + cls = get_env_registry().get(self.envs[env_id].entry.type) + if cls is None or not issubclass(cls, (MCPServerEnv, WebsiteEnv, MultiEnv)): + return None + try: + provider_class = _env_provider_class(self.envs[env_id].config.get("env_provider_type", "gateway")) + except ValueError: + return None + images, websites = self._planned_images(env_id) + return _Deployment(provider_class, images, websites, issubclass(cls, MCPServerEnv)) + + def _planned_images(self, env_id: str) -> tuple[list[_Image], bool]: + """The images an env the bundle writes runs, its children's included, and whether it has websites.""" + source = self.envs[env_id] + cls = get_env_registry().get(source.entry.type) + images, websites = [], cls is not None and issubclass(cls, WebsiteEnv) + for ref in source.references: + if ref.kind is EntityKind.ARTIFACT and (image := self._planned_image(env_id, ref)) is not None: + images.append(image) + elif ref.kind is EntityKind.ENV: + if ref.id in self.envs: + child_images, child_websites = self._planned_images(ref.id) + else: + try: + child_images, child_websites = _stored_images(Env.get(ref.id, ref.version)) + except NotFoundError: + continue # the plan reports a store env that isn't there + images += child_images + websites |= child_websites + return images, websites + + def _planned_image(self, env_id: str, ref: Reference) -> _Image | None: + what = f"env {env_id!r}'s image {ref.id!r}" + if (built := self.built.get(ref.id)) is not None: + path = relative(self.plan.bundle.bundle.root, built.entry.path) + return _Image(what, f"it's built on this machine from {path}/{built.dockerfile}") + if ref.id in self.written: + return None # another of the bundle's writes, which materialize refuses or writes first + try: + return _Image(what, _local_only(DockerImageArtifact.get(ref.id, ref.version))) + except NotFoundError: + return None # the plan reports a store image that isn't there + def _agent(self, where: str, step: DeployAgentTaskStep, sandboxes: dict[str, DeploySandboxTaskStep]) -> None: if step.sandbox_name: linked = sandboxes.get(step.sandbox_name) @@ -297,6 +361,14 @@ def _problem(self, where: str, problem: str) -> None: self.problems.setdefault(problem, {})[where] = None +def _stored_images(env: Env) -> tuple[list[_Image], bool]: + """The images a store env runs through a gateway, and whether it has websites.""" + topology = _gateway_topology(env) + images = [*topology.mcp_server_images, *(topology.website_images or [])] + return [_Image(f"env {env.id!r}'s image {image.id!r}", _local_only(image)) for image in images], bool( + topology.website_configs) + + def _state_type(step: DeployEnvTaskStep) -> str | None: """The type of the env state store the deploy runs on: an attached instance's own, else the one it creates.""" if step.env_state_instance_id is None: diff --git a/src/agent_env/bundle/resolve.py b/src/agent_env/bundle/resolve.py index 21f96bac..887c9d2c 100644 --- a/src/agent_env/bundle/resolve.py +++ b/src/agent_env/bundle/resolve.py @@ -18,6 +18,8 @@ from agent_env.a2a_agent import A2AAgent from agent_env.artifact.registry import canonical_type, get_artifact_registry from agent_env.entity_refs import EntityKind, EntityRef, RefRole, RefSite, parse_toml_ref, ref_sites +from agent_env.env.envs.gateway_server import GatewayEnv +from agent_env.env.envs.service_db import ServiceDBEnv from agent_env.env.registry import get_env_registry from agent_env.eval.eval import Eval from agent_env.plugins import _registration @@ -26,10 +28,13 @@ from agent_env.task_step.task_step import TaskStep, attach_retry_config, dependencies from ._fs import fold, relative, show +from .authoring import default_dockerfile from .parse import NAMED_BY, Bundle, BundleEntry, BundleError, BundleKind # Step keys that hold step ids, never entity ids. _STEP_ID_KEYS = ("id", "type", "depends_on") +# The env types config names one of, for every deploy that needs one, by the setting that names it. +_INFRA_ENVS = {GatewayEnv.type: "default_gateway_env_id", ServiceDBEnv.type: "default_service_db_env_id"} @dataclass(frozen=True) @@ -51,7 +56,7 @@ class Reference: version: int | None local: BundleEntry | BuiltImage | None where: str # the referencing field, as problems name it - artifact_type: str | None # the type the field takes, when it names one + artifact_type: str | None # the type the field takes, when it names one: an artifact's, or an env's @dataclass(frozen=True) @@ -195,6 +200,10 @@ def _task(self, entry: BundleEntry) -> ResolvedEntry: def _toml_class(self, entry: BundleEntry) -> type: group = None if entry.kind is BundleKind.ENV: + if entry.type in _INFRA_ENVS: + self._problem(entry, f"a {entry.type} env isn't written from a bundle: config names the one every " + f"deploy uses ({_INFRA_ENVS[entry.type]}), and a run builds it when it's missing") + raise _Skip cls, group = get_env_registry().get(entry.type), _registration.ENVS elif entry.kind is BundleKind.AGENT: cls = A2AAgent if entry.type == A2AAgent.type else None @@ -218,10 +227,14 @@ def _image(self, entry: BundleEntry, config: dict, ref: EntityRef, references: l if isinstance(value, dict) and "ref" in value: self._problem(entry, f"{ref.path}: an external image ({{ ref = ... }}) isn't supported yet") return True - if value is None and ref.path == "image": - dockerfile = "Dockerfile" + if value is None: + dockerfile = default_dockerfile(ref.path) elif isinstance(value, dict) and value.keys() == {"dockerfile"}: dockerfile = value["dockerfile"] + elif isinstance(value, dict) and "dockerfile" in value: + extra = ", ".join(sorted(value.keys() - {"dockerfile"})) + self._problem(entry, f"{ref.path}: an image built from this folder takes only dockerfile, not {extra}") + return True else: return False path = entry.path / dockerfile if isinstance(dockerfile, str) and dockerfile else None @@ -309,7 +322,7 @@ def _depends_on(self, entry: BundleEntry, steps: list[dict]) -> list[list[str] | def _resolve(self, entry: BundleEntry, site: RefSite, where: str, name: str, version: int | None, outputs: dict[tuple[EntityKind, str], Output], upstream: set[int], references: list[Reference]) -> None: - kind, expected, key = site.ref.kind, site.ref.artifact_type, _nfc(name) + kind, expected, key = site.ref.kind, site.ref.artifact_type or site.ref.env_type, _nfc(name) output = outputs.get((kind, key)) if output is not None: if output.step not in upstream: diff --git a/src/agent_env/entity_refs.py b/src/agent_env/entity_refs.py index dd35d04b..c1363dfb 100644 --- a/src/agent_env/entity_refs.py +++ b/src/agent_env/entity_refs.py @@ -62,6 +62,7 @@ class EntityRef: version_field: str | None = None role: RefRole = RefRole.INPUT artifact_type: str | None = None + env_type: str | None = None walk: Walker | None = None @classmethod @@ -92,6 +93,8 @@ def __post_init__(self) -> None: raise ValueError(f"entity ref {self.path!r}: a list element has no sibling key to hold a version") if self.artifact_type is not None and self.kind is not EntityKind.ARTIFACT: raise ValueError(f"entity ref {self.path!r}: artifact_type is only for EntityKind.ARTIFACT") + if self.env_type is not None and self.kind is not EntityKind.ENV: + raise ValueError(f"entity ref {self.path!r}: env_type is only for EntityKind.ENV") @property def field(self) -> str: diff --git a/src/agent_env/env/envs/_toml.py b/src/agent_env/env/envs/_toml.py new file mode 100644 index 00000000..d9986ffd --- /dev/null +++ b/src/agent_env/env/envs/_toml.py @@ -0,0 +1,59 @@ +"""What an env type written from an env.toml checks first: its keys, its env_provider_type and, for a type that +names itself, its environment_name.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from agent_env.providers.env_providers.constants import GATEWAY_SERVICE_NAMES + +if TYPE_CHECKING: + from agent_env.bundle.authoring import AuthoringContext + + +def accept_env_toml(cls: type, data: dict[str, Any], ctx: AuthoringContext, *, image_key: str | None = None, + one_server: bool = False) -> tuple[dict[str, Any], list[str]]: + """The keys of ``data`` ``cls`` takes, and the problems with them. With ``image_key``, the env is named by + ``environment_name`` or, left out, by the ``@environment_card`` in the source of the image that key builds; + ``one_server`` lets env_provider_type name a provider that deploys one MCP server.""" + # The env types import before the providers that deploy them, which import those types. + from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider + from agent_env.providers.env_providers.env_provider import _env_provider_class + from agent_env.providers.env_providers.env_server_provider import EnvironmentServerProvider + + fields, problems = ctx.accepted(data, **cls.toml_keys) + provider_type = fields.get("env_provider_type", "gateway") + try: + provider = _env_provider_class(provider_type) + except ValueError as e: + problems.append(f"env.toml: env_provider_type: {e}") + provider = None + if provider is not None and not one_server and issubclass(provider, EnvironmentServerProvider): + problems.append(f"env.toml: env_provider_type {provider_type!r} deploys one MCP server, not a {cls.type} env") + if image_key is not None: + name = _environment_name(data, fields, ctx, image_key, problems) + if name in GATEWAY_SERVICE_NAMES and provider is not None and issubclass(provider, EnvironmentGatewayProvider): + problems.append(f"env.toml: environment_name {name!r} is one a gateway deploy names its own containers " + f"({', '.join(sorted(GATEWAY_SERVICE_NAMES))}); choose another") + return fields, problems + + +def _environment_name(data: dict[str, Any], fields: dict[str, Any], ctx: AuthoringContext, image_key: str, + problems: list[str]) -> str | None: + if "environment_name" in data: + if fields.get("environment_name") == "": + problems.append("env.toml: environment_name can't be empty") + return fields.get("environment_name") + names = ctx.card_names(image_key) + what = "image" if image_key == "image" else image_key.replace("_", " ") + if names is None: + problems.append(f"env.toml: environment_name isn't set, and its {what} isn't built from this folder, so " + "there's no source to read it from; set it") + elif len(names) != 1: + found = (f"several environment cards ({', '.join(map(repr, names))})" if names + else "no @environment_card(name=...)") + problems.append(f"env.toml: environment_name isn't set, and the source its {what} is built from declares " + f"{found}; set it") + else: + fields["environment_name"] = names[0] + return fields.get("environment_name") diff --git a/src/agent_env/env/envs/mcp_server.py b/src/agent_env/env/envs/mcp_server.py index c80a7aa9..30f7f303 100644 --- a/src/agent_env/env/envs/mcp_server.py +++ b/src/agent_env/env/envs/mcp_server.py @@ -12,7 +12,9 @@ from agentenv_protocol import FilePart, client as protocol_v1 from agent_env.artifact import Artifact, DockerImageArtifact from agent_env.artifact.artifacts.docker_image import GitHubBuildResult, ProgressCallback, refuse_local_github_build +from agent_env.entity_refs import EntityRef from agent_env.env.env import Env, gateway_url_of +from agent_env.env.envs._toml import accept_env_toml from agent_env.store.ids import derive_id from agent_env.env import legacy_protocol from agent_env.env.envs._deployment import ( @@ -25,6 +27,7 @@ if TYPE_CHECKING: from agent_env.artifact import CliArtifact, EnvironmentArtifact, EnvironmentUniverseArtifact + from agent_env.bundle.authoring import AuthoringContext from agent_env.env.env import DeployedEnv from agent_env.providers.env_providers.env_provider import EnvironmentProvider @@ -34,6 +37,8 @@ class MCPServerEnv(Env): type: ClassVar[str] = "mcp_server" description = "An MCP server, deployed through the environment provider its env_provider_type names" + toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.artifact("image", artifact_type="docker_image"),) + toml_keys: ClassVar[dict[str, type]] = {"image": object, "environment_name": str, "env_provider_type": str} _MCP_MAX_RETRIES: ClassVar[int] = 5 def __init__(self, id: str, version: Optional[int], docker_image_artifact: DockerImageArtifact, environment_name: Optional[str] = None, *, metadata: Optional[dict[str, str]] = None, env_provider_type: str = "gateway"): @@ -78,6 +83,24 @@ def from_dict(cls, data: dict) -> MCPServerEnv: env_provider_type=data.get("env_provider_type", "gateway"), ) + @classmethod + def from_toml(cls, data: dict, ctx: AuthoringContext) -> MCPServerEnv: + """Write the env authored as ``data`` (its env.toml, with ``image`` resolved to an image artifact's id) + under ``ctx.id`` and return it.""" + fields = cls.accept_toml(data, ctx) + return cls.put(id=ctx.id, docker_image_artifact=ctx.artifact(fields["image"], DockerImageArtifact), + environment_name=fields["environment_name"], + env_provider_type=fields.get("env_provider_type", "gateway")) + + @classmethod + def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: + """The keys of ``data``, an env.toml, an MCP server env takes, its environment_name read from the image's + @environment_card when it isn't set. Raises BundleError listing every problem.""" + fields, problems = accept_env_toml(cls, data, ctx, image_key="image", one_server=True) + if problems: + ctx.refuse(problems) + return fields + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: # In container mode the server runs in its own container, so loads stage there, as for a MultiEnv child. return await deploy_through_provider( diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index b927798d..3cdb61cd 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -22,9 +22,12 @@ def _snapshot_after_load_default() -> bool: if TYPE_CHECKING: from agent_env.artifact import EnvironmentArtifact, EnvironmentUniverseArtifact + from agent_env.bundle.authoring import AuthoringContext from agent_env.providers.env_state import DatabaseStateProvider +from agent_env.entity_refs import EntityRef from agent_env.env.env import Env, gateway_url_of +from agent_env.env.envs._toml import accept_env_toml from agent_env.env.gateway import GatewayMode from agent_env.env.envs._deployment import ( as_builtin, builtin_provider_for, close_deployed, close_replaced, deploy_refusal, deploy_through_provider, host_staging_refusal, @@ -40,6 +43,10 @@ def _snapshot_after_load_default() -> bool: class MultiEnv(Env): type: ClassVar[str] = "multi" description = "Multi environment combining multiple MCP servers and websites, deployed through the environment provider its env_provider_type names" + toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.env("mcp_server_envs[]", env_type=MCPServerEnv.type), + EntityRef.env("website_envs[]", env_type=WebsiteEnv.type)) + toml_keys: ClassVar[dict[str, type]] = {"mcp_server_envs": list, "website_envs": list, "name": str, + "env_provider_type": str} def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPServerEnv], website_envs: list[WebsiteEnv] | None = None, metadata: Optional[dict[str, str]] = None, name: Optional[str] = None, env_provider_type: str = "gateway"): super().__init__(id, version, metadata=metadata) @@ -87,6 +94,31 @@ def from_dict(cls, data: dict) -> "MultiEnv": return cls(id=data["id"], version=data.get("version"), mcp_server_envs=mcp_server_envs, website_envs=website_envs, metadata=data.get("metadata", {}), name=data.get("name"), env_provider_type=data.get("env_provider_type", "gateway")) + @classmethod + def from_toml(cls, data: dict, ctx: AuthoringContext) -> MultiEnv: + """Write the env authored as ``data`` (its env.toml, with ``mcp_server_envs`` and ``website_envs`` resolved to + env ids) under ``ctx.id`` and return it, unless deploying it would be refused.""" + fields = cls.accept_toml(data, ctx) + env = dict(mcp_server_envs=[ctx.env(ref, MCPServerEnv) for ref in fields.get("mcp_server_envs", [])], + website_envs=[ctx.env(ref, WebsiteEnv) for ref in fields.get("website_envs", [])], + name=fields.get("name"), env_provider_type=fields.get("env_provider_type", "gateway")) + if refusal := cls(id=ctx.id, version=None, **env).deploy_refusal(): + ctx.refuse([refusal]) + return cls.put(id=ctx.id, **env) + + @classmethod + def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: + """The keys of ``data``, an env.toml, a multi env takes. Raises BundleError listing every problem.""" + fields, problems = accept_env_toml(cls, data, ctx) + if not (fields.get("mcp_server_envs") or fields.get("website_envs")): + problems.append("env.toml: a multi env needs at least one env in mcp_server_envs or website_envs") + name = fields.get("name") + if name is not None and (not name or any(ch.isspace() for ch in name)): + problems.append(f"env.toml: name must be non-empty with no whitespace, not {name!r}") + if problems: + ctx.refuse(problems) + return fields + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: deployed_env = await deploy_through_provider( self, environment_name=None, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, diff --git a/src/agent_env/env/envs/website.py b/src/agent_env/env/envs/website.py index f3e5430b..a8dc7748 100644 --- a/src/agent_env/env/envs/website.py +++ b/src/agent_env/env/envs/website.py @@ -12,7 +12,9 @@ from agentenv_protocol import FilePart, client as protocol_v1 from agent_env.artifact import Artifact, DockerImageArtifact, EnvironmentArtifact from agent_env.artifact.artifacts.docker_image import GitHubBuildResult, ProgressCallback, refuse_local_github_build +from agent_env.entity_refs import EntityRef from agent_env.env.env import Env, gateway_url_of +from agent_env.env.envs._toml import accept_env_toml from agent_env.store.ids import derive_id from agent_env.env import legacy_protocol from agent_env.env.envs._deployment import ( @@ -22,6 +24,7 @@ from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode from agent_env.attribution import Attribution if TYPE_CHECKING: + from agent_env.bundle.authoring import AuthoringContext from agent_env.env.env import DeployedEnv from agent_env.providers.env_providers.env_provider import EnvironmentProvider @@ -33,6 +36,10 @@ class WebsiteEnv(Env): type: ClassVar[str] = "website" description = "Website environment with frontend and backend containers, deployed through the environment provider its env_provider_type names" + toml_refs: ClassVar[tuple[EntityRef, ...]] = (EntityRef.artifact("backend_image", artifact_type="docker_image"), + EntityRef.artifact("frontend_image", artifact_type="docker_image")) + toml_keys: ClassVar[dict[str, type]] = {"backend_image": object, "frontend_image": object, "environment_name": str, + "env_provider_type": str} def __init__( self, @@ -94,6 +101,26 @@ def from_dict(cls, data: dict) -> WebsiteEnv: env_provider_type=data.get("env_provider_type", "gateway"), ) + @classmethod + def from_toml(cls, data: dict, ctx: AuthoringContext) -> WebsiteEnv: + """Write the env authored as ``data`` (its env.toml, with ``backend_image`` and ``frontend_image`` resolved to + image artifacts' ids) under ``ctx.id`` and return it.""" + fields = cls.accept_toml(data, ctx) + return cls.put(id=ctx.id, + backend_docker_image_artifact=ctx.artifact(fields["backend_image"], DockerImageArtifact), + frontend_docker_image_artifact=ctx.artifact(fields["frontend_image"], DockerImageArtifact), + environment_name=fields["environment_name"], + env_provider_type=fields.get("env_provider_type", "gateway")) + + @classmethod + def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: + """The keys of ``data``, an env.toml, a website env takes, its environment_name read from the backend image's + @environment_card when it isn't set. Raises BundleError listing every problem.""" + fields, problems = accept_env_toml(cls, data, ctx, image_key="backend_image") + if problems: + ctx.refuse(problems) + return fields + async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: return await deploy_through_provider( self, environment_name=self.environment_name, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type, diff --git a/src/agent_env/providers/env_providers/constants.py b/src/agent_env/providers/env_providers/constants.py index 7683094d..7676ac52 100644 --- a/src/agent_env/providers/env_providers/constants.py +++ b/src/agent_env/providers/env_providers/constants.py @@ -5,6 +5,10 @@ """ GATEWAY_SERVICE_NAME = "gateway" +# The compose services a gateway deploy runs beside its envs' MCP servers, so no env can be named one of them: the +# gateway, the local Postgres state's database and its two browse UIs, and the website browser (named in +# agent_env.env.envs.website_browser). +GATEWAY_SERVICE_NAMES = frozenset({GATEWAY_SERVICE_NAME, "servicedb", "pgweb", "db-mcp", "website-browser"}) GATEWAY_APP_DIR = "/app" AGENT_ENV_WEBSITE_BACKEND_PORT = 8000 diff --git a/src/agent_env/utils/card_naming.py b/src/agent_env/utils/card_naming.py index 1762e4ae..57ded2a7 100644 --- a/src/agent_env/utils/card_naming.py +++ b/src/agent_env/utils/card_naming.py @@ -2,6 +2,7 @@ import ast import posixpath +from collections.abc import Mapping from pathlib import Path import httpx @@ -68,6 +69,24 @@ def _github_card_names(client: httpx.Client, owner: str, repo: str, ref: str | N return sorted(found) +def card_names_in_files(files: Mapping[str, Path], dockerfile: str) -> list[str]: + """The names `@environment_card(name="...")` gives in the `.py` files among ``files``, keyed by POSIX path: in + those in the Dockerfile's folder, else in all of them, as `card_name_from_source` reads a folder.""" + folder = posixpath.dirname(dockerfile) + sources = {key: path for key, path in files.items() if key.endswith(".py")} + near = {key: path for key, path in sources.items() if not folder or key.startswith(f"{folder}/")} + for chosen in (near, sources): + found: set[str] = set() + for path in chosen.values(): + try: + found |= _card_names_in_source(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + continue + if found: + return sorted(found) + return [] + + def _scan_environment_card_names(root: Path) -> list[str]: found: set[str] = set() for py in root.rglob("*.py"): diff --git a/tst/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py new file mode 100644 index 00000000..3de3070c --- /dev/null +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -0,0 +1,93 @@ +"""``agent-env run`` on a bundle whose MCP server env is built from its folder, with real docker builds and the env +deployed on the local sandbox: named by the environment card in its source, reused while its folder is unchanged, and +rebuilt when it changes. Needs a Docker daemon and the local registry.""" + +import json +import logging +import shutil +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from agent_env.artifact.store import reset_artifact_store +from agent_env.bundle import parse_bundle +from agent_env.cli import cli +from agent_env.config import configure, reset_config +from agent_env.env import Env +from agent_env.store.routing import namespace_routing + +pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] + +REPO = Path(__file__).resolve().parents[3] +ITEMS = REPO / "tst" / "data" / "agentenv_mcp" # an in-memory MCP server whose card names it 'items' +PROTOCOL = REPO / "packages" / "agentenv-protocol" / "src" / "agentenv_protocol" +IMAGE = "envs/items (Dockerfile image)" + + +@pytest.fixture +def state(monkeypatch, tmp_path): + """Local stores under this test's folder. Whatever ran, no sandbox work folder may be left behind.""" + sandboxes = tmp_path / "sandboxes" + # HOME stays: docker's credential helper (the macOS keychain, for one) can hang a build's base-image lookup + # when HOME moves. + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setenv("AGENT_ENV_DOCUMENT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_OBJECT_STORE", "local") + monkeypatch.setenv("AGENT_ENV_LOCAL_SANDBOX_DIR", str(sandboxes)) + configure() + reset_artifact_store() + logging.disable(logging.CRITICAL) # pytest's live logging would take CliRunner's stdout + try: + yield tmp_path + assert not sandboxes.exists() or not any(sandboxes.iterdir()), "a run left a sandbox work folder" + finally: + logging.disable(logging.NOTSET) + reset_artifact_store() + reset_config() + + +def _bundle(root): + """The items server as an env folder, deployed by the server provider, which runs it with no gateway.""" + folder = root / "envs" / "items" + shutil.copytree(PROTOCOL, folder / "agentenv_protocol", ignore=shutil.ignore_patterns("__pycache__")) + for name in ("server.py", "Dockerfile", "seed.json"): + shutil.copy(ITEMS / name, folder / name) + (folder / "env.toml").write_text('env_provider_type = "server"\n') + (root / "tasks").mkdir() + (root / "tasks/items.json").write_text(json.dumps( + [{"id": "env", "type": "deploy_env", "env_id": "items", "sandbox_type": "local"}])) + return root + + +def _run(root): + return CliRunner().invoke(cli, ["run", str(root)]) + + +def test_an_env_folder_is_built_named_by_its_card_deployed_and_rebuilt_only_when_it_changes(state): + root = _bundle(state / "envs-bundle") + + first = _run(root) + + assert first.exit_code == 0, first.output + assert f"{IMAGE}: building with docker" in first.output, first.output + assert f"{IMAGE}: v1 (new)" in first.output and "envs/items: v1 (new)" in first.output, first.output + assert "tasks/items.json v1: unscored" in first.output, first.output + with namespace_routing(): + env = Env.get(next(entry.id for entry in parse_bundle(root).entries if entry.name == "items")) + assert (env.environment_name, env.env_provider_type) == ("items", "server") + + again = _run(root) + + assert again.exit_code == 0, again.output + assert "building with docker" not in again.output, again.output + assert f"{IMAGE}: v1, unchanged" in again.output and "envs/items: v1, unchanged" in again.output, again.output + + (root / "envs/items/seed.json").write_text('{"items": ["edited"]}\n') + edited = _run(root) + + assert edited.exit_code == 0, edited.output + assert f"{IMAGE}: v2 (files changed: seed.json)" in edited.output, edited.output + assert "envs/items: v2 (" in edited.output and "tasks/items.json v1: unscored" in edited.output, edited.output + assert edited.output.count("building with docker") == 1, edited.output diff --git a/tst/unit/bundle/_support.py b/tst/unit/bundle/_support.py index 20b3f09d..c74a0321 100644 --- a/tst/unit/bundle/_support.py +++ b/tst/unit/bundle/_support.py @@ -1,6 +1,8 @@ """What the bundle tests share: laying out and planning a folder, the @local namespace's store, and a configured store that fails any test reaching it.""" +from pathlib import Path + from agent_env.bundle import parse_bundle from agent_env.bundle.plan import plan_bundle from agent_env.bundle.resolve import resolve_bundle @@ -15,6 +17,12 @@ def layout(root, files): return root +def listing(folder): + """The files under ``folder``, as POSIX paths relative to it, a link marked with a trailing ``@``.""" + return sorted(path.relative_to(folder).as_posix() + ("@" if path.is_symlink() else "") + for path in Path(folder).rglob("*") if not path.is_dir() or path.is_symlink()) + + def plan_of(root): return plan_bundle(resolve_bundle(parse_bundle(root))) diff --git a/tst/unit/bundle/authoring_test.py b/tst/unit/bundle/authoring_test.py index 8c557e69..4611b758 100644 --- a/tst/unit/bundle/authoring_test.py +++ b/tst/unit/bundle/authoring_test.py @@ -18,6 +18,7 @@ LAYOUT = { "envs/tickets/Dockerfile": "FROM scratch\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', "envs/both/env.toml": 'type = "multi"\n', "artifacts/golden/artifact.toml": 'type = "vm_image"\n', } diff --git a/tst/unit/bundle/conftest.py b/tst/unit/bundle/conftest.py new file mode 100644 index 00000000..ffcdd665 --- /dev/null +++ b/tst/unit/bundle/conftest.py @@ -0,0 +1,32 @@ +"""Fixtures the bundle tests share.""" + +import pytest + +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.bundle import materialize as materialize_module +from tst.unit.bundle._support import listing + + +@pytest.fixture +def docker_on_path(monkeypatch): + monkeypatch.setattr(materialize_module.shutil, "which", lambda name: f"/usr/bin/{name}") + + +@pytest.fixture +def builds(monkeypatch, docker_on_path): + """Stands in for docker: each build is recorded with the files of its context, a link marked with a + trailing ``@``, and the image written as a docker_image document.""" + calls = [] + + def build(dockerfile, context, tag, *, platform): + calls.append({"build": (dockerfile.relative_to(context).as_posix(), listing(context), tag, platform)}) + + def put(id, *, description, image_name, build_context_path=None, dockerfile_path=None): + calls[-1]["put"] = (id, image_name, listing(build_context_path), dockerfile_path) + return get_artifact_store().put_document(DockerImageArtifact( + id=id, description=description, image_name=image_name, tar_gz_s3_url=f"file:///{id}.tar.gz")) + + monkeypatch.setattr(materialize_module, "build_image", build) + monkeypatch.setattr(materialize_module.DockerImageArtifact, "put", put) + return calls diff --git a/tst/unit/bundle/env_toml_test.py b/tst/unit/bundle/env_toml_test.py new file mode 100644 index 00000000..e15f30ca --- /dev/null +++ b/tst/unit/bundle/env_toml_test.py @@ -0,0 +1,248 @@ +"""Envs written from a bundle's env folders: an MCP server, a website and a multi, each over the images built from +its folder or named in the store, named by its env.toml or its source's environment card, and refused before any +write when its env.toml can't be written.""" + +import json + +import pytest + +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.store import get_artifact_store +from agent_env.bundle import BundleError, parse_bundle +from agent_env.bundle.plan import check_bundle +from agent_env.bundle.resolve import resolve_bundle +from agent_env.bundle.materialize import materialize +from agent_env.env import Env +from agent_env.env.envs.mcp_server import MCPServerEnv +from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.website import WebsiteEnv +from agent_env.store.routing import namespace_routing +from tst.unit.bundle._support import RefusingStore, layout, local_store, plan_of + +ROOT = "@local/~/triage" +CARD = 'from agentenv_protocol import environment_card\n\n\n@environment_card(name="{}")\nclass Server:\n pass\n' +DOCKERFILE = "FROM scratch\nCOPY . /app\n" + + +@pytest.fixture +def bundle_dir(tmp_path, monkeypatch, local_stores, cli_routing): + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path / "triage" + + +def _bundle(root, files, *deployed): + layout(root, {**files, "tasks/t.json": json.dumps( + [{"id": f"deploy-{name}", "type": "deploy_env", "env_id": name} for name in deployed])}) + return root + + +def _mcp(name, card=None): + return {f"envs/{name}/Dockerfile": DOCKERFILE, f"envs/{name}/server.py": CARD.format(card or name)} + + +def _run(root, dry_run=False): + return materialize(plan_of(root), dry_run=dry_run) + + +def _summary(materialization): + return {done.write.id: (done.version, done.reused, done.reasons) for done in materialization.writes} + + +def _store_image(id, version=None): + with namespace_routing(): + return get_artifact_store().put_document(DockerImageArtifact( + id=id, description=id, image_name=f"registry.example/{id}:v1", tar_gz_s3_url=f"s3://bucket/{id}.tar.gz")) + + +def _problems(root): + with pytest.raises(BundleError) as caught: + plan_of(root) + return caught.value.problems + + +# MCP servers + + +def test_an_mcp_server_folder_is_written_over_the_image_built_from_it_named_by_its_card(bundle_dir, builds): + _bundle(bundle_dir, _mcp("crm"), "crm") + + first = _summary(_run(bundle_dir)) + env = Env.get(f"{ROOT}/crm") + + assert first[f"{ROOT}/crm__env_image"] == (1, False, ("new",)) and first[f"{ROOT}/crm"] == (1, False, ("new",)) + assert isinstance(env, MCPServerEnv) + assert (env.environment_name, env.env_provider_type, env.metadata) == ("crm", "gateway", {}) + assert (env.docker_image_artifact.id, env.docker_image_artifact.version) == (f"{ROOT}/crm__env_image", 1) + assert [call["build"][:2] for call in builds] == [("Dockerfile", ["Dockerfile", "server.py"])] + + assert all(reused for _, reused, _ in _summary(_run(bundle_dir)).values()) and len(builds) == 1 + (bundle_dir / "envs/crm/server.py").write_text(CARD.format("crm") + "# edited\n") + rebuilt = _summary(_run(bundle_dir)) + assert rebuilt[f"{ROOT}/crm__env_image"] == (2, False, ("files changed: server.py",)) + assert rebuilt[f"{ROOT}/crm"] == (2, False, (f"artifact {ROOT}/crm__env_image is written anew (v1 → v2)",)) + assert Env.get(f"{ROOT}/crm").docker_image_artifact.version == 2 + + +def test_an_environment_name_in_env_toml_wins_over_the_card_and_a_dockerfile_elsewhere_names_where_to_look( + bundle_dir, builds, +): + _bundle(bundle_dir, { + "envs/crm/docker/Dockerfile": DOCKERFILE, "envs/crm/docker/server.py": CARD.format("crm"), + "envs/crm/other.py": CARD.format("not-this"), "envs/crm/env.toml": 'image = { dockerfile = "docker/Dockerfile" }\n', + "envs/named/Dockerfile": DOCKERFILE, "envs/named/server.py": CARD.format("crm"), + "envs/named/env.toml": 'environment_name = "tickets"\nenv_provider_type = "server"\n', + }, "crm", "named") + + _run(bundle_dir) + + assert Env.get(f"{ROOT}/crm").environment_name == "crm" + named = Env.get(f"{ROOT}/named") + assert (named.environment_name, named.env_provider_type) == ("tickets", "server") + + +def test_an_env_over_a_store_image_pins_the_version_the_plan_read_and_is_rewritten_when_the_store_gains_one( + bundle_dir, builds, +): + _store_image("base") + _bundle(bundle_dir, {"envs/crm/env.toml": 'image = "base"\nenvironment_name = "crm"\n'}, "crm") + + _run(bundle_dir) + assert Env.get(f"{ROOT}/crm").docker_image_artifact.version == 1 + _store_image("base") + rewritten = _summary(_run(bundle_dir)) + + assert rewritten[f"{ROOT}/crm"] == (2, False, ("artifact base has a new version in the store (v1 → v2)",)) + assert Env.get(f"{ROOT}/crm").docker_image_artifact.version == 2 + assert builds == [] + + +# Websites and multis + + +def test_a_website_builds_dockerfile_backend_and_dockerfile_frontend_and_takes_its_name_from_the_backend( + bundle_dir, builds, +): + _bundle(bundle_dir, {"envs/shop/Dockerfile.backend": DOCKERFILE, "envs/shop/Dockerfile.frontend": DOCKERFILE, + "envs/shop/server.py": CARD.format("shop"), "envs/shop/env.toml": 'type = "website"\n'}, "shop") + + _run(bundle_dir) + env = Env.get(f"{ROOT}/shop") + + assert isinstance(env, WebsiteEnv) and env.environment_name == "shop" + assert (env.backend_docker_image_artifact.id, env.frontend_docker_image_artifact.id) == ( + f"{ROOT}/shop__backend_image", f"{ROOT}/shop__frontend_image") + assert [call["build"][0] for call in builds] == ["Dockerfile.backend", "Dockerfile.frontend"] + + +def test_a_multi_names_bundle_and_store_envs_and_is_rewritten_when_a_child_is(bundle_dir, builds): + with namespace_routing(): + MCPServerEnv.put(id="mail", docker_image_artifact=_store_image("mail-image"), environment_name="mail") + _bundle(bundle_dir, { + **_mcp("crm"), "envs/shop/Dockerfile.backend": DOCKERFILE, "envs/shop/Dockerfile.frontend": DOCKERFILE, + "envs/shop/env.toml": 'type = "website"\nenvironment_name = "shop"\n', + "envs/suite/env.toml": 'type = "multi"\nmcp_server_envs = ["crm", "mail"]\nwebsite_envs = ["shop"]\nname = "suite"\n', + }, "suite") + + _run(bundle_dir) + suite = Env.get(f"{ROOT}/suite") + assert isinstance(suite, MultiEnv) and suite.name == "suite" + assert [(env.id, env.version) for env in [*suite.mcp_server_envs, *suite.website_envs]] == [ + (f"{ROOT}/crm", 1), ("mail", 1), (f"{ROOT}/shop", 1)] + + (bundle_dir / "envs/crm/server.py").write_text(CARD.format("crm") + "# edited\n") + rewritten = _summary(_run(bundle_dir)) + assert rewritten[f"{ROOT}/suite"] == (2, False, (f"env {ROOT}/crm is written anew (v1 → v2)",)) + assert rewritten[f"{ROOT}/shop"][1] and rewritten[f"{ROOT}/shop__backend_image"][1] + + +# Refused before any write + + +def _website(name, toml=""): + return {f"envs/{name}/Dockerfile.backend": DOCKERFILE, f"envs/{name}/Dockerfile.frontend": DOCKERFILE, + f"envs/{name}/env.toml": f'type = "website"\nenvironment_name = "{name}"\n{toml}'} + + +@pytest.mark.parametrize("files, problem", [ + ({"envs/x/Dockerfile": DOCKERFILE}, + "envs/x: env.toml: environment_name isn't set, and the source its image is built from declares no " + "@environment_card(name=...); set it"), + ({"envs/x/Dockerfile": DOCKERFILE, "envs/x/a.py": CARD.format("a"), "envs/x/b.py": CARD.format("b")}, + "envs/x: env.toml: environment_name isn't set, and the source its image is built from declares several " + "environment cards ('a', 'b'); set it"), + ({"envs/x/env.toml": 'image = "base"\n'}, + "envs/x: env.toml: environment_name isn't set, and its image isn't built from this folder, so there's no source " + "to read it from; set it"), + ({**_mcp("x"), "envs/x/env.toml": '[metadata]\nowner = "me"\n'}, + "envs/x: env.toml: unknown key 'metadata'; a mcp_server env takes image, environment_name, env_provider_type, " + "type and id"), + ({**_mcp("x"), "envs/x/env.toml": 'environment_name = ""\n'}, "envs/x: env.toml: environment_name can't be empty"), + ({**_mcp("x"), "envs/x/env.toml": 'environment_name = "gateway"\n'}, + "envs/x: env.toml: environment_name 'gateway' is one a gateway deploy names its own containers (db-mcp, gateway, " + "pgweb, servicedb, website-browser); choose another"), + (_website("x", 'env_provider_type = "server"\n'), + "envs/x: env.toml: env_provider_type 'server' deploys one MCP server, not a website env"), + ({**_mcp("x"), "envs/x/env.toml": 'image = { dockerfile = "Dockerfile", build_args = { A = "1" } }\n'}, + "envs/x: image: an image built from this folder takes only dockerfile, not build_args"), + ({"envs/x/env.toml": 'type = "multi"\nname = "suite"\n'}, + "envs/x: env.toml: a multi env needs at least one env in mcp_server_envs or website_envs"), + ({**_mcp("crm"), "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["crm"]\nname = "my suite"\n'}, + "envs/x: env.toml: name must be non-empty with no whitespace, not 'my suite'"), + ({**_website("shop"), "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["shop"]\n'}, + "envs/x: mcp_server_envs[0]: 'shop' is this bundle's website, but this field takes mcp_server"), + ({**_mcp("a", "crm"), **_mcp("b", "crm"), "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["a", "b"]\n'}, + "envs/x: mcp_server_envs[1]: environment_name 'crm' is also mcp_server_envs[0]'s; each of a multi's " + "mcp_server_envs needs its own"), + ({"envs/x/env.toml": 'type = "gateway_server"\n'}, + "envs/x: a gateway_server env isn't written from a bundle: config names the one every deploy uses " + "(default_gateway_env_id), and a run builds it when it's missing"), + ({"envs/x/env.toml": 'type = "service_db"\n'}, + "envs/x: a service_db env isn't written from a bundle: config names the one every deploy uses " + "(default_service_db_env_id), and a run builds it when it's missing"), +], ids=["no-card", "several-cards", "store-image-unnamed", "metadata", "empty-name", "reserved-name", + "website-on-server", "build-args", "multi-without-envs", "multi-name", "multi-wrong-child", "multi-same-names", + "gateway", "service-db"]) +def test_an_env_toml_that_cant_be_written_is_refused_before_any_write(bundle_dir, files, problem): + _store_image("base") + _bundle(bundle_dir, files, "x") + + assert problem in _problems(bundle_dir) + assert not local_store().path.exists() + + +def test_an_unknown_env_provider_type_is_refused_naming_the_known_ones(bundle_dir): + _bundle(bundle_dir, {**_mcp("x"), "envs/x/env.toml": 'env_provider_type = "nope"\n'}, "x") + + (problem,) = _problems(bundle_dir) + assert problem.startswith("envs/x: env.toml: env_provider_type: Unknown env_provider_type: 'nope' (expected one of") + + +def test_a_multi_naming_a_store_env_of_another_type_is_refused(bundle_dir): + with namespace_routing(): + WebsiteEnv.put(id="web", backend_docker_image_artifact=_store_image("b"), frontend_docker_image_artifact=_store_image("f"), + environment_name="web") + _bundle(bundle_dir, {"envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["web"]\n'}, "x") + + assert _problems(bundle_dir) == ("envs/x: mcp_server_envs[0]: 'web' is a website in the store, but this field takes " + "mcp_server",) + + +def test_an_env_folder_whose_id_is_another_type_of_env_in_the_store_is_refused(bundle_dir): + with namespace_routing(): + WebsiteEnv.put(id=f"{ROOT}/x", backend_docker_image_artifact=_store_image("b"), + frontend_docker_image_artifact=_store_image("f"), environment_name="x") + _bundle(bundle_dir, _mcp("x"), "x") + + assert _problems(bundle_dir) == (f"envs/x: '{ROOT}/x' is a website env in the store, and an env keeps its type " + "across versions; rename the folder",) + + +def test_a_check_reports_an_env_toml_problem_without_reading_a_store(bundle_dir, monkeypatch): + _bundle(bundle_dir, {"envs/x/Dockerfile": DOCKERFILE}, "x") + monkeypatch.setattr("agent_env.config.runtime.Config.get_document_store", lambda self: RefusingStore()) + + with pytest.raises(BundleError) as caught: + check_bundle(resolve_bundle(parse_bundle(bundle_dir))) + + assert caught.value.problems == ("envs/x: env.toml: environment_name isn't set, and the source its image is built " + "from declares no @environment_card(name=...); set it",) diff --git a/tst/unit/bundle/ledger_test.py b/tst/unit/bundle/ledger_test.py index c66e9820..d8f2b12b 100644 --- a/tst/unit/bundle/ledger_test.py +++ b/tst/unit/bundle/ledger_test.py @@ -29,6 +29,7 @@ GREETING = f"{ROOT}/greeting" LAYOUT = { "envs/tickets/Dockerfile": "FROM scratch\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', "envs/shelf/env.toml": 'type = "shelf_ledger_test"\ndata = "greeting"\n', "artifacts/greeting/hello.txt": "hello\n", "tasks/t.json": json.dumps([ @@ -161,7 +162,7 @@ def _steps(*extra): def test_a_rerun_reuses_every_version_whose_inputs_havent_changed(bundle_dir): first = _run(bundle_dir) assert {id: check.reasons for id, check in first.items()} == dict.fromkeys( - [f"{ROOT}/tickets", GREETING, f"{ROOT}/shelf", f"{ROOT}/t"], ("new",)) + [f"{ROOT}/tickets__env_image", f"{ROOT}/tickets", GREETING, f"{ROOT}/shelf", f"{ROOT}/t"], ("new",)) second = _run(bundle_dir) diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index c912d39b..e5d895d8 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -109,17 +109,20 @@ def from_toml(cls, data, ctx): raise NotImplementedError -class _Imaged(Env): - """An env type whose image is built from the folder's Dockerfile.""" +class _OwnEnv(Env): + """A plugin's env type with a from_toml of its own.""" - type = "imaged_materialize_test" - toml_refs = (EntityRef.artifact("image", artifact_type="docker_image"),) + type = "own_env_materialize_test" + + @classmethod + def from_toml(cls, data, ctx): + raise NotImplementedError @pytest.fixture(autouse=True) def registries(monkeypatch): steps = {**Config().task_step_registry(), **{cls.type: cls for cls in (_Checked, _Writes, _Consuming)}} - envs = {**Config().env_registry(), _Imaged.type: _Imaged} + envs = {**Config().env_registry(), _OwnEnv.type: _OwnEnv} plugins = {cls.model_fields["type"].default: cls for cls in (_PluginFile, _OwnFile)} artifacts = {**Config().artifact_registry(), **plugins} monkeypatch.setattr(Config, "task_step_registry", lambda self: steps) @@ -186,8 +189,8 @@ def test_a_rerun_reuses_every_version_and_an_edit_rewrites_only_what_it_changed( def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir, dry_run): layout(bundle_dir, { "envs/tickets/Dockerfile": "FROM scratch\n", - "envs/imaged/env.toml": 'type = "imaged_materialize_test"\n', - "envs/imaged/Dockerfile": "FROM scratch\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', + "envs/own/env.toml": 'type = "own_env_materialize_test"\n', "agents/solver/Dockerfile": "FROM scratch\n", "skills/pdf/SKILL.md": "---\nname: pdf\n---\n", "artifacts/base-mcp/Dockerfile": "FROM scratch\n", @@ -196,7 +199,7 @@ def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir }) _steps(bundle_dir, [ {"id": "tickets", "type": "deploy_env", "env_id": "tickets"}, - {"id": "imaged", "type": "deploy_env", "env_id": "imaged"}, + {"id": "own", "type": "deploy_env", "env_id": "own"}, {"id": "agent", "type": "deploy_agent", "env_ids": ["tickets"], "a2a_agent_id": "solver"}, {"id": "pdf", "type": "load_artifact", "env_id": "tickets", "artifact_id": "pdf"}, {"id": "image", "type": "load_artifact", "env_id": "tickets", "artifact_id": "base-mcp"}, @@ -206,8 +209,7 @@ def test_what_has_no_writer_yet_is_refused_before_anything_is_written(bundle_dir assert sorted(_problems(lambda: _run(bundle_dir, dry_run))) == [ "artifacts/base-mcp: writing a docker_image artifact isn't supported yet", "artifacts/snap: writing an environment artifact isn't supported yet", - "envs/imaged: writing an env isn't supported yet", - "envs/tickets: writing an env isn't supported yet", + "envs/own: writing an own_env_materialize_test env isn't supported yet", "skills/pdf: writing a skill isn't supported yet", ] assert not local_store().path.exists() @@ -313,35 +315,6 @@ def test_another_bundles_entities_are_read_like_store_ids_and_an_agent_pins_the_ assert A2AAgent.get(f"{ROOT}/solver").docker_image_artifact.version == 2 -@pytest.fixture -def docker_on_path(monkeypatch): - monkeypatch.setattr(materialize_module.shutil, "which", lambda name: f"/usr/bin/{name}") - - -@pytest.fixture -def builds(monkeypatch, docker_on_path): - """Stands in for docker: each build is recorded with the files of its context, a link marked with a - trailing ``@``, and the image written as a docker_image document.""" - calls = [] - - def build(dockerfile, context, tag, *, platform): - calls.append({"build": (dockerfile.relative_to(context).as_posix(), _listing(context), tag, platform)}) - - def put(id, *, description, image_name, build_context_path=None, dockerfile_path=None): - calls[-1]["put"] = (id, image_name, _listing(build_context_path), dockerfile_path) - return get_artifact_store().put_document(DockerImageArtifact( - id=id, description=description, image_name=image_name, tar_gz_s3_url=f"file:///{id}.tar.gz")) - - monkeypatch.setattr(materialize_module, "build_image", build) - monkeypatch.setattr(materialize_module.DockerImageArtifact, "put", put) - return calls - - -def _listing(folder): - return sorted(path.relative_to(folder).as_posix() + ("@" if path.is_symlink() else "") - for path in Path(folder).rglob("*") if not path.is_dir() or path.is_symlink()) - - def test_an_agent_folder_with_a_dockerfile_is_built_and_the_agent_written_over_its_image(bundle_dir, builds): layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\nCOPY run.sh /\n", "agents/solver/run.sh": "echo hi\n"}) _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) diff --git a/tst/unit/bundle/plan_test.py b/tst/unit/bundle/plan_test.py index 4035e9d7..b2ae92f7 100644 --- a/tst/unit/bundle/plan_test.py +++ b/tst/unit/bundle/plan_test.py @@ -27,6 +27,7 @@ ROOT = "@local/~/triage" LAYOUT = { "envs/tickets/Dockerfile": "FROM scratch\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', "agents/solver/Dockerfile": "FROM scratch\n", "artifacts/greeting/hello.txt": "hello\n", "artifacts/greeting/check.py": "print('ok')\n", @@ -152,7 +153,8 @@ def test_a_bundle_without_evals_runs_every_task_and_writes_only_what_they_reach( "t": [deploy("tickets"), load("greeting"), {"id": "agent", "type": "deploy_agent", "env_ids": ["tickets"], "a2a_agent_id": "solver"}], })) - assert writes(plan) == [(BundleKind.ENV, f"{ROOT}/tickets"), (BundleKind.ARTIFACT, f"{ROOT}/solver__agent_image"), + assert writes(plan) == [(BundleKind.ARTIFACT, f"{ROOT}/tickets__env_image"), (BundleKind.ENV, f"{ROOT}/tickets"), + (BundleKind.ARTIFACT, f"{ROOT}/solver__agent_image"), (BundleKind.AGENT, f"{ROOT}/solver"), (BundleKind.ARTIFACT, f"{ROOT}/greeting"), (BundleKind.TASK, f"{ROOT}/t")] assert set(plan.writes[-1].needs) == {("env", f"{ROOT}/tickets"), ("agent", f"{ROOT}/solver"), @@ -163,7 +165,8 @@ def test_a_bundle_without_evals_runs_every_task_and_writes_only_what_they_reach( def test_a_bundle_with_evals_runs_every_eval_and_writes_the_tasks_they_name(make): plan = plan_bundle(make(tasks={"t": [deploy("tickets")], "u": [deploy("tickets")]}, evals={"regression": 'tasks = ["t"]'})) - assert writes(plan) == [(BundleKind.ENV, f"{ROOT}/tickets"), (BundleKind.TASK, f"{ROOT}/t"), + assert writes(plan) == [(BundleKind.ARTIFACT, f"{ROOT}/tickets__env_image"), (BundleKind.ENV, f"{ROOT}/tickets"), + (BundleKind.TASK, f"{ROOT}/t"), (BundleKind.EVAL, f"{ROOT}/regression")] assert plan.writes[-1].needs == (("task", f"{ROOT}/t"),) assert (plan.tasks, names(plan.evals)) == ((), ["regression"]) @@ -199,10 +202,11 @@ def test_each_write_comes_after_what_it_references(make): plan = plan_bundle(make(tasks={"t": [deploy("both")]}, files={ "envs/both/Dockerfile": "FROM scratch\n", "envs/both/env.toml": composite(mcp_server_envs=["tickets"]), })) - assert writes(plan) == [(BundleKind.ARTIFACT, f"{ROOT}/both__env_image"), (BundleKind.ENV, f"{ROOT}/tickets"), + assert writes(plan) == [(BundleKind.ARTIFACT, f"{ROOT}/both__env_image"), + (BundleKind.ARTIFACT, f"{ROOT}/tickets__env_image"), (BundleKind.ENV, f"{ROOT}/tickets"), (BundleKind.ENV, f"{ROOT}/both"), (BundleKind.TASK, f"{ROOT}/t")] assert plan.writes[0].needs == () - assert plan.writes[2].needs == (("artifact", f"{ROOT}/both__env_image"), ("env", f"{ROOT}/tickets")) + assert plan.writes[3].needs == (("artifact", f"{ROOT}/both__env_image"), ("env", f"{ROOT}/tickets")) @pytest.mark.parametrize(("files", "problem"), [ diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 0d1eeef7..3f18287f 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -382,6 +382,59 @@ def test_a_problem_several_deploys_share_is_reported_once_naming_the_first(bundl ] +# Envs the bundle writes, checked from their planned env.toml + +BUNDLE = "@local/~/triage" +SERVER = 'from agentenv_protocol import environment_card\n\n\n@environment_card(name="{}")\nclass S:\n pass\n' + + +def _env_folder(root, name, toml="", kind="mcp_server"): + files = ({f"envs/{name}/Dockerfile": "FROM scratch\n"} if kind == "mcp_server" else + {f"envs/{name}/Dockerfile.backend": "FROM scratch\n", f"envs/{name}/Dockerfile.frontend": "FROM scratch\n"}) + layout(root, {**files, f"envs/{name}/server.py": SERVER.format(name), + f"envs/{name}/env.toml": f'type = "{kind}"\n{toml}'}) + + +def test_an_image_the_bundle_builds_for_an_env_is_refused_on_another_provider(bundle_dir): + _env_folder(bundle_dir, "crm") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + + assert (f"tasks/t.json: step 'env': deploys env '{BUNDLE}/crm''s image '{BUNDLE}/crm__env_image' on the 'modal_vm' " + "sandbox provider, which can't reach it: it's built on this machine from envs/crm/Dockerfile; run it with " + "--sandbox local") in _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) + + +def test_a_website_the_bundle_writes_is_refused_on_modal_and_a_multi_holding_one_too(bundle_dir): + _env_folder(bundle_dir, "shop", kind="website") + layout(bundle_dir, {"envs/suite/env.toml": 'type = "multi"\nwebsite_envs = ["shop"]\n'}) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "suite"}]) + + assert (f"tasks/t.json: step 'env': deploys env '{BUNDLE}/suite', which has websites, on the 'modal' sandbox " + "provider, whose gateway runs in containers and can't serve websites; run it on a VM provider, such as " + "--sandbox local") in _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) + + +@pytest.mark.parametrize("folders, deployed, kinds", [ + ([("crm", "", "mcp_server")], "crm", ["service-db", "gateway"]), + ([("shop", "", "website")], "shop", ["service-db", "gateway", "website-browser"]), + ([("crm", "", "mcp_server"), ("shop", "", "website"), + ("suite", 'mcp_server_envs = ["crm"]\nwebsite_envs = ["shop"]\n', "multi")], "suite", + ["service-db", "gateway", "website-browser"]), + ([("crm", 'env_provider_type = "server"\n', "mcp_server")], "crm", []), +], ids=["mcp-server", "website", "multi", "server-provider"]) +def test_an_env_the_bundle_writes_names_the_infra_its_deploy_on_the_local_provider_builds( + bundle_dir, folders, deployed, kinds, +): + for name, toml, kind in folders: + if kind == "multi": + layout(bundle_dir, {f"envs/{name}/env.toml": f'type = "multi"\n{toml}'}) + else: + _env_folder(bundle_dir, name, toml, kind) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": deployed}]) + + assert [build.kind for build in dry_run_bundle(bundle_dir).infra] == kinds + + def test_the_cli_dry_run_lists_the_infra_it_would_build(bundle_dir, quiet_logs): _env("crm") _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) diff --git a/tst/unit/bundle/resolve_test.py b/tst/unit/bundle/resolve_test.py index 818ae1be..874711f7 100644 --- a/tst/unit/bundle/resolve_test.py +++ b/tst/unit/bundle/resolve_test.py @@ -15,6 +15,7 @@ ROOT = "@local/~/triage" LAYOUT = { "envs/tickets/Dockerfile": "FROM scratch\n", + "envs/tickets/env.toml": 'environment_name = "tickets"\n', "agents/solver/Dockerfile": "FROM scratch\n", "artifacts/greeting/hello.txt": "hello\n", "artifacts/greeting/check.py": "print('ok')\n", @@ -331,14 +332,15 @@ def test_an_eval_type_other_than_eval_is_refused(make): def test_a_toml_names_envs_and_images_in_the_bundle_or_the_store(make): toml = ('type = "composite_test"\nmcp_server_envs = ["tickets", { env = "crm", version = 2 }]\n' - 'image = "base-mcp"\n') + 'image = "base-mcp"\nbackend_image = { artifact = "store-backend", version = 4 }\n') entry = resolved(make(files={"envs/both/env.toml": toml}), "both") assert entry.config == {"type": "composite_test", "mcp_server_envs": [f"{ROOT}/tickets", {"env": "crm", "version": 2}], - "image": f"{ROOT}/base-mcp"} + "image": f"{ROOT}/base-mcp", "backend_image": {"artifact": "store-backend", "version": 4}} assert refs(entry) == [ (EntityKind.ENV, f"{ROOT}/tickets", None, "tickets"), (EntityKind.ENV, "crm", 2, None), (EntityKind.ARTIFACT, f"{ROOT}/base-mcp", None, "base-mcp"), + (EntityKind.ARTIFACT, "store-backend", 4, None), ] @@ -365,7 +367,8 @@ def test_an_agent_names_a_store_image_or_builds_its_folder(make): solver, judge = (next(e for e in result.entries if e.entry.name == name) for name in ("solver", "judge")) assert (solver.config, judge.config) == ( {"image": f"{ROOT}/solver__agent_image"}, {"image": {"artifact": "claude-image", "version": 3}}) - assert result.built_images == (BuiltImage(f"{ROOT}/solver__agent_image", solver.entry, "Dockerfile"),) + assert [image for image in result.built_images if image.entry.kind is BundleKind.AGENT] == [ + BuiltImage(f"{ROOT}/solver__agent_image", solver.entry, "Dockerfile")] assert (refs(solver), refs(judge)) == ( [(EntityKind.ARTIFACT, f"{ROOT}/solver__agent_image", None, "Dockerfile")], [(EntityKind.ARTIFACT, "claude-image", 3, None)], @@ -386,12 +389,13 @@ def test_an_agent_names_a_store_image_or_builds_its_folder(make): "or { env = \"\", version = }, the version optional, not {'env': 'crm', 'extra': 1}"), ]) def test_a_bad_toml_reference_is_refused(make, toml, problem): - bundle = make(files={"envs/web/env.toml": f'type = "composite_test"\n{toml}\n'}) + bundle = make(files={"envs/web/env.toml": f'type = "composite_test"\n{toml}\n', "envs/web/Dockerfile.backend": "FROM x\n"}) assert problems(bundle) == (f"envs/web: {problem}",) -def test_an_image_left_out_needs_a_dockerfile_to_build(make): +def test_an_image_left_out_needs_its_dockerfile_to_build_image_for_image_and_dockerfile_role_for_role_image(make): assert problems(make(files={"envs/web/env.toml": 'type = "composite_test"\n'})) == ( + "envs/web: backend_image: there is no 'Dockerfile.backend' in this folder to build", "envs/web: image: there is no 'Dockerfile' in this folder to build", ) @@ -433,11 +437,11 @@ def test_every_problem_is_reported_together(make): def test_entities_without_references_resolve_to_a_copy_of_their_config(make): result = resolve_bundle(make()) - assert {(e.entry.kind, e.entry.name): e.references for e in result.entries if e.entry.kind is not BundleKind.AGENT} == { - (BundleKind.ENV, "tickets"): (), (BundleKind.ARTIFACT, "greeting"): (), - (BundleKind.ARTIFACT, "base-mcp"): (), (BundleKind.SKILL, "pdf"): (), + built = (BundleKind.AGENT, BundleKind.ENV) # each names the image built from its folder + assert {(e.entry.kind, e.entry.name): e.references for e in result.entries if e.entry.kind not in built} == { + (BundleKind.ARTIFACT, "greeting"): (), (BundleKind.ARTIFACT, "base-mcp"): (), (BundleKind.SKILL, "pdf"): (), } - assert [image.entry.kind for image in result.built_images] == [BundleKind.AGENT] + assert {image.entry.kind for image in result.built_images} == set(built) @pytest.mark.parametrize(("depends_on", "problem"), [ diff --git a/tst/unit/bundle/toml_refs_test.py b/tst/unit/bundle/toml_refs_test.py new file mode 100644 index 00000000..59eaf52a --- /dev/null +++ b/tst/unit/bundle/toml_refs_test.py @@ -0,0 +1,80 @@ +"""Every type written from a bundle's toml declares exactly the keys that name other entities: each ``toml_refs`` +path is a key it takes, ``from_toml`` loads what each declared path names, and what it writes references nothing +else.""" + +import pytest + +from agent_env.a2a_agent import A2AAgent +from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.bundle import parse_bundle +from agent_env.bundle.authoring import AuthoringContext +from agent_env.bundle.parse import _EVAL_KEYS +from agent_env.env.envs.mcp_server import MCPServerEnv +from agent_env.env.envs.multi_env import MultiEnv +from agent_env.env.envs.website import WebsiteEnv +from agent_env.eval.eval import Eval +from tst.unit.bundle._support import layout + +WRITTEN_FROM_TOML = { # each type, the folder that holds one, and the keys it needs beside its references + MCPServerEnv: ("envs/server", {"environment_name": "server"}), + WebsiteEnv: ("envs/site", {"environment_name": "site"}), + MultiEnv: ("envs/suite", {}), + A2AAgent: ("agents/solver", {}), +} + + +@pytest.mark.parametrize("cls", [*WRITTEN_FROM_TOML, Eval], ids=lambda cls: cls.__name__) +def test_each_declared_path_starts_at_a_key_the_type_takes(cls): + takes = set(_EVAL_KEYS) if cls is Eval else set(cls.toml_keys) + assert cls.toml_refs and {ref.field for ref in cls.toml_refs} <= takes + + +def _image(id): + return DockerImageArtifact(id=id, version=1, description=id, image_name=f"registry.example/{id}:v1", + tar_gz_s3_url=f"s3://bucket/{id}.tar.gz") + + +_STAND_INS = { # what a load returns for each type a from_toml asks for + DockerImageArtifact: _image, + MCPServerEnv: lambda id: MCPServerEnv(id, 1, docker_image_artifact=_image(f"{id}-image"), environment_name=id), + WebsiteEnv: lambda id: WebsiteEnv(id, 1, backend_docker_image_artifact=_image(f"{id}-back"), + frontend_docker_image_artifact=_image(f"{id}-front"), environment_name=id), +} + + +@pytest.mark.parametrize("cls", WRITTEN_FROM_TOML, ids=lambda cls: cls.__name__) +def test_from_toml_loads_each_declared_ref_and_writes_no_other(tmp_path, monkeypatch, cls): + folder, needed = WRITTEN_FROM_TOML[cls] + root = layout(tmp_path / "b", {f"{folder}/{'agent' if folder.startswith('agents') else 'env'}.toml": + f'type = "{cls.type}"\n'}) + entry = next(entry for entry in parse_bundle(root).entries if entry.name == folder.split("/")[1]) + sentinels = {ref: f"sentinel-{ref.field}" for ref in cls.toml_refs} + data = {"type": cls.type, **needed, + **{ref.field: [id] if ref.path.endswith("[]") else id for ref, id in sentinels.items()}} + loads, written = [], {} + + def load(self, base, kind, ref, expect): + loads.append((kind, ref)) + return _STAND_INS[expect](ref) + + def put(**kwargs): + written.update(kwargs) + return cls(version=1, **kwargs) + + monkeypatch.setattr(AuthoringContext, "_load", load) + monkeypatch.setattr(cls, "put", put) + entity = cls.from_toml(data, AuthoringContext(parse_bundle(root), entry)) + + assert sorted(loads) == sorted((ref.kind, id) for ref, id in sentinels.items()) + assert _referenced(entity.to_dict()) <= set(sentinels.values()) + + +def _referenced(doc): + """The ids of the entities a written document names: each nested ``{id, version, ...}`` table.""" + found = set() + for value in doc.values(): + for item in value if isinstance(value, list) else [value]: + if isinstance(item, dict) and "id" in item and "version" in item: + found.add(item["id"]) + return found + From 3fe3630f2a6c1b032fcb3ff0da1b1642c9318938 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 12:22:02 -0700 Subject: [PATCH 4/6] fix(bundle): refuse a multi's clashing container names before any write, and preflight the planned versions A multi's MCP servers and websites run as containers their environment_names name: an MCP server as one by that name, a website as its backend's and frontend's. Two of a multi's envs could name one container across the two lists, which the check missed. It now compares the containers. On a provider other than the core's, an MCP server and a website of one name are refused here too, not only when the multi is written. The preflight read a store image or child env named without a version at its latest, while the writer pins the version the plan read; it now reads that version, and so does the check of a bundle agent's store image. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/plan.py | 44 +++++++++++++++++++++++++------ src/agent_env/bundle/preflight.py | 13 ++++++--- tst/unit/bundle/env_toml_test.py | 33 +++++++++++++++++++++-- tst/unit/bundle/preflight_test.py | 20 ++++++++++++++ 4 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 3dd97a89..191583e4 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -26,6 +26,11 @@ from agent_env.env.envs.website import WebsiteEnv from agent_env.env.registry import get_env_registry from agent_env.env.store import ENVS_COLLECTION +from agent_env.providers.env_providers.constants import ( + AGENT_ENV_WEBSITE_BACKEND_SUFFIX, + AGENT_ENV_WEBSITE_FRONTEND_SUFFIX, +) +from agent_env.providers.env_providers.env_provider import _env_provider_class, _SandboxEnvironmentProvider from agent_env.store import Filter, Sort from agent_env.store.base import NotFoundError from agent_env.store.ids import LOCAL_PREFIX @@ -331,25 +336,39 @@ def _check_env_types(self, closure: set[_Key]) -> None: "across versions; rename the folder") def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[EntityKind, str], int]) -> None: - """A multi's MCP servers each need their own environment_name, as its websites do: each is a container of - its deploy, named by it.""" + """Each env of a multi runs as containers its environment_name names: an MCP server as one by that name, a + website as its backend's and frontend's. No two of a multi's envs may name one container, and a provider + other than the core's, which gives a multi one env card, can't tell an MCP server and a website of one name + apart.""" for key in sorted(closure, key=self.rank.__getitem__): source = self.nodes[key] if isinstance(source, BuiltImage) or source.entry.kind is not BundleKind.ENV: continue if not issubclass(get_env_registry().get(source.entry.type, Env), MultiEnv): continue - first: dict[tuple[str, str], str] = {} + where = self._path(source.entry) + taken: dict[str, str] = {} # each container a child runs as, by the field naming the child + named: dict[str, set[str]] = {} # the environment_names of each list's envs for ref in source.references: name = self._environment_name(ref, store_latest) - group = ref.where.partition("[")[0] if name is None: continue - if (group, name) in first: - self.problems.append(f"{self._path(source.entry)}: {ref.where}: environment_name {name!r} is also " - f"{first[group, name]}'s; each of a multi's {group} needs its own") + group = ref.where.partition("[")[0] + named.setdefault(group, set()).add(name) + containers = [name] if group == "mcp_server_envs" else [ + f"{name}-{AGENT_ENV_WEBSITE_BACKEND_SUFFIX}", f"{name}-{AGENT_ENV_WEBSITE_FRONTEND_SUFFIX}"] + clash = next((container for container in containers if container in taken), None) + if clash is not None: + self.problems.append(f"{where}: {ref.where}: environment_name {name!r} names the container " + f"{clash!r}, as {taken[clash]}'s does; each env of a multi needs its own") else: - first[group, name] = ref.where + taken.update(dict.fromkeys(containers, ref.where)) + shared = named.get("mcp_server_envs", set()) & named.get("website_envs", set()) + provider_type = source.config.get("env_provider_type", "gateway") + if shared and not _deploys_in_sandboxes(provider_type): + self.problems.append(f"{where}: env_provider_type {provider_type!r} gives a multi one env card, which " + "can't tell an MCP server and a website apart by name, and both are named " + f"{', '.join(map(repr, sorted(shared)))}; rename one") def _environment_name(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int]) -> str | None: if ref.local is not None: @@ -417,6 +436,15 @@ def unpinned_store_refs(plan: Plan, write: Write) -> dict[tuple[EntityKind, str] for ref in write.source.references if ref.local is None and ref.version is None} +def _deploys_in_sandboxes(provider_type: str) -> bool: + """Whether the provider ``provider_type`` names is one of the core's, which deploy each env in a sandbox of its + own; a type no installed provider has is accepted here, since the env's toml check refuses it.""" + try: + return issubclass(_env_provider_class(provider_type), _SandboxEnvironmentProvider) + except ValueError: + return True + + def env_writer(cls: type | None) -> bool: """Whether an env of type ``cls`` is written from its env.toml, by a ``from_toml`` that reads only the toml and what it names: its own, or one it inherits from a core env type.""" diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index 8f4db136..67b81ca2 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -239,7 +239,8 @@ def _planned_images(self, env_id: str) -> tuple[list[_Image], bool]: child_images, child_websites = self._planned_images(ref.id) else: try: - child_images, child_websites = _stored_images(Env.get(ref.id, ref.version)) + child = Env.get(ref.id, self._planned_version(ref.kind, ref.id, ref.version)) + child_images, child_websites = _stored_images(child) except NotFoundError: continue # the plan reports a store env that isn't there images += child_images @@ -254,10 +255,15 @@ def _planned_image(self, env_id: str, ref: Reference) -> _Image | None: if ref.id in self.written: return None # another of the bundle's writes, which materialize refuses or writes first try: - return _Image(what, _local_only(DockerImageArtifact.get(ref.id, ref.version))) + return _Image(what, _local_only(DockerImageArtifact.get(ref.id, self._planned_version(ref.kind, ref.id, + ref.version)))) except NotFoundError: return None # the plan reports a store image that isn't there + def _planned_version(self, kind: EntityKind, entity_id: str, version: int | None) -> int | None: + """The version a write names a store entity at: its pin, else the one the plan read, which the writer pins.""" + return version if version is not None else self.plan.store_latest.get((kind, entity_id)) + def _agent(self, where: str, step: DeployAgentTaskStep, sandboxes: dict[str, DeploySandboxTaskStep]) -> None: if step.sandbox_name: linked = sandboxes.get(step.sandbox_name) @@ -311,7 +317,8 @@ def _agent_image(self, agent_id: str, version: int | None) -> _Image | None: return _Image(what, f"it's built on this machine from {path}/{built.dockerfile}") if image_id in self.written: return None # another of the bundle's writes, which materialize refuses or writes first - return _Image(what, _local_only(DockerImageArtifact.get(image_id, image_version))) + planned = self._planned_version(EntityKind.ARTIFACT, image_id, image_version) + return _Image(what, _local_only(DockerImageArtifact.get(image_id, planned))) try: return _Image(what, _local_only(A2AAgent.get(agent_id, version).docker_image_artifact)) except NotFoundError: diff --git a/tst/unit/bundle/env_toml_test.py b/tst/unit/bundle/env_toml_test.py index e15f30ca..aec6fb3f 100644 --- a/tst/unit/bundle/env_toml_test.py +++ b/tst/unit/bundle/env_toml_test.py @@ -11,15 +11,22 @@ from agent_env.bundle import BundleError, parse_bundle from agent_env.bundle.plan import check_bundle from agent_env.bundle.resolve import resolve_bundle +from agent_env.bundle import plan as plan_module from agent_env.bundle.materialize import materialize from agent_env.env import Env from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv from agent_env.env.envs.website import WebsiteEnv +from agent_env.providers.env_providers import env_provider as env_provider_module from agent_env.store.routing import namespace_routing from tst.unit.bundle._support import RefusingStore, layout, local_store, plan_of ROOT = "@local/~/triage" + + +class _OneCard: + """A plugin's provider, which deploys a multi behind one env card rather than a sandbox per env.""" + CARD = 'from agentenv_protocol import environment_card\n\n\n@environment_card(name="{}")\nclass Server:\n pass\n' DOCKERFILE = "FROM scratch\nCOPY . /app\n" @@ -191,8 +198,12 @@ def _website(name, toml=""): ({**_website("shop"), "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["shop"]\n'}, "envs/x: mcp_server_envs[0]: 'shop' is this bundle's website, but this field takes mcp_server"), ({**_mcp("a", "crm"), **_mcp("b", "crm"), "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["a", "b"]\n'}, - "envs/x: mcp_server_envs[1]: environment_name 'crm' is also mcp_server_envs[0]'s; each of a multi's " - "mcp_server_envs needs its own"), + "envs/x: mcp_server_envs[1]: environment_name 'crm' names the container 'crm', as mcp_server_envs[0]'s does; " + "each env of a multi needs its own"), + ({**_mcp("a", "shop-website-backend"), **_website("shop"), + "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["a"]\nwebsite_envs = ["shop"]\n'}, + "envs/x: website_envs[0]: environment_name 'shop' names the container 'shop-website-backend', as " + "mcp_server_envs[0]'s does; each env of a multi needs its own"), ({"envs/x/env.toml": 'type = "gateway_server"\n'}, "envs/x: a gateway_server env isn't written from a bundle: config names the one every deploy uses " "(default_gateway_env_id), and a run builds it when it's missing"), @@ -201,6 +212,7 @@ def _website(name, toml=""): "(default_service_db_env_id), and a run builds it when it's missing"), ], ids=["no-card", "several-cards", "store-image-unnamed", "metadata", "empty-name", "reserved-name", "website-on-server", "build-args", "multi-without-envs", "multi-name", "multi-wrong-child", "multi-same-names", + "multi-same-container", "gateway", "service-db"]) def test_an_env_toml_that_cant_be_written_is_refused_before_any_write(bundle_dir, files, problem): _store_image("base") @@ -217,6 +229,23 @@ def test_an_unknown_env_provider_type_is_refused_naming_the_known_ones(bundle_di assert problem.startswith("envs/x: env.toml: env_provider_type: Unknown env_provider_type: 'nope' (expected one of") +def test_a_plugin_provider_multi_with_an_mcp_server_and_a_website_of_one_name_is_refused_before_any_write( + bundle_dir, monkeypatch, +): + real = env_provider_module._env_provider_class + providers = lambda name: _OneCard if name == "one_card_test" else real(name) + monkeypatch.setattr(env_provider_module, "_env_provider_class", providers) + monkeypatch.setattr(plan_module, "_env_provider_class", providers) + _bundle(bundle_dir, {**_mcp("crm", "shop"), **_website("shop"), "envs/x/env.toml": + 'type = "multi"\nmcp_server_envs = ["crm"]\nwebsite_envs = ["shop"]\n' + 'env_provider_type = "one_card_test"\n'}, "x") + + assert _problems(bundle_dir) == ( + "envs/x: env_provider_type 'one_card_test' gives a multi one env card, which can't tell an MCP server and a " + "website apart by name, and both are named 'shop'; rename one",) + assert not local_store().path.exists() + + def test_a_multi_naming_a_store_env_of_another_type_is_refused(bundle_dir): with namespace_routing(): WebsiteEnv.put(id="web", backend_docker_image_artifact=_store_image("b"), frontend_docker_image_artifact=_store_image("f"), diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 3f18287f..2372c98a 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -11,6 +11,7 @@ from click.testing import CliRunner import agent_env.bundle.preflight as preflight_module +import agent_env.bundle.run as run_module from agent_env.a2a_agent import A2AAgent from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.store import get_artifact_store @@ -435,6 +436,25 @@ def test_an_env_the_bundle_writes_names_the_infra_its_deploy_on_the_local_provid assert [build.kind for build in dry_run_bundle(bundle_dir).infra] == kinds +def test_a_store_image_an_env_names_without_a_version_is_checked_at_the_version_the_plan_read( + bundle_dir, monkeypatch, +): + _image("base", REMOTE) + _infra(REMOTE) + layout(bundle_dir, {"envs/crm/env.toml": 'image = "base"\nenvironment_name = "crm"\n'}) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + planned = run_module.plan_bundle + + def a_local_image_lands_once_planned(*args, **kwargs): + plan = planned(*args, **kwargs) + _image("base", LOCAL) # v2, after the plan read v1, which the env is written at + return plan + + monkeypatch.setattr(run_module, "plan_bundle", a_local_image_lands_once_planned) + + assert dry_run_bundle(bundle_dir, sandbox="modal_vm").runs + + def test_the_cli_dry_run_lists_the_infra_it_would_build(bundle_dir, quiet_logs): _env("crm") _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) From 6d807e6899c0a60700d51c1983fd94039643d233 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 15:46:36 -0700 Subject: [PATCH 5/6] test(bundle): deploy each kind of bundle env through the gateway and check it by calling it An MCP server built from its folder and named by its card, one built from docker/Dockerfile under a name of its own, one over a store image a CLI put wrote, a website, and a multi of bundle and store envs: each is deployed on the local sandbox through the gateway and answers a verifier that calls its tools or pages. An edit to a multi's child rebuilds that child alone and rewrites the multi, which then serves the new build. A multi whose children name one container is refused before anything is built or written. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/run_bundle_built_envs_test.py | 227 +++++++++++++++++- 1 file changed, 221 insertions(+), 6 deletions(-) diff --git a/tst/integration/cli/run_bundle_built_envs_test.py b/tst/integration/cli/run_bundle_built_envs_test.py index 3de3070c..d11c10d8 100644 --- a/tst/integration/cli/run_bundle_built_envs_test.py +++ b/tst/integration/cli/run_bundle_built_envs_test.py @@ -1,7 +1,11 @@ -"""``agent-env run`` on a bundle whose MCP server env is built from its folder, with real docker builds and the env -deployed on the local sandbox: named by the environment card in its source, reused while its folder is unchanged, and -rebuilt when it changes. Needs a Docker daemon and the local registry.""" +"""``agent-env run`` on bundles whose envs are written from their folders, with real docker builds and the envs +deployed on the local sandbox. An MCP server on the server provider is named by the environment card in its source, +reused while its folder is unchanged, and rebuilt when it changes. Through the gateway, each way of writing an env +deploys and answers a verifier calling it: an MCP server built from its folder, one built from a Dockerfile elsewhere +under a name of its own, one over a store image, a website, and a multi of bundle and store envs, which is rewritten +when a child is. Needs a Docker daemon and the local registry.""" +import hashlib import json import logging import shutil @@ -20,10 +24,62 @@ pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] REPO = Path(__file__).resolve().parents[3] -ITEMS = REPO / "tst" / "data" / "agentenv_mcp" # an in-memory MCP server whose card names it 'items' +DATA = REPO / "tst" / "data" +ITEMS = DATA / "agentenv_mcp" # an in-memory MCP server whose card names it 'items' PROTOCOL = REPO / "packages" / "agentenv-protocol" / "src" / "agentenv_protocol" IMAGE = "envs/items (Dockerfile image)" +# Checks what an env serves through its gateway, by calling it: each items server adds an item under its own +# environment_name's tool and lists it back from the build it was given, the slack server lists its channels, and each +# website serves its frontend and its backend's health. CONFIG, naming what to check, goes above it. +VERIFY = ''' +import json + +import httpx +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def verify(mcp_url): + results = [] + + def check(id, passed, description): + results.append({"id": id, "result": bool(passed), "description": description}) + + if CONFIG["items"] or CONFIG["slack"]: + async with streamablehttp_client(mcp_url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + names = [tool.name for tool in (await session.list_tools()).tools] + + async def call(suffix, arguments): + name = next((name for name in names if name.endswith(suffix)), None) + if name is None: + return None + result = await session.call_tool(name, arguments) + return None if result.isError else " ".join(getattr(part, "text", "") for part in result.content) + + for name in CONFIG["items"]: + added = await call(f"{name}_add_item", {"item": f"from-{name}"}) + listed = json.loads(await call("list_items", {}) or "{}") + check(f"{name}-items", added and f"from-{name}" in listed.get("items", []), + f"{name}_add_item adds an item list_items lists, among {names}") + check(f"{name}-build", listed.get("build") == CONFIG["build"], + f"list_items answers from build {CONFIG['build']}: {listed}") + if CONFIG["slack"]: + channels = await call("channels_list", {"channel_types": "public_channel"}) + check("slack", channels and json.loads(channels).get("ok"), f"channels_list answers: {channels}") + gateway = mcp_url.rsplit("/mcp", 1)[0] + async with httpx.AsyncClient(timeout=30) as client: + for name in CONFIG["websites"]: + page = await client.get(f"{gateway}/website/{name}/") + check(f"{name}-frontend", page.status_code == 200 and "Slack Workspace" in page.text, + f"the frontend serves its page: {page.status_code}") + health = await client.get(f"{gateway}/svc/{name}/api/health") + check(f"{name}-backend", health.status_code == 200, f"the backend is healthy: {health.status_code}") + return results +''' + @pytest.fixture def state(monkeypatch, tmp_path): @@ -61,8 +117,8 @@ def _bundle(root): return root -def _run(root): - return CliRunner().invoke(cli, ["run", str(root)]) +def _run(root, *args): + return CliRunner().invoke(cli, ["run", str(root), *args]) def test_an_env_folder_is_built_named_by_its_card_deployed_and_rebuilt_only_when_it_changes(state): @@ -91,3 +147,162 @@ def test_an_env_folder_is_built_named_by_its_card_deployed_and_rebuilt_only_when assert f"{IMAGE}: v2 (files changed: seed.json)" in edited.output, edited.output assert "envs/items: v2 (" in edited.output and "tasks/items.json v1: unscored" in edited.output, edited.output assert edited.output.count("building with docker") == 1, edited.output + + +def _server(build): + """The items server's source, its list_items answering with ``build``.""" + source = (ITEMS / "server.py").read_text() + server = source.replace('json.dumps({"items": self.store})', + f'json.dumps({{"items": self.store, "build": "{build}"}})') + assert server != source + return server + + +def _items(folder, build, dockerfile="Dockerfile"): + """The items server of ``build``, built from ``dockerfile`` in its folder.""" + shutil.copytree(PROTOCOL, folder / "agentenv_protocol", ignore=shutil.ignore_patterns("__pycache__")) + (folder / "server.py").write_text(_server(build)) + shutil.copy(ITEMS / "seed.json", folder / "seed.json") + (folder / dockerfile).parent.mkdir(parents=True, exist_ok=True) + shutil.copy(ITEMS / "Dockerfile", folder / dockerfile) + + +def _shop(folder): + """The slack website as an env folder: its backend and frontend built from Dockerfile.backend and + Dockerfile.frontend, with the folder as their context.""" + no_dockerfiles = shutil.ignore_patterns("Dockerfile", "__pycache__") + for part in ("backend", "frontend"): + shutil.copytree(DATA / "slack_website" / part, folder / part, ignore=no_dockerfiles) + dockerfile = (DATA / "slack_website" / part / "Dockerfile").read_text() + (folder / f"Dockerfile.{part}").write_text(dockerfile.replace("slack_website/", "")) + for package in ("base_mcp", "slack_mcp"): + shutil.copytree(DATA / package, folder / package, ignore=no_dockerfiles) + (folder / "env.toml").write_text('type = "website"\nenvironment_name = "shop"\n') + + +def _check(root, env, *, items=(), slack=False, websites=(), build="v1"): + """A task deploying ``env`` and verifying what it serves, its check a file artifact of the bundle.""" + config = {"items": list(items), "slack": slack, "websites": list(websites), "build": build} + (root / f"artifacts/check-{env}").mkdir(parents=True, exist_ok=True) + (root / f"artifacts/check-{env}/verify.py").write_text(f"CONFIG = {config!r}\n{VERIFY}") + (root / "tasks").mkdir(exist_ok=True) + (root / f"tasks/{env}.json").write_text(json.dumps([ + {"id": "env", "type": "deploy_env", "env_id": env, "sandbox_type": "local"}, + {"id": "check", "type": "env_outcome_verifier", "env_id": env, "file_artifact_id": f"check-{env}", + "verifier_id": env, "depends_on": ["env"]}, + ])) + + +def _gateway_bundle(root): + """An env of each kind a bundle writes, all deployed through the gateway: items and stock are the items server, + built from its folder and named by its card, and built from docker/Dockerfile under a name of its own; relay is an + MCP server over the store image the CLI put for mail; shop is a website; suite is a multi of items, mail and + shop.""" + envs = root / "envs" + _items(envs / "items", "v1") + _items(envs / "stock", "v1", dockerfile="docker/Dockerfile") + (envs / "stock/env.toml").write_text('image = { dockerfile = "docker/Dockerfile" }\nenvironment_name = "stock"\n') + (envs / "relay").mkdir() + (envs / "relay/env.toml").write_text('image = "mail__env_image"\nenvironment_name = "relay"\n') + _shop(envs / "shop") + (envs / "suite").mkdir() + (envs / "suite/env.toml").write_text( + 'type = "multi"\nmcp_server_envs = ["items", "mail"]\nwebsite_envs = ["shop"]\nname = "suite"\n') + _check(root, "items", items=["items"]) + _check(root, "stock", items=["stock"]) + _check(root, "relay", slack=True) + _check(root, "shop", websites=["shop"]) + _check(root, "suite", items=["items"], slack=True, websites=["shop"]) + return root + + +def _put_mail(): + """The slack server as a store env, mail, put by the CLI as a user would before a bundle names it.""" + put = CliRunner().invoke(cli, ["env", "mcp-server", "put", "--id", "mail", "--environment-name", "mail", + "--dockerfile", str(DATA / "slack_mcp/Dockerfile"), "--context", str(DATA), + "--platform", ""]) + assert put.exit_code == 0, put.output + + +GATEWAY_IMAGES = ("envs/items (Dockerfile image)", "envs/stock (docker/Dockerfile image)", + "envs/shop (Dockerfile.backend image)", "envs/shop (Dockerfile.frontend image)") +GATEWAY_ENVS = ("items", "stock", "relay", "shop", "suite") + + +def test_each_kind_of_bundle_env_deploys_through_the_gateway_and_a_child_edit_rewrites_its_multi(state): + _put_mail() + root = _gateway_bundle(state / "gateway-bundle") + + first = _run(root) + + assert first.exit_code == 0, first.output + for image in GATEWAY_IMAGES: + assert f"{image}: building with docker" in first.output and f"{image}: v1 (new)" in first.output, first.output + for env in GATEWAY_ENVS: + assert f"envs/{env}: v1 (new)" in first.output, first.output + assert f"tasks/{env}.json v1: passed (check: 1)" in first.output, first.output + with namespace_routing(): + ids = {entry.name: entry.id for entry in parse_bundle(root).entries} + written = {name: Env.get(ids[name]) for name in GATEWAY_ENVS} + mail = Env.get("mail") + assert {name: env.environment_name for name, env in written.items() if name != "suite"} == { + "items": "items", "stock": "stock", "relay": "relay", "shop": "shop"} + assert written["relay"].docker_image_artifact.id == mail.docker_image_artifact.id == "mail__env_image" + assert [child.id for child in written["suite"].mcp_server_envs] == [ids["items"], "mail"] + assert [child.id for child in written["suite"].website_envs] == [ids["shop"]] + assert written["suite"].name == "suite" + + # The checks now expect build v2 from items, alone and inside suite. + (root / "envs/items/server.py").write_text(_server("v2")) + _check(root, "items", items=["items"], build="v2") + _check(root, "suite", items=["items"], slack=True, websites=["shop"], build="v2") + edited = _run(root, "--task", "items", "--task", "suite") + + assert edited.exit_code == 0, edited.output + assert f"{IMAGE}: v2 (files changed: server.py)" in edited.output, edited.output + assert edited.output.count("building with docker") == 1, edited.output + assert "envs/items: v2 (" in edited.output and "envs/suite: v2 (" in edited.output, edited.output + assert "envs/shop: v1, unchanged" in edited.output, edited.output + for env in ("items", "suite"): + assert f"artifacts/check-{env}: v2 (files changed: verify.py)" in edited.output, edited.output + assert f"tasks/{env}.json v1: passed (check: 1)" in edited.output, edited.output + + planned = _run(root, "--dry-run") + + assert planned.exit_code == 0, planned.output + versions = {"items": 2, "suite": 2} + for image in GATEWAY_IMAGES: + assert f"{image}: v{2 if image == IMAGE else 1}, unchanged" in planned.output, planned.output + for env in GATEWAY_ENVS: + assert f"envs/{env}: v{versions.get(env, 1)}, unchanged" in planned.output, planned.output + + +def _files(root): + """A digest of every file under ``root`` but the locks a run takes.""" + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if path.is_file() and "locks" not in path.parts: + digest.update(str(path.relative_to(root)).encode() + path.read_bytes()) + return digest.hexdigest() + + +def test_a_multi_whose_children_name_one_container_is_refused_before_anything_is_built_or_written(state): + root = state / "clash-bundle" + envs = root / "envs" + _items(envs / "items", "v1") + _items(envs / "stock", "v1") + (envs / "stock/env.toml").write_text('environment_name = "items"\n') + (envs / "suite").mkdir() + (envs / "suite/env.toml").write_text('type = "multi"\nmcp_server_envs = ["items", "stock"]\n') + (root / "tasks").mkdir() + (root / "tasks/suite.json").write_text(json.dumps( + [{"id": "env", "type": "deploy_env", "env_id": "suite", "sandbox_type": "local"}])) + before = _files(state) + + result = _run(root) + + assert result.exit_code == 1, result.output + assert ("mcp_server_envs[1]: environment_name 'items' names the container 'items', as mcp_server_envs[0]'s does" + in result.output), result.output + assert "building with docker" not in result.output and "Traceback" not in result.output, result.output + assert _files(state) == before From 5b99bae557dc5087af5256146607df21cff7daf4 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 6 Oct 2026 16:51:25 -0700 Subject: [PATCH 6/6] refactor(bundle): check env.toml in the authoring context, against the deploy path's provider rules _toml.py goes. The checks every env.toml gets now live on AuthoringContext as accept_env and accepted_env, beside accepted and card_names, so a plugin env type writing its own from_toml can make them too. The MCP server and website accept_toml are each one call. What a provider can't deploy is one function, provider_refusal, in _deployment.py: the server provider deploys one MCP server, and a plugin's provider can't tell a multi's MCP server and website of one name apart. deploy_refusal, the env.toml check and the plan's multi check all call it, so the bundle refuses exactly what a deploy would. The one_server flag and the function-level provider imports go with it. Also: the environment_name lookup returns its name and problem rather than writing into the fields; ctx.config_problem prefixes a problem with the entry's toml for env and agent tomls alike; and the accepted fields carry env_provider_type, defaulting to the gateway provider's type, so from_toml no longer repeats the default. Wording: an unknown provider type reads 'env.toml: Unknown env_provider_type: ...', and the multi refusal reads 'gives a multi one env card, so it can't tell ...' at deploy and in a bundle alike. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/a2a_agent/a2a_agent.py | 2 +- src/agent_env/bundle/authoring.py | 73 +++++++++++++++++-- src/agent_env/bundle/plan.py | 30 ++++---- src/agent_env/bundle/preflight.py | 3 +- src/agent_env/env/envs/_deployment.py | 35 ++++++--- src/agent_env/env/envs/_toml.py | 59 --------------- src/agent_env/env/envs/mcp_server.py | 9 +-- src/agent_env/env/envs/multi_env.py | 9 +-- src/agent_env/env/envs/website.py | 9 +-- tst/unit/bundle/env_toml_test.py | 9 ++- .../envs/test_multi_env_plugin_provider.py | 3 +- 11 files changed, 124 insertions(+), 117 deletions(-) delete mode 100644 src/agent_env/env/envs/_toml.py diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index f427a8a3..e389cf90 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -352,7 +352,7 @@ def accept_toml(cls, data: dict[str, Any], ctx: AuthoringContext) -> dict[str, A kinds, described = cls.toml_metadata[name] if isinstance(value, bool) or not isinstance(value, kinds): values.append(f"metadata.{name} must be {described}, not {value!r}") - problems += [f"agent.toml: {problem}" for problem in values] + problems += [ctx.config_problem(problem) for problem in values] if problems: ctx.refuse(problems) return fields diff --git a/src/agent_env/bundle/authoring.py b/src/agent_env/bundle/authoring.py index d4d7cba4..d70a2efd 100644 --- a/src/agent_env/bundle/authoring.py +++ b/src/agent_env/bundle/authoring.py @@ -12,6 +12,10 @@ from agent_env.artifact.artifact import Artifact from agent_env.entity_refs import EntityKind, parse_toml_ref from agent_env.env.env import Env +from agent_env.env.envs._deployment import provider_refusal +from agent_env.providers.env_providers.constants import GATEWAY_SERVICE_NAMES +from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider +from agent_env.providers.env_providers.env_provider import _env_provider_class from agent_env.utils.card_naming import card_names_in_files from ._fs import os_reason, relative, show, with_article @@ -81,20 +85,79 @@ def accept(self, data: dict, /, **takes: type) -> dict: def accepted(self, data: dict, /, **takes: type) -> tuple[dict, list[str]]: """``accept`` without refusing: the keys of ``data`` a type takes and of the type given, and the problems with the rest, for a type that checks more before it refuses.""" - config = CONFIG_FILES[self.entry.kind] problems = [] unknown = sorted(set(data) - {"type", "id", *takes}) if unknown: names = [*takes, "type", "id"] - problems.append(f"{config}: unknown key{'s' * (len(unknown) > 1)} {', '.join(map(repr, unknown))}; " - f"{with_article(f'{self.entry.type} {self.entry.kind.store}')} takes " - f"{', '.join(names[:-1])} and {names[-1]}") + problems.append(self.config_problem( + f"unknown key{'s' * (len(unknown) > 1)} {', '.join(map(repr, unknown))}; " + f"{with_article(f'{self.entry.type} {self.entry.kind.store}')} takes " + f"{', '.join(names[:-1])} and {names[-1]}")) for key, kind in takes.items(): if key in data and not isinstance(data[key], kind): - problems.append(f"{config}: {key} must be {_TOML_TYPES.get(kind, kind.__name__)}, not {data[key]!r}") + problems.append(self.config_problem( + f"{key} must be {_TOML_TYPES.get(kind, kind.__name__)}, not {data[key]!r}")) fields = {key: data[key] for key, kind in takes.items() if key in data and isinstance(data[key], kind)} return fields, problems + def accept_env(self, data: dict, env_class: type[Env], *, named_by: str | None = None) -> dict: + """The keys of ``data``, an env.toml, ``env_class`` takes, checked the way ``accepted_env`` checks + them; every problem is refused.""" + fields, problems = self.accepted_env(data, env_class, named_by=named_by) + if problems: + self.refuse(problems) + return fields + + def accepted_env(self, data: dict, env_class: type[Env], *, + named_by: str | None = None) -> tuple[dict, list[str]]: + """``accepted`` for an env.toml: the keys ``env_class`` takes, and the problems with them. The fields + carry its env_provider_type, the gateway's when left out, which must name an installed provider that + deploys an env of ``env_class``. With ``named_by``, they carry its environment_name too: the one set, + else the one the ``@environment_card`` in the source of the image that key builds gives, which a + gateway deploy can't take from one of its own containers.""" + fields, problems = self.accepted(data, **env_class.toml_keys) + provider_type = fields.setdefault("env_provider_type", EnvironmentGatewayProvider.type) + try: + provider = _env_provider_class(provider_type) + except ValueError as e: + problems.append(self.config_problem(str(e))) + provider = None + if provider is not None and (reason := provider_refusal(provider, env_class)) is not None: + problems.append(self.config_problem(f"env_provider_type {provider_type!r} {reason}")) + if named_by is not None: + name, problem = self._environment_name(data, fields, named_by) + if problem is not None: + problems.append(self.config_problem(problem)) + elif name is not None: + fields["environment_name"] = name + if (name in GATEWAY_SERVICE_NAMES and provider is not None + and issubclass(provider, EnvironmentGatewayProvider)): + problems.append(self.config_problem( + f"environment_name {name!r} is one a gateway deploy names its own containers " + f"({', '.join(sorted(GATEWAY_SERVICE_NAMES))}); choose another")) + return fields, problems + + def _environment_name(self, data: dict, fields: dict, named_by: str) -> tuple[str | None, str | None]: + """The env's environment_name, and the problem when there's none: the one set, else the one the source + of the image ``named_by`` builds from this folder declares.""" + if "environment_name" in data: # one of the wrong type is a problem accepted() reported + name = fields.get("environment_name") + return name, "environment_name can't be empty" if name == "" else None + names = self.card_names(named_by) + what = "image" if named_by == "image" else named_by.replace("_", " ") + if names is None: + return None, (f"environment_name isn't set, and its {what} isn't built from this folder, so there's no " + "source to read it from; set it") + if len(names) != 1: + found = (f"several environment cards ({', '.join(map(repr, names))})" if names + else "no @environment_card(name=...)") + return None, f"environment_name isn't set, and the source its {what} is built from declares {found}; set it" + return names[0], None + + def config_problem(self, message: str) -> str: + """``message`` as a problem with the entry's toml, ready for ``refuse``.""" + return f"{CONFIG_FILES[self.entry.kind]}: {message}" + def refuse(self, problems: list[str]) -> NoReturn: """Raise BundleError with ``problems``, each named by the entry's folder.""" where = relative(self.bundle.root, self.entry.path) diff --git a/src/agent_env/bundle/plan.py b/src/agent_env/bundle/plan.py index 191583e4..ae338704 100644 --- a/src/agent_env/bundle/plan.py +++ b/src/agent_env/bundle/plan.py @@ -21,6 +21,7 @@ from agent_env.config import get_config from agent_env.entity_refs import EntityKind from agent_env.env.env import Env +from agent_env.env.envs._deployment import provider_refusal from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv from agent_env.env.envs.website import WebsiteEnv @@ -30,7 +31,8 @@ AGENT_ENV_WEBSITE_BACKEND_SUFFIX, AGENT_ENV_WEBSITE_FRONTEND_SUFFIX, ) -from agent_env.providers.env_providers.env_provider import _env_provider_class, _SandboxEnvironmentProvider +from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider +from agent_env.providers.env_providers.env_provider import _env_provider_class from agent_env.store import Filter, Sort from agent_env.store.base import NotFoundError from agent_env.store.ids import LOCAL_PREFIX @@ -363,12 +365,17 @@ def _check_multi_names(self, closure: set[_Key], store_latest: dict[tuple[Entity f"{clash!r}, as {taken[clash]}'s does; each env of a multi needs its own") else: taken.update(dict.fromkeys(containers, ref.where)) - shared = named.get("mcp_server_envs", set()) & named.get("website_envs", set()) - provider_type = source.config.get("env_provider_type", "gateway") - if shared and not _deploys_in_sandboxes(provider_type): - self.problems.append(f"{where}: env_provider_type {provider_type!r} gives a multi one env card, which " - "can't tell an MCP server and a website apart by name, and both are named " - f"{', '.join(map(repr, sorted(shared)))}; rename one") + provider_type = source.config.get("env_provider_type", EnvironmentGatewayProvider.type) + try: + provider = _env_provider_class(provider_type) + except ValueError: + continue # the env's toml check refuses a type no installed provider has + if provider_refusal(provider, MultiEnv) is not None: + continue # and one that deploys no multi at all + reason = provider_refusal(provider, MultiEnv, mcp_names=named.get("mcp_server_envs", ()), + website_names=named.get("website_envs", ())) + if reason is not None: + self.problems.append(f"{where}: env_provider_type {provider_type!r} {reason}; rename one") def _environment_name(self, ref: Reference, store_latest: dict[tuple[EntityKind, str], int]) -> str | None: if ref.local is not None: @@ -436,15 +443,6 @@ def unpinned_store_refs(plan: Plan, write: Write) -> dict[tuple[EntityKind, str] for ref in write.source.references if ref.local is None and ref.version is None} -def _deploys_in_sandboxes(provider_type: str) -> bool: - """Whether the provider ``provider_type`` names is one of the core's, which deploy each env in a sandbox of its - own; a type no installed provider has is accepted here, since the env's toml check refuses it.""" - try: - return issubclass(_env_provider_class(provider_type), _SandboxEnvironmentProvider) - except ValueError: - return True - - def env_writer(cls: type | None) -> bool: """Whether an env of type ``cls`` is written from its env.toml, by a ``from_toml`` that reads only the toml and what it names: its own, or one it inherits from a core env type.""" diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index 67b81ca2..03d5ce3f 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -219,8 +219,9 @@ def _planned(self, env_id: str) -> _Deployment | None: cls = get_env_registry().get(self.envs[env_id].entry.type) if cls is None or not issubclass(cls, (MCPServerEnv, WebsiteEnv, MultiEnv)): return None + provider_type = self.envs[env_id].config.get("env_provider_type", EnvironmentGatewayProvider.type) try: - provider_class = _env_provider_class(self.envs[env_id].config.get("env_provider_type", "gateway")) + provider_class = _env_provider_class(provider_type) except ValueError: return None images, websites = self._planned_images(env_id) diff --git a/src/agent_env/env/envs/_deployment.py b/src/agent_env/env/envs/_deployment.py index e86917c4..47ffab57 100644 --- a/src/agent_env/env/envs/_deployment.py +++ b/src/agent_env/env/envs/_deployment.py @@ -15,6 +15,8 @@ from agent_env.store.base import ObjectNotFoundError if TYPE_CHECKING: + from collections.abc import Iterable + from agent_env.artifact import FileArtifact from agent_env.env.env import DeployedEnv, Env from agent_env.env.envs.mcp_server import MCPServerEnv @@ -247,23 +249,34 @@ def provider_or_class(env: Env) -> EnvironmentProvider | type[EnvironmentProvide def deploy_refusal(env: Env, provider: EnvironmentProvider | type[EnvironmentProvider], options: dict) -> str | None: - """Why deploying env through provider, or a provider of that class, is refused before anything is built, or None: the server provider - for an env other than one MCP server, a plugin's MultiEnv with an MCP server and a website of one name, or an option it can't take.""" + """Why deploying env through provider, or a provider of that class, is refused before anything is built, or None: the provider + can't deploy an env like it (see ``provider_refusal``), or an option it can't take.""" + provider_class = provider if isinstance(provider, type) else type(provider) + reason = provider_refusal(provider_class, type(env), + mcp_names=[child.environment_name for child in getattr(env, "mcp_server_envs", ())], + website_names=[child.environment_name for child in getattr(env, "website_envs", ())]) + if reason is not None: + return f"env '{env.id}' has env_provider_type '{env.env_provider_type}', which {reason}" + return option_refusal(env, provider, options) + + +def provider_refusal(provider_class: type[EnvironmentProvider], env_class: type[Env], *, mcp_names: Iterable[str] = (), + website_names: Iterable[str] = ()) -> str | None: + """Why a provider of provider_class can't deploy an env of env_class, a multi's with children of these environment_names, worded + to follow the provider's type, or None: the server provider deploys one MCP server, and a plugin's provider gives a multi one + env card, which can't tell an MCP server and a website of one name apart. It needs no env, so a bundle checks it before writing one.""" from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv from agent_env.providers.env_providers.env_provider import _SandboxEnvironmentProvider from agent_env.providers.env_providers.env_server_provider import EnvironmentServerProvider - provider_class = provider if isinstance(provider, type) else type(provider) - refused = f"env '{env.id}' has env_provider_type '{env.env_provider_type}'" - if issubclass(provider_class, EnvironmentServerProvider) and not isinstance(env, MCPServerEnv): - return f"{refused}, which deploys one MCP server, not a {env.type} env" - if isinstance(env, MultiEnv) and not issubclass(provider_class, _SandboxEnvironmentProvider): - shared = {c.environment_name for c in env.mcp_server_envs} & {c.environment_name for c in env.website_envs} - if shared: - return (f"{refused}, whose one env card can't tell an MCP server and a website apart by name, and both are named " + if issubclass(provider_class, EnvironmentServerProvider) and not issubclass(env_class, MCPServerEnv): + return f"deploys one MCP server, not a {env_class.type} env" + if issubclass(env_class, MultiEnv) and not issubclass(provider_class, _SandboxEnvironmentProvider): + if shared := set(mcp_names) & set(website_names): + return (f"gives a multi one env card, so it can't tell an MCP server and a website apart by name, and both are named " f"{', '.join(map(repr, sorted(shared)))}") - return option_refusal(env, provider, options) + return None def option_refusal(env: Env, provider: EnvironmentProvider | type[EnvironmentProvider], options: dict) -> str | None: diff --git a/src/agent_env/env/envs/_toml.py b/src/agent_env/env/envs/_toml.py deleted file mode 100644 index d9986ffd..00000000 --- a/src/agent_env/env/envs/_toml.py +++ /dev/null @@ -1,59 +0,0 @@ -"""What an env type written from an env.toml checks first: its keys, its env_provider_type and, for a type that -names itself, its environment_name.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from agent_env.providers.env_providers.constants import GATEWAY_SERVICE_NAMES - -if TYPE_CHECKING: - from agent_env.bundle.authoring import AuthoringContext - - -def accept_env_toml(cls: type, data: dict[str, Any], ctx: AuthoringContext, *, image_key: str | None = None, - one_server: bool = False) -> tuple[dict[str, Any], list[str]]: - """The keys of ``data`` ``cls`` takes, and the problems with them. With ``image_key``, the env is named by - ``environment_name`` or, left out, by the ``@environment_card`` in the source of the image that key builds; - ``one_server`` lets env_provider_type name a provider that deploys one MCP server.""" - # The env types import before the providers that deploy them, which import those types. - from agent_env.providers.env_providers.env_gateway_provider import EnvironmentGatewayProvider - from agent_env.providers.env_providers.env_provider import _env_provider_class - from agent_env.providers.env_providers.env_server_provider import EnvironmentServerProvider - - fields, problems = ctx.accepted(data, **cls.toml_keys) - provider_type = fields.get("env_provider_type", "gateway") - try: - provider = _env_provider_class(provider_type) - except ValueError as e: - problems.append(f"env.toml: env_provider_type: {e}") - provider = None - if provider is not None and not one_server and issubclass(provider, EnvironmentServerProvider): - problems.append(f"env.toml: env_provider_type {provider_type!r} deploys one MCP server, not a {cls.type} env") - if image_key is not None: - name = _environment_name(data, fields, ctx, image_key, problems) - if name in GATEWAY_SERVICE_NAMES and provider is not None and issubclass(provider, EnvironmentGatewayProvider): - problems.append(f"env.toml: environment_name {name!r} is one a gateway deploy names its own containers " - f"({', '.join(sorted(GATEWAY_SERVICE_NAMES))}); choose another") - return fields, problems - - -def _environment_name(data: dict[str, Any], fields: dict[str, Any], ctx: AuthoringContext, image_key: str, - problems: list[str]) -> str | None: - if "environment_name" in data: - if fields.get("environment_name") == "": - problems.append("env.toml: environment_name can't be empty") - return fields.get("environment_name") - names = ctx.card_names(image_key) - what = "image" if image_key == "image" else image_key.replace("_", " ") - if names is None: - problems.append(f"env.toml: environment_name isn't set, and its {what} isn't built from this folder, so " - "there's no source to read it from; set it") - elif len(names) != 1: - found = (f"several environment cards ({', '.join(map(repr, names))})" if names - else "no @environment_card(name=...)") - problems.append(f"env.toml: environment_name isn't set, and the source its {what} is built from declares " - f"{found}; set it") - else: - fields["environment_name"] = names[0] - return fields.get("environment_name") diff --git a/src/agent_env/env/envs/mcp_server.py b/src/agent_env/env/envs/mcp_server.py index 50b49a63..0c8e2a47 100644 --- a/src/agent_env/env/envs/mcp_server.py +++ b/src/agent_env/env/envs/mcp_server.py @@ -14,7 +14,6 @@ from agent_env.artifact.artifacts.docker_image import GitHubBuildResult, ProgressCallback, refuse_local_github_build from agent_env.entity_refs import EntityRef from agent_env.env.env import Env, gateway_url_of -from agent_env.env.envs._toml import accept_env_toml from agent_env.store.ids import derive_id from agent_env.env import legacy_protocol from agent_env.env.envs._deployment import ( @@ -89,17 +88,13 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> MCPServerEnv: under ``ctx.id`` and return it.""" fields = cls.accept_toml(data, ctx) return cls.put(id=ctx.id, docker_image_artifact=ctx.artifact(fields["image"], DockerImageArtifact), - environment_name=fields["environment_name"], - env_provider_type=fields.get("env_provider_type", "gateway")) + environment_name=fields["environment_name"], env_provider_type=fields["env_provider_type"]) @classmethod def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: """The keys of ``data``, an env.toml, an MCP server env takes, its environment_name read from the image's @environment_card when it isn't set. Raises BundleError listing every problem.""" - fields, problems = accept_env_toml(cls, data, ctx, image_key="image", one_server=True) - if problems: - ctx.refuse(problems) - return fields + return ctx.accept_env(data, cls, named_by="image") async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: # In container mode the server runs in its own container, so loads stage there, as for a MultiEnv child. diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index 3cdb61cd..8c4cf81b 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -27,7 +27,6 @@ def _snapshot_after_load_default() -> bool: from agent_env.entity_refs import EntityRef from agent_env.env.env import Env, gateway_url_of -from agent_env.env.envs._toml import accept_env_toml from agent_env.env.gateway import GatewayMode from agent_env.env.envs._deployment import ( as_builtin, builtin_provider_for, close_deployed, close_replaced, deploy_refusal, deploy_through_provider, host_staging_refusal, @@ -101,7 +100,7 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> MultiEnv: fields = cls.accept_toml(data, ctx) env = dict(mcp_server_envs=[ctx.env(ref, MCPServerEnv) for ref in fields.get("mcp_server_envs", [])], website_envs=[ctx.env(ref, WebsiteEnv) for ref in fields.get("website_envs", [])], - name=fields.get("name"), env_provider_type=fields.get("env_provider_type", "gateway")) + name=fields.get("name"), env_provider_type=fields["env_provider_type"]) if refusal := cls(id=ctx.id, version=None, **env).deploy_refusal(): ctx.refuse([refusal]) return cls.put(id=ctx.id, **env) @@ -109,12 +108,12 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> MultiEnv: @classmethod def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: """The keys of ``data``, an env.toml, a multi env takes. Raises BundleError listing every problem.""" - fields, problems = accept_env_toml(cls, data, ctx) + fields, problems = ctx.accepted_env(data, cls) if not (fields.get("mcp_server_envs") or fields.get("website_envs")): - problems.append("env.toml: a multi env needs at least one env in mcp_server_envs or website_envs") + problems.append(ctx.config_problem("a multi env needs at least one env in mcp_server_envs or website_envs")) name = fields.get("name") if name is not None and (not name or any(ch.isspace() for ch in name)): - problems.append(f"env.toml: name must be non-empty with no whitespace, not {name!r}") + problems.append(ctx.config_problem(f"name must be non-empty with no whitespace, not {name!r}")) if problems: ctx.refuse(problems) return fields diff --git a/src/agent_env/env/envs/website.py b/src/agent_env/env/envs/website.py index bc3bdd4b..7f316f04 100644 --- a/src/agent_env/env/envs/website.py +++ b/src/agent_env/env/envs/website.py @@ -14,7 +14,6 @@ from agent_env.artifact.artifacts.docker_image import GitHubBuildResult, ProgressCallback, refuse_local_github_build from agent_env.entity_refs import EntityRef from agent_env.env.env import Env, gateway_url_of -from agent_env.env.envs._toml import accept_env_toml from agent_env.store.ids import derive_id from agent_env.env import legacy_protocol from agent_env.env.envs._deployment import ( @@ -109,17 +108,13 @@ def from_toml(cls, data: dict, ctx: AuthoringContext) -> WebsiteEnv: return cls.put(id=ctx.id, backend_docker_image_artifact=ctx.artifact(fields["backend_image"], DockerImageArtifact), frontend_docker_image_artifact=ctx.artifact(fields["frontend_image"], DockerImageArtifact), - environment_name=fields["environment_name"], - env_provider_type=fields.get("env_provider_type", "gateway")) + environment_name=fields["environment_name"], env_provider_type=fields["env_provider_type"]) @classmethod def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: """The keys of ``data``, an env.toml, a website env takes, its environment_name read from the backend image's @environment_card when it isn't set. Raises BundleError listing every problem.""" - fields, problems = accept_env_toml(cls, data, ctx, image_key="backend_image") - if problems: - ctx.refuse(problems) - return fields + return ctx.accept_env(data, cls, named_by="backend_image") async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv: return await deploy_through_provider( diff --git a/tst/unit/bundle/env_toml_test.py b/tst/unit/bundle/env_toml_test.py index aec6fb3f..3bc03ee1 100644 --- a/tst/unit/bundle/env_toml_test.py +++ b/tst/unit/bundle/env_toml_test.py @@ -9,6 +9,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.artifact.store import get_artifact_store from agent_env.bundle import BundleError, parse_bundle +from agent_env.bundle import authoring as authoring_module from agent_env.bundle.plan import check_bundle from agent_env.bundle.resolve import resolve_bundle from agent_env.bundle import plan as plan_module @@ -226,7 +227,7 @@ def test_an_unknown_env_provider_type_is_refused_naming_the_known_ones(bundle_di _bundle(bundle_dir, {**_mcp("x"), "envs/x/env.toml": 'env_provider_type = "nope"\n'}, "x") (problem,) = _problems(bundle_dir) - assert problem.startswith("envs/x: env.toml: env_provider_type: Unknown env_provider_type: 'nope' (expected one of") + assert problem.startswith("envs/x: env.toml: Unknown env_provider_type: 'nope' (expected one of") def test_a_plugin_provider_multi_with_an_mcp_server_and_a_website_of_one_name_is_refused_before_any_write( @@ -234,14 +235,14 @@ def test_a_plugin_provider_multi_with_an_mcp_server_and_a_website_of_one_name_is ): real = env_provider_module._env_provider_class providers = lambda name: _OneCard if name == "one_card_test" else real(name) - monkeypatch.setattr(env_provider_module, "_env_provider_class", providers) - monkeypatch.setattr(plan_module, "_env_provider_class", providers) + for module in (env_provider_module, authoring_module, plan_module): + monkeypatch.setattr(module, "_env_provider_class", providers) _bundle(bundle_dir, {**_mcp("crm", "shop"), **_website("shop"), "envs/x/env.toml": 'type = "multi"\nmcp_server_envs = ["crm"]\nwebsite_envs = ["shop"]\n' 'env_provider_type = "one_card_test"\n'}, "x") assert _problems(bundle_dir) == ( - "envs/x: env_provider_type 'one_card_test' gives a multi one env card, which can't tell an MCP server and a " + "envs/x: env_provider_type 'one_card_test' gives a multi one env card, so it can't tell an MCP server and a " "website apart by name, and both are named 'shop'; rename one",) assert not local_store().path.exists() diff --git a/tst/unit/env/envs/test_multi_env_plugin_provider.py b/tst/unit/env/envs/test_multi_env_plugin_provider.py index 504bdff4..37b3cbef 100644 --- a/tst/unit/env/envs/test_multi_env_plugin_provider.py +++ b/tst/unit/env/envs/test_multi_env_plugin_provider.py @@ -240,7 +240,8 @@ def _preflight(env, **options) -> list[str]: @pytest.mark.asyncio @pytest.mark.parametrize("make, refusal", [ - (lambda: _multi(websites=("slack",)), "whose one env card can't tell an MCP server and a website apart by name, and both are named 'slack'"), + (lambda: _multi(websites=("slack",)), + "which gives a multi one env card, so it can't tell an MCP server and a website apart by name, and both are named 'slack'"), (lambda: _multi("server"), "which deploys one MCP server, not a multi env"), (lambda: _site("shop", "server"), "which deploys one MCP server, not a website env"), (lambda: _multi("plugin_named"), "which doesn't take cpu; name it in the provider's deploy() or take **options"),