Skip to content
28 changes: 24 additions & 4 deletions src/agent_env/artifact/artifacts/docker_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@

ProgressCallback = Callable[[str, str, int], None]

PUT_AGAIN = "put the image again in an image store whose registry the sandbox can reach"

_SAFE_GIT_NAME = re.compile(r"^[a-zA-Z0-9._-]+$")


Expand Down Expand Up @@ -275,16 +277,33 @@ def context_only(self) -> bool:
tag the image's build gives it."""
return bool(self.build_context_object_url) and not self.tar_gz_object_url and not names_registry(self.image_name)

@property
def buildable(self) -> bool:
"""Whether the context records its Dockerfile, so a sandbox can build it; old and GitHub contexts don't."""
return bool(self.build_context_object_url and self.dockerfile_path)

@property
def stored_under_build_tag(self) -> bool:
"""Whether the image is a tar.gz under a bare tag, which only the machine that built it has."""
return bool(self.tar_gz_object_url) and not names_registry(self.image_name)

def build_tag_reason(self) -> str:
"""Why a sandbox elsewhere can't pull an image ``stored_under_build_tag``."""
return (f"{self.id!r} v{self.version} is stored as a tar.gz under {self.image_name!r}, which names no registry: "
"it's the tag the machine that built it gave it, which a sandbox elsewhere can't pull")

def rebuilt_for(self, platform: str | None) -> bool:
"""Whether a VM sandbox of ``platform`` builds this image from its build context rather than load its tar.gz,
which is built for another platform: the image runs, though not byte for byte the one stored."""
return bool(self.tar_gz_object_url and self.build_context_object_url) and self.platform_problem(platform) is not None
return bool(self.tar_gz_object_url) and self.buildable and self.platform_problem(platform) is not None

def load_problem(self, *, on_this_machine: bool, platform: str | None = None) -> str | None:
"""Why a VM sandbox can't get this image, or None when it can: a tar.gz is loaded, an image with none is pulled
when its name names a registry, one that a VM ``on_this_machine`` or not reaches, and built from its build
context when it has only that; and a VM of ``platform`` runs only an image built for it, or one it rebuilds
from the build context beside a tar.gz of another platform."""
if self.context_only and not self.buildable:
return f"{self.id!r} v{self.version} is only a build context, which records no Dockerfile to build it with"
if self.tar_gz_object_url or self.context_only:
return None if self.rebuilt_for(platform) else self.platform_problem(platform)
if names_registry(self.image_name):
Expand All @@ -293,11 +312,12 @@ def load_problem(self, *, on_this_machine: bool, platform: str | None = None) ->
"registry to pull it from")

def by_name_problem(self, *, on_this_machine: bool) -> str | None:
"""Why a sandbox that runs an image by pulling its name can't run this one, or None when it can: a context-only
image's name is a tag only its build gives it, a VM sandbox's or Modal's, and pulled, it would be looked up on
Docker Hub; and a sandbox not ``on_this_machine`` can't pull from a registry on this machine."""
"""Why a sandbox that pulls images by name can't run this one, or None: a context-only image must be built,
and a sandbox elsewhere can't pull from this machine's registry or an image ``stored_under_build_tag``."""
if self.context_only:
return f"{self.id!r} v{self.version} is only a build context, which has to be built, not pulled"
if self.stored_under_build_tag and not on_this_machine:
return f"{self.build_tag_reason()}; run it on a provider whose VMs load its tar.gz, or {PUT_AGAIN}"
return pull_problem(self.image_name, on_this_machine=on_this_machine)

def platform_problem(self, platform: str | None) -> str | None:
Expand Down
5 changes: 4 additions & 1 deletion src/agent_env/bundle/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from dataclasses import dataclass

from agent_env.a2a_agent import A2AAgent
from agent_env.artifact.artifacts.docker_image import DockerImageArtifact, pull_problem
from agent_env.artifact.artifacts.docker_image import PUT_AGAIN, DockerImageArtifact, pull_problem
from agent_env.config import get_config
from agent_env.entity_refs import EntityKind, parse_toml_ref
from agent_env.env.bootstrap import (
Expand Down Expand Up @@ -383,6 +383,9 @@ def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_l
loads = (", or on a provider whose VMs load its tar.gz, such as --sandbox modal_vm"
if vms_load and image.artifact.tar_gz_object_url and form is not Accepts.LOADABLE
and image.artifact.platform_problem(DEFAULT_BUILD_PLATFORM) is None else "")
if image.artifact.stored_under_build_tag: # its remedy, with the CLI's flags
return (f"deploys {image.what} on the {_shown(link)} sandbox provider: "
f"{image.artifact.build_tag_reason()}; run it with --sandbox local{loads}, or {PUT_AGAIN}")
return (f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; run it with "
f"--sandbox local{loads}")
if form is Accepts.LOADABLE:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ async def build(image: DockerImageArtifact, app: modal.App) -> str:
try:
await asyncio.to_thread(_fetch_context, image.build_context_object_url, Path(work) / "context.tar.gz",
context)
dockerfile = context / (image.dockerfile_path or "Dockerfile")
dockerfile = context / image.dockerfile_path
ignores = ignore_file(context, dockerfile)
# Beside the context, not in it, so a COPY of the context still copies the Dockerfile as written.
for_modal = Path(work) / "Dockerfile"
Expand Down
4 changes: 2 additions & 2 deletions src/agent_env/providers/sandbox_providers/modal_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,8 @@ async def prepare_image(self, image: DockerImageArtifact, *, attribution: Option
"""Build ``image`` from its build context when Modal can't run it by name: it's only a build context, its name
is in a registry Modal can't reach, or it's built for another platform than Modal's; unless this process has
already built its sources. One it pulls drops an image prepared earlier under its name."""
if image.build_context_object_url and (image.by_name_problem(on_this_machine=self.ON_THIS_MACHINE)
or image.platform_problem(self._sandbox_cls.platform)):
if image.buildable and (image.by_name_problem(on_this_machine=self.ON_THIS_MACHINE)
or image.platform_problem(self._sandbox_cls.platform)):
self._context_images[image.image_name] = image
await self._context_image(image)
else:
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/providers/sandbox_providers/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ async def _build_image(self, images: list) -> None:
await self.load_object_file(first.build_context_object_url, f"{work}/context.tar.gz")
await self.exec_script(
f"tar -xzf {work}/context.tar.gz -C {work}/context && cd {work}/context && "
f"docker build --platform {shlex.quote(platform)} -f {shlex.quote(first.dockerfile_path or 'Dockerfile')} "
f"docker build --platform {shlex.quote(platform)} -f {shlex.quote(first.dockerfile_path)} "
f"{tags} ."
)
except RuntimeError as e:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def problem(self, image: DockerImageArtifact, *, on_this_machine: bool) -> str |
or None when it can: one elsewhere can't pull from a registry on this machine."""
if self is Accepts.LOADABLE:
return image.load_problem(on_this_machine=on_this_machine)
if self is Accepts.NAME_OR_CONTEXT and image.build_context_object_url:
if self is Accepts.NAME_OR_CONTEXT and image.buildable:
return None # the provider builds what it can't pull
return image.by_name_problem(on_this_machine=on_this_machine)

Expand Down
2 changes: 1 addition & 1 deletion tst/unit/a2a_agent/context_only_deploy_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async def create_container(self, **kwargs):


IMAGE = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="local/solver-0123456789ab:v2",
build_context_object_url="s3://bucket/ctx.tar.gz")
build_context_object_url="s3://bucket/ctx.tar.gz", dockerfile_path="Dockerfile")


@pytest.fixture
Expand Down
24 changes: 15 additions & 9 deletions tst/unit/artifact/test_docker_image_build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,17 +177,23 @@ def test_a_put_records_the_platform_as_docker_build_names_it(local_stores, conte
assert image.platform == "linux/arm64" and image.source_digest == named.source_digest


@pytest.mark.parametrize("context_url, platform, rebuilt, problem", [
("s3://bucket/ctx.tar.gz", "linux/amd64", True, None),
("s3://bucket/ctx.tar.gz", "linux/arm64", False, None),
("s3://bucket/ctx.tar.gz", None, False, None),
(None, "linux/amd64", False, "'img' v1 is a linux/arm64 image, and the sandbox runs linux/amd64"),
], ids=["context-another-platform", "context-same-platform", "vm-runs-any", "no-context"])
def test_a_vm_of_another_platform_builds_an_image_from_the_context_beside_its_tar_gz(context_url, platform, rebuilt,
problem):
OTHER_PLATFORM = "'img' v1 is a linux/arm64 image, and the sandbox runs linux/amd64"


@pytest.mark.parametrize("context_url, dockerfile, platform, rebuilt, problem", [
("s3://bucket/ctx.tar.gz", "Dockerfile", "linux/amd64", True, None),
("s3://bucket/ctx.tar.gz", "Dockerfile", "linux/arm64", False, None),
("s3://bucket/ctx.tar.gz", "Dockerfile", None, False, None),
(None, None, "linux/amd64", False, OTHER_PLATFORM),
("s3://bucket/ctx.tar.gz", None, "linux/amd64", False, OTHER_PLATFORM),
], ids=["context-another-platform", "context-same-platform", "vm-runs-any", "no-context",
"context-naming-no-dockerfile"])
def test_a_vm_of_another_platform_builds_an_image_from_the_context_beside_its_tar_gz(context_url, dockerfile, platform,
rebuilt, problem):
"""Only from a context that records its Dockerfile."""
image = DockerImageArtifact(id="img", version=1, description="d", image_name="localhost:5000/img:v1",
tar_gz_object_url="file:///state/img.tar.gz", build_context_object_url=context_url,
platform="linux/arm64")
dockerfile_path=dockerfile, platform="linux/arm64")

assert image.rebuilt_for(platform) is rebuilt
assert (image.load_problem(on_this_machine=False, platform=platform) or "").startswith(problem or "")
Expand Down
19 changes: 19 additions & 0 deletions tst/unit/artifact/test_docker_image_pulled.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry(image_name):
assert image.load_problem(on_this_machine=False) == image.by_name_problem(on_this_machine=False) == here


@pytest.mark.parametrize("image_name, tar_gz, elsewhere", [
("mcp-server-email", "s3://bucket/img.tar.gz",
"'img' v1 is stored as a tar.gz under 'mcp-server-email', which names no registry: it's the tag the machine that "
"built it gave it, which a sandbox elsewhere can't pull; run it on a provider whose VMs load its tar.gz, or put "
"the image again in an image store whose registry the sandbox can reach"),
("team/img:v1", "s3://bucket/img.tar.gz", "names no registry"),
(REF, "s3://bucket/img.tar.gz", None),
("python:3.12", None, None),
], ids=["bare-tag", "bare-path", "registry", "docker-hub-name"])
def test_a_sandbox_elsewhere_cant_pull_the_tag_an_image_stored_as_a_tar_gz_was_built_under(image_name, tar_gz,
elsewhere):
"""The tag exists only where the image was built; a bare name with no tar.gz is a Docker Hub image."""
image = DockerImageArtifact(id="img", version=1, description="d", image_name=image_name, tar_gz_object_url=tar_gz)

found = image.by_name_problem(on_this_machine=False)
assert (found is None) if elsewhere is None else (elsewhere in found)
assert image.by_name_problem(on_this_machine=True) is None


def test_a_vm_elsewhere_loads_the_tar_gz_of_an_image_named_for_this_machines_registry():
image = DockerImageArtifact(id="img", version=1, description="d", image_name="localhost:5000/img:v1",
tar_gz_object_url="file:///state/img.tar.gz")
Expand Down
16 changes: 16 additions & 0 deletions tst/unit/bundle/preflight_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,22 @@ def test_an_image_in_this_machines_registry_is_refused_where_a_sandbox_elsewhere
assert not local_store().path.exists()


def test_an_image_stored_under_its_build_tag_is_refused_where_a_sandbox_elsewhere_pulls_it_naming_the_flags(bundle_dir):
"""The remedy is given once, with the CLI's flags."""
_agent("solver", ("mcp-server-email:v1", "s3://bucket/img-v1.tar.gz"))
_task(bundle_dir, [AGENT])

[problem] = _problems(lambda: run_bundle(bundle_dir, sandbox="modal"))

assert problem.startswith("tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal' sandbox "
"provider: ")
assert "is stored as a tar.gz under 'mcp-server-email:v1', which names no registry" in problem
assert problem.endswith("; run it with --sandbox local, or on a provider whose VMs load its tar.gz, such as "
"--sandbox modal_vm, or put the image again in an image store whose registry the sandbox can reach")
assert problem.count("whose VMs load its tar.gz") == 1
assert not local_store().path.exists()


def test_a_chain_runs_an_image_in_this_machines_registry_on_its_local_provider_skipping_one_elsewhere(bundle_dir):
_agent("solver")
_task(bundle_dir, [AGENT])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ def _vm_provider(**attrs) -> MagicMock:


_CONTEXT_ONLY = DockerImageArtifact(id="slack-image", version=2, description="d", image_name="local/slack-0123456789ab:v2",
build_context_object_url="s3://bucket/ctx.tar.gz")
build_context_object_url="s3://bucket/ctx.tar.gz", dockerfile_path="Dockerfile")


@pytest.mark.asyncio
Expand Down Expand Up @@ -244,7 +244,8 @@ async def test_container_mode_refuses_a_servicedb_image_in_this_machines_registr
_SERVICEDB_BUILT_HERE = DockerImageArtifact(id="default-db__db_image", version=1, description="d",
image_name="localhost:5000/local/servicedb:v1",
tar_gz_object_url="file:///state/servicedb.tar.gz",
build_context_object_url="file:///state/ctx.tar.gz", platform="linux/arm64")
build_context_object_url="file:///state/ctx.tar.gz",
dockerfile_path="Dockerfile", platform="linux/arm64")


@pytest.mark.asyncio
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ async def test_a_deploy_starts_only_the_server_on_sqlite_with_its_sizing():


_CONTEXT_ONLY = DockerImageArtifact(id="slack-image", version=3, description="d", image_name="local/slack-0123456789ab:v3",
build_context_object_url="s3://bucket/ctx.tar.gz")
build_context_object_url="s3://bucket/ctx.tar.gz", dockerfile_path="Dockerfile")


@pytest.mark.asyncio
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ async def test_an_image_with_no_build_context_is_not_built(local_stores, builds)
("localhost:5000/local/gateway:v1", "linux/amd64", True),
("registry.example/gateway:v1", "linux/arm64", True),
("registry.example/gateway:v1", "linux/amd64", False),
], ids=["in-this-machines-registry", "for-another-platform", "pulled"])
("mcp-server-gateway", "linux/amd64", True),
], ids=["in-this-machines-registry", "for-another-platform", "pulled", "under-a-tag-of-the-machine-that-built-it"])
async def test_an_image_with_a_build_context_beside_its_tar_gz_is_built_when_modal_cant_run_it_by_name(
local_stores, context, builds, image_name, platform, built):
"""As the infra bootstrap writes its images: a tar.gz, which Modal can't load, and the context it was built from."""
Expand All @@ -201,6 +202,17 @@ async def test_an_image_with_a_build_context_beside_its_tar_gz_is_built_when_mod
assert len(builds.calls) == int(built)


@pytest.mark.asyncio
async def test_an_image_whose_context_records_no_dockerfile_is_not_built(local_stores, context, builds):
"""Old and GitHub contexts record no Dockerfile."""
image = _context_image("server-image", context).model_copy(update={
"image_name": "mcp-server-email", "tar_gz_object_url": "file:///state/server.tar.gz", "dockerfile_path": None})

await _provider().prepare_image(image)

assert builds.calls == []


@pytest.mark.asyncio
async def test_an_image_modal_pulls_drops_one_prepared_earlier_under_its_name(local_stores, context, builds):
provider = _provider()
Expand Down
Loading
Loading