From 698542cbc94fc997bd78756d5faa76b130bf091f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 8 Oct 2026 23:06:32 -0700 Subject: [PATCH 1/2] feat(bundle): write a built image as its build context when its deploys build it in their VMs Preflight now decides how each image the bundle builds is written. When every deploy runs it on the local provider it is built on this machine, as before. When every deploy runs it on providers whose VMs build it, it is written with put_context and no local docker. A run deploying it both ways, or on a provider that runs an image by name, is refused. The ledger hashes the form and platform only for a build context, so existing rows stay reused, and the dry run lists each built image's form. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/ledger.py | 21 ++++- src/agent_env/bundle/materialize.py | 66 +++++++++++----- src/agent_env/bundle/preflight.py | 68 +++++++++++++--- src/agent_env/bundle/run.py | 15 +++- src/agent_env/cli/run.py | 4 + tst/unit/bundle/materialize_test.py | 39 ++++++++- tst/unit/bundle/preflight_test.py | 79 +++++++++++++++++-- tst/unit/bundle/run_test.py | 2 + .../sandbox_providers/vm_sandbox_test.py | 23 ++++++ 9 files changed, 276 insertions(+), 41 deletions(-) diff --git a/src/agent_env/bundle/ledger.py b/src/agent_env/bundle/ledger.py index b1652d81..cf64d38d 100644 --- a/src/agent_env/bundle/ledger.py +++ b/src/agent_env/bundle/ledger.py @@ -29,6 +29,7 @@ from agent_env.store.document_store import LocalSqliteDocumentStore from agent_env.store.local_state import holding_locks from agent_env.task.store import TASKS_COLLECTION +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM from .authoring import build_context_files from .parse import Bundle, BundleKind @@ -40,6 +41,9 @@ # version it wrote should be written again. SCHEME = 1 +_CONTEXT = "build context" +_FORMS = {None: "an image built on this machine", _CONTEXT: "a build context, which its VMs build"} + _COLLECTIONS = {"env": ENVS_COLLECTION, "agent": A2A_AGENTS_COLLECTION, "artifact": ARTIFACTS_COLLECTION, "task": TASKS_COLLECTION, "eval": EVALS_COLLECTION} _LISTED = 5 @@ -48,7 +52,8 @@ @dataclass(frozen=True) class Digest: value: str - inputs: dict[str, Any] # type, config, files {key: sha256}, needs and store_refs {" ": version} + inputs: dict[str, Any] # type, config, files {key: sha256}, needs and store_refs {" ": version}, and + # for an image written as its build context, its form and the platform its VMs build it for @dataclass(frozen=True) @@ -76,14 +81,15 @@ def next_version(self) -> int: class Ledger: """One plan's rows in the ``@local`` namespace's store.""" - def __init__(self, store: LocalSqliteDocumentStore, plan: Plan): + def __init__(self, store: LocalSqliteDocumentStore, plan: Plan, contexts: frozenset[str] = frozenset()): self._store = store self._plan = plan self._bundle = plan.bundle.bundle.id_root + self._contexts = contexts # the built images written as build contexts @classmethod - def for_plan(cls, plan: Plan) -> Ledger: - return cls(get_config().local_namespace_document_store(), plan) + def for_plan(cls, plan: Plan, contexts: frozenset[str] = frozenset()) -> Ledger: + return cls(get_config().local_namespace_document_store(), plan, contexts) def check(self, write: Write, needs: Mapping[tuple[str, str], int]) -> Check: """Compare ``write`` with the latest version the ledger records for it. ``needs`` holds, by (store, id), @@ -124,6 +130,9 @@ def digest(self, write: Write, needs: Mapping[tuple[str, str], int]) -> Digest | listing = build_context_files if built else folder_walk(get_artifact_registry().get(_type(write))) for key, path in listing(self._plan.bundle.bundle, write.source.entry).items(): inputs["files"][key] = _file_sha256(path) + (_mode(path) if built else "") + if built and write.id in self._contexts: + # Only then, so an image built on this machine is hashed as before and earlier rows stay reused. + inputs.update(form=_CONTEXT, platform=DEFAULT_BUILD_PLATFORM) if write.kind not in (BundleKind.TASK, BundleKind.EVAL): # A document records the versions of what it references (an env its images, a universe its # environments), so one written anew means it must be written again. A task or eval names its @@ -235,6 +244,10 @@ def _changes(row: Mapping[str, Any], digest: Digest) -> list[str]: reasons.append(f"type changed: {before['type']} → {after['type']}") if before.get("config") != after["config"]: reasons.append("config changed") + if before.get("form") != after.get("form"): + reasons.append(f"now written as {_FORMS[after.get('form')]}, not {_FORMS[before.get('form')]}") + elif before.get("platform") != after.get("platform"): + reasons.append(f"platform changed: {before.get('platform')} → {after.get('platform')}") for label, names in zip(("files added", "files removed", "files changed"), _diff(before["files"], after["files"])): if names: shown = ", ".join(names[:_LISTED]) + (f" and {len(names) - _LISTED} more" if len(names) > _LISTED else "") diff --git a/src/agent_env/bundle/materialize.py b/src/agent_env/bundle/materialize.py index 478163e0..0aa3ddb0 100644 --- a/src/agent_env/bundle/materialize.py +++ b/src/agent_env/bundle/materialize.py @@ -3,9 +3,9 @@ Materializing first refuses every write this release has no writer for, so nothing is written for a bundle that can't be written whole. It needs the CLI's namespace routing, which sends ``@local`` writes to the ``@local`` namespace's store. Holding the bundle's lock, it writes the entities in the plan's order, an image -built from an entry's Dockerfile just before the entry, with ``docker build`` on this machine; then it -builds and preflights every task before writing any of them, and writes the evals last, since they name the -tasks. Each write goes through the ledger, so one whose inputs haven't changed reuses the version the bundle +built from an entry's Dockerfile just before the entry: with ``docker build`` on this machine, or, for one +whose deploys build it in their VMs, as its build context alone. Then it builds and preflights every task +before writing any of them, and writes the evals last, since they name the tasks. Each write goes through the ledger, so one whose inputs haven't changed reuses the version the bundle last wrote. A reused task keeps whatever its steps took from config when it was first written, such as a rubrics verifier's default judge model. A dry run takes the same path, refusals, checks and preflights included, without the lock or a single write. @@ -59,6 +59,7 @@ class Materialization: plan: Plan writes: tuple[Materialized, ...] # in the plan's order + contexts: frozenset[str] = frozenset() # the ids of the built images written as build contexts # A dry run's steps with a preflight that read what it would write first, so the store can't check them yet. not_preflighted: tuple[tuple[Write, TaskStep], ...] = () @@ -76,11 +77,13 @@ def materialize( on_wait: Callable[[], None] | None = None, on_build: Callable[[Write], None] | None = None, on_write: Callable[[Materialized], None] | None = None, + contexts: frozenset[str] = frozenset(), ) -> Materialization: """Write ``plan``'s entities, tasks and evals. The first write that fails stops it, and every earlier one stays: they're in the ledger, so the next run reuses them. ``on_wait`` is called when another run holds a lock this one needs, ``on_build`` before an image is built, and ``on_write`` after each write, - reused or not. + reused or not. An image the bundle builds whose id is in ``contexts`` is written as its build context + alone, for the VM sandboxes that deploy it to build (``preflight_run`` decides which). ``dry_run`` checks and preflights what a run would, and writes nothing: no entity, ledger row or lock. Each write gets the version it would reuse or the store's next, which another run can take first. A step @@ -89,7 +92,7 @@ def materialize( if not namespace_routing_enabled(): raise RuntimeError("materializing a bundle needs namespace routing, which the agent-env CLI turns on; " "call it inside agent_env.store.routing.namespace_routing()") - ledger = Ledger.for_plan(plan) + ledger = Ledger.for_plan(plan, contexts) entities = [write for write in plan.writes if write.kind not in (BundleKind.TASK, BundleKind.EVAL)] tasks = [write for write in plan.writes if write.kind is BundleKind.TASK] evals = [write for write in plan.writes if write.kind is BundleKind.EVAL] @@ -111,9 +114,9 @@ def through_ledger(write: Write, write_fn: Callable[[], int]) -> None: on_write(done[_key(write)]) with nullcontext() if dry_run else materializing(plan.bundle.bundle, on_wait): - _refuse_builds_without_docker(plan, ledger) # once another run writing these ids is done + _refuse_builds_without_docker(plan, ledger, contexts) # once another run writing these ids is done for write in entities: - through_ledger(write, lambda: _write_entity(plan, write, on_build)) + through_ledger(write, lambda: _write_entity(plan, write, on_build, contexts)) unwritten = {_key(item.write) for item in done.values() if not item.reused} if dry_run else set() built, problems, unchecked = {}, [], [] for write in tasks: @@ -128,29 +131,52 @@ def through_ledger(write: Write, write_fn: Callable[[], int]) -> None: through_ledger(write, lambda: Task.put(id=write.id, steps=built[write.id].steps).version) for write in evals: through_ledger(write, lambda: _WRITERS[write.kind](plan, write)) - return Materialization(plan, tuple(done[_key(write)] for write in plan.writes), tuple(unchecked)) + return Materialization(plan, tuple(done[_key(write)] for write in plan.writes), contexts, tuple(unchecked)) -def _write_entity(plan: Plan, write: Write, on_build: Callable[[Write], None] | None) -> int: +def _write_entity(plan: Plan, write: Write, on_build: Callable[[Write], None] | None, + contexts: frozenset[str]) -> int: if not isinstance(write.source, BuiltImage): return _WRITERS[write.kind](plan, write) + if write.id in contexts: + return _write_build_context(plan, write) if on_build is not None: on_build(write) return _write_built_image(plan, write) -def _write_built_image(plan: Plan, write: Write) -> int: - """Build the image an entry's Dockerfile describes and write it as a docker_image artifact: pushed to the - image store, saved as a tarball, and its build context kept for installing it into a running container. - The build context is a copy of the files the ledger hashes, ``build_context_files``, so an image the - ledger reuses was built from what it hashed.""" - image = write.source - tag = f"{image_repository(write.id)}:bundle" +@contextmanager +def _staged(plan: Plan, image: BuiltImage) -> Iterator[Path]: + """A copy of the files the ledger hashes, ``build_context_files``, so an image the ledger reuses was made from + what it hashed.""" with tempfile.TemporaryDirectory(prefix="agent-env-build-") as staged: context = Path(staged) for key, path in build_context_files(plan.bundle.bundle, image.entry).items(): (context / key).parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, context / key) + yield context + + +def _write_build_context(plan: Plan, write: Write) -> int: + """Write the image an entry's Dockerfile describes as a docker_image artifact holding its build context alone, + which each VM sandbox that deploys it builds.""" + image = write.source + with _staged(plan, image) as context: + try: + return DockerImageArtifact.put_context( + write.id, description=f"built from {_path(plan, write)}/{image.dockerfile}", context_path=str(context), + dockerfile_path=str(context / image.dockerfile), + ).version + except (ValueError, RuntimeError) as e: # a context it can't write, or the object store + raise BundleError([f"{_path(plan, write)}: {e}"]) from None + + +def _write_built_image(plan: Plan, write: Write) -> int: + """Build the image an entry's Dockerfile describes and write it as a docker_image artifact: pushed to the + image store, saved as a tarball, and its build context kept for installing it into a running container.""" + image = write.source + tag = f"{image_repository(write.id)}:bundle" + with _staged(plan, image) as context: try: build_image(context / image.dockerfile, context, tag, platform=None) except DockerBuildError as e: @@ -229,13 +255,15 @@ def _refuse_unwritable(plan: Plan) -> None: raise BundleError(problems) -def _refuse_builds_without_docker(plan: Plan, ledger: Ledger) -> None: - """An image the ledger will reuse needs no docker, so only the ones it would build are refused.""" +def _refuse_builds_without_docker(plan: Plan, ledger: Ledger, contexts: frozenset[str]) -> None: + """An image the ledger will reuse, or one written as its build context, needs no docker, so only the ones it + would build are refused.""" if shutil.which("docker") is not None: return problems = [f"{_path(plan, write)}: building its image from {write.source.dockerfile} needs docker, and it isn't " "on PATH" for write in plan.writes - if isinstance(write.source, BuiltImage) and not ledger.check(write, {}).unchanged] + if isinstance(write.source, BuiltImage) and write.id not in contexts + and not ledger.check(write, {}).unchanged] if problems: raise BundleError(problems) diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index da381a51..a702d5e0 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -7,7 +7,11 @@ 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. An env the bundle writes is -checked from its planned env.toml: an image the bundle builds is one only this machine has. +checked from its planned env.toml. + +An image the bundle builds is written in the form its deploys can run: built on this machine when they deploy on the +local provider, and as its build context alone when they deploy on providers whose VMs build it. One version is one of +the two, so a run deploying it both ways is refused, as is one deploying it on a provider that runs an image by name. 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 @@ -78,6 +82,7 @@ class Preflight: infra_kinds: frozenset[str] # the infra its deploys on the local provider run on infra: tuple[InfraBuild, ...] # those the store doesn't hold yet, or holds built from other inputs + contexts: frozenset[str] = frozenset() # the ids of the images the bundle builds that it writes as build contexts def preflight_run(plan: Plan, tasks: Iterable[ResolvedEntry], sandbox: str | None) -> Preflight: @@ -97,6 +102,7 @@ class _Image: local_only: str | None # why only this machine has it, or None unloadable: str | None = None # why no sandbox can get it, or None by_name: str | None = None # why a sandbox that runs it by pulling its name can't, or None + built: str | None = None # the id of the image when the bundle builds it, whose form the walk decides at the end @dataclass(frozen=True) @@ -122,6 +128,8 @@ def __init__(self, plan: Plan, sandbox: str | None): 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)} + # Each deploy of an image the bundle builds: (where, what, its provider's links, those that run it by name) + self.uses: dict[str, list[tuple[str, str, list[SandboxProvider], list[SandboxProvider]]]] = {} def task(self, task: ResolvedEntry) -> None: steps = [build_step(config) for config in task.config] @@ -148,6 +156,7 @@ def finish(self) -> Preflight: self.run_problems.extend(e.problems) for where, provider, kinds in self.remote_infra: self._remote_infra(where, provider, kinds) + contexts = self._contexts() self._default_agent() if self.docker_users and (reason := docker_unreachable()): shown = ", ".join(self.docker_users[:_SHOWN_DOCKER_USERS]) @@ -161,7 +170,7 @@ def finish(self) -> Preflight: problems.append(f"{first}{more}: {problem}") if problems: raise BundleError(problems) - return Preflight(frozenset(self.infra), tuple(infra)) + return Preflight(frozenset(self.infra), tuple(infra), contexts) # Deploys, each resolving its provider as its step does when it runs @@ -188,6 +197,7 @@ def _env(self, where: str, step: DeployEnvTaskStep) -> None: by_name = ([link for link in _links(provider) if isinstance(link, ModalSandboxProvider)] if GATEWAY in kinds else _links(provider)) self._by_name(where, by_name, images) + self._use(where, _links(provider), by_name, images) if _local_link(provider): self.infra |= kinds self.docker_users.append(where) @@ -259,9 +269,8 @@ def _planned_images(self, env_id: str) -> tuple[list[_Image], bool]: 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.built: + return _Image(what, None, built=ref.id) if ref.id in self.written: return None # another of the bundle's writes, which materialize refuses or writes first try: @@ -304,6 +313,7 @@ def _agent_deploy(self, where: str, provider: SandboxProvider, agent_id: str | N if loaders: self._loadable(where, loaders, [image]) self._by_name(where, by_name, [image]) + self._use(where, _links(provider), by_name, [image]) if remote: self._reachable(where, remote, [image]) @@ -327,9 +337,8 @@ def _agent_image(self, agent_id: str, version: int | None) -> _Image | None: what = f"agent {agent_id!r}'s image" if (agent := self.agents.get(agent_id)) is not None: image_id, image_version = parse_toml_ref(EntityKind.ARTIFACT, agent.config.get("image")) - if (built := self.built.get(image_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 image_id in self.built: + return _Image(what, None, built=image_id) if image_id in self.written: return None # another of the bundle's writes, which materialize refuses or writes first planned = self._planned_version(EntityKind.ARTIFACT, image_id, image_version) @@ -381,6 +390,43 @@ def _remote_infra(self, where: str, provider: SandboxProvider, kinds: set[str]) self._by_name(where, [provider], images) self._reachable(where, [provider], images) + def _use(self, where: str, links: list[SandboxProvider], by_name: list[SandboxProvider], + images: list[_Image]) -> None: + for image in images: + if image.built is not None: + self.uses.setdefault(image.built, []).append((where, image.what, links, by_name)) + + def _contexts(self) -> frozenset[str]: + """The images the bundle builds that it writes as build contexts: those every deploy runs on providers whose + VMs build them. One its deploys run only on the local provider, or that none runs, is built on this machine.""" + contexts = set() + for image_id, uses in self.uses.items(): + built = self.built[image_id] + source = f"{relative(self.plan.bundle.bundle.root, built.entry.path)}/{built.dockerfile}" + local, vms = [], [] + for where, what, links, by_name in uses: + for link in links: + if isinstance(link, LocalSandboxProvider): + local.append((where, what, links, link)) + elif link in by_name: + self._problem(where, f"deploys {what} on the {_shown(link)} sandbox provider, which runs it by " + f"name, and the bundle builds it from {source}, which a sandbox runs only " + "once built on this machine or in its VM; run it with --sandbox local, or " + "on a provider whose VMs build it, such as --sandbox modal_vm") + else: + vms.append((where, what, links, link)) + if local and vms: + (where, what, links, link), (elsewhere, _, _, vm) = local[0], vms[0] + on = (f"{_named(links)} sandbox provider" if elsewhere == where else + f"{_shown(link)} sandbox provider, and {elsewhere} on the {_shown(vm)} one") + self._problem(where, f"deploys {what} on the {on}: the bundle builds it from {source} on this machine " + "for the local provider, and as a build context for one whose VMs build it, and a " + "run writes it one way; run its deploys on one kind, such as --sandbox local or " + "--sandbox modal_vm") + elif vms: + contexts.add(image_id) + return frozenset(contexts) + def _default_agent(self) -> None: if not self.default_agent_users: return @@ -443,7 +489,11 @@ def _creates_vms(provider: SandboxProvider) -> bool: def _shown(provider: SandboxProvider) -> str: - return repr(",".join(_name(link) for link in _links(provider))) + return _named(_links(provider)) + + +def _named(links: list[SandboxProvider]) -> str: + return repr(",".join(_name(link) for link in links)) def _name(provider: SandboxProvider) -> str: diff --git a/src/agent_env/bundle/run.py b/src/agent_env/bundle/run.py index 93f00fa8..4c8ad1c2 100644 --- a/src/agent_env/bundle/run.py +++ b/src/agent_env/bundle/run.py @@ -29,6 +29,7 @@ from agent_env.task.interrupts import Interrupts from agent_env.task.teardown import TeardownReport, teardown_run from agent_env.task_step.context import TaskStepContext +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM from ._fs import relative from .materialize import Materialization, Materialized, materialize @@ -156,6 +157,16 @@ def path(self, entry: BundleEntry) -> str: """``entry``'s path in the bundle (``tasks/hello.json``).""" return relative(self.materialization.plan.bundle.bundle.root, entry.path) + def images(self) -> tuple[str, ...]: + """A line per image the bundle builds: which, the version it would leave, and how it would be written.""" + plan, contexts = self.materialization.plan, self.materialization.contexts + return tuple( + f"{_label(plan, done.write)} v{done.version}: " + + (f"a build context, which each VM deploying it builds for {DEFAULT_BUILD_PLATFORM}" + if done.write.id in contexts else "built on this machine") + for done in self.materialization.writes if isinstance(done.write.source, BuiltImage) + ) + class RunInterrupted(KeyboardInterrupt): """Ctrl-C or SIGTERM stopped ``run_bundle``. ``result`` holds every run, those it cancelled included, each @@ -210,6 +221,7 @@ def run_bundle( on_wait=lambda: say("waiting for another agent-env run to finish writing this bundle's ids"), on_build=lambda write: say(f"{_label(plan, write)}: building with docker, which can take minutes"), on_write=lambda done: say(_written(plan, done)), + contexts=preflight.contexts, ) entries = _to_run(plan) to_run = [(entry, Task.get(entry.id, materialization.version_of("task", entry.id))) for entry in entries] @@ -251,7 +263,8 @@ def dry_run_bundle( say = _progress(on_progress) with namespace_routing(): plan, preflight = _planned(root, tasks, evals, id_root, sandbox) - materialization = materialize(plan, dry_run=True, on_write=lambda done: say(_written(plan, done))) + materialization = materialize(plan, dry_run=True, on_write=lambda done: say(_written(plan, done)), + contexts=preflight.contexts) runs = _to_run(plan) return DryRun(materialization, runs, _skipped(plan, runs, every=not tasks and not evals), preflight.infra) diff --git a/src/agent_env/cli/run.py b/src/agent_env/cli/run.py index 3a21d5d7..a57137f7 100644 --- a/src/agent_env/cli/run.py +++ b/src/agent_env/cli/run.py @@ -199,6 +199,10 @@ def _report_dry_run(dry: DryRun) -> None: click.echo(f" {ref.kind} {ref.id} v{plan.store_latest[ref.kind, ref.id]}, the latest") else: click.echo(f" {ref.kind} {ref.id} v{ref.version}") + if images := dry.images(): + click.echo("Images:") + for line in images: + click.echo(f" {line}") if dry.infra: click.echo("Would build first:") for build in dry.infra: diff --git a/tst/unit/bundle/materialize_test.py b/tst/unit/bundle/materialize_test.py index aae33774..3f62b41f 100644 --- a/tst/unit/bundle/materialize_test.py +++ b/tst/unit/bundle/materialize_test.py @@ -34,6 +34,7 @@ from agent_env.store.routing import disable_namespace_routing from agent_env.task import Task from agent_env.task_step.task_step import TaskStep +from agent_env.utils.build_context import BuildContext from tst.unit.bundle._support import RefusingStore, layout, local_store, plan_of ROOT = "@local/~/triage" @@ -402,6 +403,42 @@ def test_a_changed_build_context_rebuilds_the_image_and_rewrites_its_agent(bundl assert A2AAgent.get(f"{ROOT}/solver").docker_image_artifact.version == 2 +def test_an_image_written_as_its_build_context_is_uploaded_with_no_build_and_no_docker(bundle_dir, builds, + monkeypatch): + monkeypatch.setattr(materialize_module.shutil, "which", lambda name: None) + 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"}]) + image = f"{ROOT}/solver__agent_image" + announced = [] + + first = materialize(plan_of(bundle_dir), contexts=frozenset({image}), on_build=lambda write: announced.append(write.id)) + + written = DockerImageArtifact.get(image, 1) + folder = bundle_dir / "agents/solver" + assert builds == [] and announced == [] and first.contexts == {image} + assert written.context_only and (written.dockerfile_path, written.platform) == ("Dockerfile", "linux/amd64") + assert written.source_digest == BuildContext.of(folder, folder / "Dockerfile").source_digest("linux/amd64") + assert A2AAgent.get(f"{ROOT}/solver").docker_image_artifact.version == 1 + assert _summary(materialize(plan_of(bundle_dir), contexts=frozenset({image})))[image] == (1, True, ()) + + +def test_an_image_switched_between_built_here_and_a_build_context_is_rewritten_with_its_agent(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"}]) + image = f"{ROOT}/solver__agent_image" + _run(bundle_dir) + + as_context = _summary(materialize(plan_of(bundle_dir), contexts=frozenset({image}))) + built_again = _summary(_run(bundle_dir)) + + assert as_context[image] == (2, False, ("now written as a build context, which its VMs build, not an image built " + "on this machine",)) + assert as_context[f"{ROOT}/solver"] == (2, False, (f"artifact {image} is written anew (v1 → v2)",)) + assert built_again[image] == (3, False, ("now written as an image built on this machine, not a build context, " + "which its VMs build",)) + assert len(builds) == 2 + + def test_a_dry_run_predicts_a_rebuild_and_its_agents_rewrite_without_building(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"}]) @@ -485,7 +522,7 @@ def materializing(bundle, on_wait=None): monkeypatch.setattr(materialize_module, "materializing", materializing) check = materialize_module._refuse_builds_without_docker monkeypatch.setattr(materialize_module, "_refuse_builds_without_docker", - lambda plan, ledger: events.append("docker checked") or check(plan, ledger)) + lambda plan, ledger, contexts: events.append("docker checked") or check(plan, ledger, contexts)) layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) _steps(bundle_dir, [{"id": "agent", "type": "deploy_agent", "env_ids": [], "a2a_agent_id": "solver"}]) diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 99376af2..55a48f41 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -132,13 +132,62 @@ def test_a_remote_image_runs_anywhere_and_a_local_one_on_the_local_provider(bund assert problem.startswith("tasks/t.json: step 'helper': deploys agent 'helper''s image") -def test_an_agent_built_from_the_bundle_is_refused_on_another_provider(bundle_dir): +# Images the bundle builds, written in the form their deploys run + +BUILT = "@local/~/triage/solver__agent_image" + + +@pytest.mark.parametrize("sandbox, form", [ + ("local", "built on this machine"), + ("modal_vm", "a build context, which each VM deploying it builds for linux/amd64"), +]) +def test_an_agent_the_bundle_builds_is_built_here_for_the_local_provider_and_a_build_context_for_vms( + bundle_dir, docker_on_path, sandbox, form): layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) _task(bundle_dir, [AGENT]) - assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) == [ + dry = dry_run_bundle(bundle_dir, sandbox=sandbox) + + assert dry.materialization.contexts == ({BUILT} if sandbox == "modal_vm" else set()) + assert dry.images() == (f"agents/solver (Dockerfile image) v1: {form}",) + + +@pytest.mark.parametrize("sandbox", ["modal", "modal_vm,modal"]) +def test_an_agent_the_bundle_builds_is_refused_on_a_provider_that_runs_it_by_name(bundle_dir, sandbox): + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) + _task(bundle_dir, [AGENT]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ "tasks/t.json: step 'agent': deploys agent '@local/~/triage/solver''s image on the 'modal' sandbox provider, " - "which can't reach it: it's built on this machine from agents/solver/Dockerfile; run it with --sandbox local", + "which runs it by name, and the bundle builds it from agents/solver/Dockerfile, which a sandbox runs only once " + "built on this machine or in its VM; run it with --sandbox local, or on a provider whose VMs build it, such as " + "--sandbox modal_vm", + ] + + +MIXED = ("the bundle builds it from agents/solver/Dockerfile on this machine for the local provider, and as a build " + "context for one whose VMs build it, and a run writes it one way; run its deploys on one kind, such as " + "--sandbox local or --sandbox modal_vm") + + +def test_an_image_the_bundle_builds_is_refused_when_a_run_deploys_it_both_here_and_on_vms(bundle_dir): + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) + _task(bundle_dir, [{**AGENT, "sandbox_type": "local"}, + {**AGENT, "id": "again", "agent_name": "again", "sandbox_type": "modal_vm"}]) + + assert _problems(lambda: dry_run_bundle(bundle_dir)) == [ + "tasks/t.json: step 'agent': deploys agent '@local/~/triage/solver''s image on the 'local' sandbox provider, " + f"and tasks/t.json: step 'again' on the 'modal_vm' one: {MIXED}", + ] + + +def test_an_image_the_bundle_builds_is_refused_on_a_chain_that_falls_back_from_vms_to_the_local_provider(bundle_dir): + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) + _task(bundle_dir, [AGENT]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm,local")) == [ + "tasks/t.json: step 'agent': deploys agent '@local/~/triage/solver''s image on the 'modal_vm,local' sandbox " + f"provider: {MIXED}", ] @@ -517,13 +566,29 @@ def _env_folder(root, name, toml="", kind="mcp_server"): 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): +def test_an_image_the_bundle_builds_for_an_env_is_a_build_context_on_a_gateway_vm(bundle_dir): _env_folder(bundle_dir, "crm") _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + _infra(REMOTE) - 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")) + dry = dry_run_bundle(bundle_dir, sandbox="modal_vm") + + assert dry.materialization.contexts == {f"{BUNDLE}/crm__env_image"} + + +@pytest.mark.parametrize("sandbox, toml", [("modal", ""), ("modal_vm", 'env_provider_type = "server"\n')]) +def test_an_image_the_bundle_builds_for_an_env_is_refused_where_its_server_runs_by_name(bundle_dir, sandbox, toml): + """Modal's gateway runs each server in a container from its image's name, and a lone server does on any provider.""" + _env_folder(bundle_dir, "crm", toml) + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + _infra(REMOTE) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ + f"tasks/t.json: step 'env': deploys env '{BUNDLE}/crm''s image '{BUNDLE}/crm__env_image' on the {sandbox!r} " + "sandbox provider, which runs it by name, and the bundle builds it from envs/crm/Dockerfile, which a sandbox " + "runs only once built on this machine or in its VM; run it with --sandbox local, or on a provider whose VMs " + "build it, such as --sandbox modal_vm", + ] def test_a_website_the_bundle_writes_is_refused_on_modal_and_a_multi_holding_one_too(bundle_dir): diff --git a/tst/unit/bundle/run_test.py b/tst/unit/bundle/run_test.py index a3328b91..56a472c9 100644 --- a/tst/unit/bundle/run_test.py +++ b/tst/unit/bundle/run_test.py @@ -534,6 +534,8 @@ def test_the_cli_dry_run_lists_an_image_it_would_build_and_builds_nothing(bundle "agents/solver: v1 (new)\n" "tasks/agent.json: v1 (new)\n" "\n" + "Images:\n" + " agents/solver (Dockerfile image) v1: built on this machine\n" "Would run:\n" " tasks/agent.json v1\n" + DRY_RUN diff --git a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py index 7bca3229..6a741fdc 100644 --- a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py +++ b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py @@ -587,6 +587,29 @@ async def push(sandbox, store, object_url, vm_path): assert pushed == [(local, data), (local, image)] +@pytest.mark.asyncio +async def test_a_build_context_the_local_store_holds_is_pushed_over_exec_and_built(cli_routing, monkeypatch, tmp_path): + """A bundle run writes an @local image's context to this machine's store, which signs no url.""" + set_object_store(ConfiguredObjectStore()) + (tmp_path / "Dockerfile").write_text("FROM scratch\n") + image = DockerImageArtifact.put_context("@local/~/t/img", description="", context_path=str(tmp_path), + dockerfile_path=str(tmp_path / "Dockerfile")) + pushed = [] + + async def push(sandbox, store, object_url, vm_path): + pushed.append((object_url, vm_path)) + + monkeypatch.setattr(sandbox_module, "push_object_over_exec", push) + sandbox = _RecordingVmSandbox() + + await sandbox.load_docker_images([image]) + + work = _work(image.source_digest) + assert image.build_context_object_url.startswith("file://") + assert pushed == [(image.build_context_object_url, f"{work}/context.tar.gz")] + assert f"docker build --platform linux/amd64 -f Dockerfile -t {image.image_name} ." in sandbox.scripts[-2] + + def _context_image(id: str, digest: str | None, *, platform: str | None = "linux/amd64", dockerfile: str | None = "Dockerfile") -> DockerImageArtifact: return DockerImageArtifact(id=id, version=1, description="", image_name=f"local/{id}-0123456789ab:v1", From c3df078e36ace200d60d4d08cffc5cb4cedd9417 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 8 Oct 2026 23:26:29 -0700 Subject: [PATCH 2/2] fix(bundle): refuse a build context on a provider that creates no VM to build it in Co-Authored-By: Claude Opus 5.5 (1M context) --- src/agent_env/bundle/preflight.py | 11 ++++++----- tst/unit/bundle/preflight_test.py | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index a702d5e0..42372b78 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -408,11 +408,12 @@ def _contexts(self) -> frozenset[str]: for link in links: if isinstance(link, LocalSandboxProvider): local.append((where, what, links, link)) - elif link in by_name: - self._problem(where, f"deploys {what} on the {_shown(link)} sandbox provider, which runs it by " - f"name, and the bundle builds it from {source}, which a sandbox runs only " - "once built on this machine or in its VM; run it with --sandbox local, or " - "on a provider whose VMs build it, such as --sandbox modal_vm") + elif link in by_name or not _creates_vms(link): + how = "runs it by name" if link in by_name else "creates no VM to build it in" + self._problem(where, f"deploys {what} on the {_shown(link)} sandbox provider, which {how}, and " + f"the bundle builds it from {source}, which a sandbox runs only once built " + "on this machine or in its VM; run it with --sandbox local, or on a " + "provider whose VMs build it, such as --sandbox modal_vm") else: vms.append((where, what, links, link)) if local and vms: diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 55a48f41..c7e7f6c2 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -28,6 +28,7 @@ from agent_env.providers.env_state.env_state_provider import EnvStateInstance from agent_env.providers.env_state.store import register_env_state_instance from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider from agent_env.store.routing import namespace_routing from agent_env.task_step.context import PromptResponse, TaskStepContext from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep @@ -165,6 +166,28 @@ def test_an_agent_the_bundle_builds_is_refused_on_a_provider_that_runs_it_by_nam ] +class _ContainersOnly(SandboxProvider): + """A plugin's provider that runs an agent's image in a container of its own, and creates no VM.""" + + async def create_sandbox(self, **kwargs): + raise AssertionError("preflight creates nothing") + + +def test_an_agent_the_bundle_builds_is_refused_on_a_provider_that_creates_no_vm_to_build_it_in(bundle_dir, + monkeypatch): + registry = Config.sandbox_registry + monkeypatch.setattr(Config, "sandbox_registry", lambda self: {**registry(self), "containers": {"impl": _ContainersOnly}}) + layout(bundle_dir, {"agents/solver/Dockerfile": "FROM scratch\n"}) + _task(bundle_dir, [AGENT]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="containers")) == [ + "tasks/t.json: step 'agent': deploys agent '@local/~/triage/solver''s image on the 'containers' sandbox " + "provider, which creates no VM to build it in, and the bundle builds it from agents/solver/Dockerfile, which a " + "sandbox runs only once built on this machine or in its VM; run it with --sandbox local, or on a provider whose " + "VMs build it, such as --sandbox modal_vm", + ] + + MIXED = ("the bundle builds it from agents/solver/Dockerfile on this machine for the local provider, and as a build " "context for one whose VMs build it, and a run writes it one way; run its deploys on one kind, such as " "--sandbox local or --sandbox modal_vm")