From 37079a567dc9cd7e8b7f58b9407a3bee5493e630 Mon Sep 17 00:00:00 2001 From: Andrew Park Date: Mon, 9 Nov 2026 15:41:44 -0800 Subject: [PATCH 1/2] test: build a gateway image whose published pull fails rather than fail the fixture Co-Authored-By: Claude --- tst/integration/env/gateway/gateway_test.py | 28 ++++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tst/integration/env/gateway/gateway_test.py b/tst/integration/env/gateway/gateway_test.py index b5178d5a6..42a326a71 100644 --- a/tst/integration/env/gateway/gateway_test.py +++ b/tst/integration/env/gateway/gateway_test.py @@ -170,18 +170,22 @@ def _amd64_image(pinned: str | None) -> str | None: def _pull_published_else_build(img: DockerImageConfig) -> None: published = _PUBLISHED_IMAGES.get((img.dockerfile, img.context)) - pinned = published and _amd64_image(published_pinned(published)) - if pinned: - logger.info(f"Pulling {img.name}'s published image {pinned}...") - commands = [["docker", "pull", "--platform", "linux/amd64", pinned], ["docker", "tag", pinned, img.tag]] - else: - logger.info(f"Building {img.name} Docker image...") - commands = [["docker", "build", "--platform", "linux/amd64", "-f", str(img.dockerfile), "-t", img.tag, - str(img.context)]] - for argv in commands: - result = subprocess.run(argv, capture_output=True, text=True) - if result.returncode != 0: - raise RuntimeError(f"{shlex.join(argv)} failed: {result.stderr}") + try: + if pinned := published and _amd64_image(published_pinned(published)): + logger.info(f"Pulling {img.name}'s published image {pinned}...") + _docker("pull", "--platform", "linux/amd64", pinned) + _docker("tag", pinned, img.tag) + return + except (RuntimeError, ValueError) as e: + logger.warning(f"Pulling {img.name}'s published image failed, so it's built: {e}") + logger.info(f"Building {img.name} Docker image...") + _docker("build", "--platform", "linux/amd64", "-f", str(img.dockerfile), "-t", img.tag, str(img.context)) + + +def _docker(*args: str) -> None: + result = subprocess.run(["docker", *args], capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"{shlex.join(['docker', *args])} failed: {result.stderr}") @pytest.fixture(scope="module") From 09dc1beeffb736178014755aaaa49663390b5a68 Mon Sep 17 00:00:00 2001 From: Andrew Park Date: Mon, 9 Nov 2026 15:47:49 -0800 Subject: [PATCH 2/2] test: unit-test the gateway fixtures' pull-or-build fallback Moves the helper into tst/util/published_image.py so a unit test can force a failed lookup, pull, tag or build without Docker or the network. Co-Authored-By: Claude --- tst/integration/env/gateway/gateway_test.py | 38 +--------- tst/unit/test_published_image.py | 82 +++++++++++++++++++++ tst/util/published_image.py | 52 +++++++++++++ 3 files changed, 136 insertions(+), 36 deletions(-) create mode 100644 tst/unit/test_published_image.py create mode 100644 tst/util/published_image.py diff --git a/tst/integration/env/gateway/gateway_test.py b/tst/integration/env/gateway/gateway_test.py index 42a326a71..29a64e1d4 100644 --- a/tst/integration/env/gateway/gateway_test.py +++ b/tst/integration/env/gateway/gateway_test.py @@ -34,10 +34,7 @@ GET_TIME_TOOL_NAME as GET_TIME, _SCHEMA as GET_TIME_SCHEMA, ) -from agent_env.env.bootstrap import PUBLISHED_IMAGES from agent_env.env.envs.service_db import ServiceDBEnv -from agent_env.env.published_images import published_pinned -from agent_env.store.image_store.registry_api import read_manifest from agent_env.env.envs.playwright_mcp import ( PLAYWRIGHT_MCP_IMAGE_TAG, PLAYWRIGHT_MCP_ENVIRONMENT_NAME, @@ -50,6 +47,7 @@ from agent_env.env.snapshot_store import compute_env_fingerprint from agent_env.config import get_config from agent_env.task_step.task_steps.prompt_agent import PromptAgentTaskStep +from tst.util.published_image import pull_published_else_build from tst.util.capabilities import ( MCP_SERVER_SOURCES, missing_capability_reason, @@ -152,40 +150,8 @@ class DockerImageConfig: service_name="slack", ) -_PUBLISHED_IMAGES = {(image.dockerfile, image.dockerfile.parent): image for image in PUBLISHED_IMAGES} - - -def _amd64_image(pinned: str | None) -> str | None: - """``pinned``'s linux/amd64 image, pinned by its own digest: the containerd image store keeps a pulled index whole, - and ``docker save`` of one it holds only some platforms of fails.""" - if pinned is None: - return None - manifest = read_manifest(pinned, None) - if "manifests" not in manifest: - return pinned - digest = next((m["digest"] for m in manifest["manifests"] - if m.get("platform", {}).get("os") == "linux" and m["platform"].get("architecture") == "amd64"), None) - return digest and f"{pinned.partition('@')[0]}@{digest}" - - def _pull_published_else_build(img: DockerImageConfig) -> None: - published = _PUBLISHED_IMAGES.get((img.dockerfile, img.context)) - try: - if pinned := published and _amd64_image(published_pinned(published)): - logger.info(f"Pulling {img.name}'s published image {pinned}...") - _docker("pull", "--platform", "linux/amd64", pinned) - _docker("tag", pinned, img.tag) - return - except (RuntimeError, ValueError) as e: - logger.warning(f"Pulling {img.name}'s published image failed, so it's built: {e}") - logger.info(f"Building {img.name} Docker image...") - _docker("build", "--platform", "linux/amd64", "-f", str(img.dockerfile), "-t", img.tag, str(img.context)) - - -def _docker(*args: str) -> None: - result = subprocess.run(["docker", *args], capture_output=True, text=True) - if result.returncode != 0: - raise RuntimeError(f"{shlex.join(['docker', *args])} failed: {result.stderr}") + pull_published_else_build(img.name, img.dockerfile, img.context, img.tag) @pytest.fixture(scope="module") diff --git a/tst/unit/test_published_image.py b/tst/unit/test_published_image.py new file mode 100644 index 000000000..078e6a8f5 --- /dev/null +++ b/tst/unit/test_published_image.py @@ -0,0 +1,82 @@ +"""The gateway tier's fixtures pull a published image of unchanged sources, and build it when there's none or the pull +fails.""" + +from __future__ import annotations + +import subprocess + +import pytest + +from agent_env.env.bootstrap import PUBLISHED_IMAGES +from tst.util import published_image + +IMAGE = PUBLISHED_IMAGES[0] +INDEX = f"{IMAGE.ref()}@sha256:{'1' * 64}" +AMD64 = f"sha256:{'a' * 64}" + + +@pytest.fixture +def docker(monkeypatch): + """The ``docker`` commands run, each failing with ``fails[command]`` when set; the registry serves ``INDEX``, an + index of an amd64 and an arm64 image, unless ``served`` is emptied.""" + state = {"calls": [], "fails": {}, "served": INDEX} + + def run(argv, **kwargs): + state["calls"].append(argv[1:]) + return subprocess.CompletedProcess(argv, 1 if argv[1] in state["fails"] else 0, "", state["fails"].get(argv[1], "")) + + monkeypatch.setattr(published_image.subprocess, "run", run) + monkeypatch.setattr(published_image, "published_pinned", lambda image: state["served"]) + monkeypatch.setattr(published_image, "read_manifest", lambda ref, auth: {"manifests": [ + {"digest": f"sha256:{'b' * 64}", "platform": {"os": "linux", "architecture": "arm64"}}, + {"digest": AMD64, "platform": {"os": "linux", "architecture": "amd64"}}]}) + return state + + +def _make(tag="env-gateway"): + published_image.pull_published_else_build("gateway", IMAGE.dockerfile, IMAGE.dockerfile.parent, tag) + + +def test_a_published_image_of_the_same_sources_is_pulled_by_its_amd64_digest_and_tagged_not_built(docker): + _make() + + pinned = f"{IMAGE.ref()}@{AMD64}" + assert docker["calls"] == [["pull", "--platform", "linux/amd64", pinned], ["tag", pinned, "env-gateway"]] + + +@pytest.mark.parametrize("failing", ["pull", "tag"]) +def test_a_failed_pull_or_tag_builds_the_image_instead(docker, failing, caplog): + docker["fails"][failing] = "failed to copy: httpReadSeeker: failed open" + + _make() + + assert docker["calls"][-1][0] == "build" and "so it's built" in caplog.text + + +def test_a_registry_lookup_that_fails_builds_the_image(docker, monkeypatch): + def unreachable(image): + raise ValueError("ghcr.io: couldn't read its manifest") + + monkeypatch.setattr(published_image, "published_pinned", unreachable) + + _make() + + assert [call[0] for call in docker["calls"]] == ["build"] + + +def test_sources_nothing_has_published_and_ones_agent_env_doesnt_publish_are_built(docker, tmp_path): + docker["served"] = None + _make() + published_image.pull_published_else_build("slack", tmp_path / "Dockerfile", tmp_path, "mcp-slack") + + assert [call[0] for call in docker["calls"]] == ["build", "build"] + assert docker["calls"][1] == ["build", "--platform", "linux/amd64", "-f", str(tmp_path / "Dockerfile"), "-t", + "mcp-slack", str(tmp_path)] + + +def test_a_failed_build_raises_with_dockers_output(docker): + docker["served"] = None + docker["fails"]["build"] = "no such file: Dockerfile" + + with pytest.raises(RuntimeError, match="docker build .* failed: no such file: Dockerfile"): + _make() diff --git a/tst/util/published_image.py b/tst/util/published_image.py new file mode 100644 index 000000000..cd32f675a --- /dev/null +++ b/tst/util/published_image.py @@ -0,0 +1,52 @@ +"""Tag an image on this machine's Docker the way the gateway tier's fixtures do: pulled when the registry serves the +image agent-env published from the same Dockerfile and build context, else built here.""" + +from __future__ import annotations + +import logging +import shlex +import subprocess +from pathlib import Path + +from agent_env.env.bootstrap import PUBLISHED_IMAGES +from agent_env.env.published_images import published_pinned +from agent_env.store.image_store.registry_api import read_manifest + +logger = logging.getLogger(__name__) + +_PUBLISHED_IMAGES = {(image.dockerfile, image.dockerfile.parent): image for image in PUBLISHED_IMAGES} + + +def pull_published_else_build(name: str, dockerfile: Path, context: Path, tag: str) -> None: + """``tag``, pulled for linux/amd64 when the registry serves the published image of these sources, whose tag is a + digest of them, so a change to them is built; also built when the lookup or pull fails. A failed build raises.""" + published = _PUBLISHED_IMAGES.get((dockerfile, context)) + try: + if pinned := published and _amd64_image(published_pinned(published)): + logger.info(f"Pulling {name}'s published image {pinned}...") + _docker("pull", "--platform", "linux/amd64", pinned) + _docker("tag", pinned, tag) + return + except (RuntimeError, ValueError) as e: + logger.warning(f"Pulling {name}'s published image failed, so it's built: {e}") + logger.info(f"Building {name} Docker image...") + _docker("build", "--platform", "linux/amd64", "-f", str(dockerfile), "-t", tag, str(context)) + + +def _amd64_image(pinned: str | None) -> str | None: + """``pinned``'s linux/amd64 image, pinned by its own digest: the containerd image store keeps a pulled index whole, + and ``docker save`` of one it holds only some platforms of fails.""" + if pinned is None: + return None + manifest = read_manifest(pinned, None) + if "manifests" not in manifest: + return pinned + digest = next((m["digest"] for m in manifest["manifests"] + if m.get("platform", {}).get("os") == "linux" and m["platform"].get("architecture") == "amd64"), None) + return digest and f"{pinned.partition('@')[0]}@{digest}" + + +def _docker(*args: str) -> None: + result = subprocess.run(["docker", *args], capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"{shlex.join(['docker', *args])} failed: {result.stderr}")