diff --git a/AGENTS.md b/AGENTS.md index 8e3e27f3..a377d8c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ and deserialized through a registry. | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, and more). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. A new backend must pass the conformance kits in `tst/store/`. | | `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. | -| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `CREATES_VMS`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). | +| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `SANDBOX_ACCEPTS`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, and `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). | | `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. | | `runner/` | The `[runner]` seam: `Runner.submit()` returns `(run_id, instance_id)`; `LocalRunner` is built in. | | `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. | diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index cba14870..71742f53 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import functools import json import logging import posixpath @@ -26,8 +27,10 @@ skill_add_call, ) from agent_env.a2a_agent.staging import transfer_store +from agent_env.providers.sandbox_providers.chained_sandbox_provider import skip_refusing from agent_env.providers.sandbox_providers.local_sandbox import LOCAL_TRUST_ENV, local_grant_trust, start_trusting from agent_env.providers.sandbox_providers.sandbox_provider import ( + SANDBOX_MODE_VM, Accepts, SandboxProvider, all_sandbox_container_env, @@ -120,8 +123,11 @@ class A2AAgent: SNAPSHOT_METHOD_APPLY_CHANGELOG: ClassVar[str] = "apply-changelog" @staticmethod - def accepts(sandbox_provider: SandboxProvider) -> Accepts: - """The images an agent deploys from on ``sandbox_provider``: those its sandboxes run (``create_sandbox``).""" + def accepts(sandbox_provider: SandboxProvider, *, sandbox_mode: str | None = None) -> Accepts: + """The images an agent deploys from on ``sandbox_provider``: those its own sandboxes run (``create_sandbox``), + or, placed on a VM sandbox a task deployed there (``sandbox_mode`` "vm"), any that VM loads.""" + if sandbox_mode == SANDBOX_MODE_VM: + return Accepts.LOADABLE return sandbox_provider.SANDBOX_ACCEPTS @staticmethod @@ -412,7 +418,6 @@ async def deploy( attribution: Optional[Attribution] = None, ) -> DeployedA2AAgent: from agent_env.providers.sandbox_providers.sandbox_provider import ( - SANDBOX_MODE_VM, build_sandbox_provider, get_agent_sandbox_provider, ) @@ -434,15 +439,16 @@ async def deploy( resolved_env["LITELLM_BASE_URL"] = (provider or sandbox).url_from_sandbox(resolved_env["LITELLM_BASE_URL"]) merged_env = await asyncio.to_thread(self._build_merged_env, resolved_env, a2a_port) - image_name = self.docker_image_artifact.image_name - if problem := self.docker_image_artifact.by_name_problem(): - if provider is None and sandbox.mode != SANDBOX_MODE_VM: + image = self.docker_image_artifact + image_name = image.image_name + if provider is None: + # A container sandbox pulls nothing, so only an image that has to be built can't be what it runs. + if sandbox.mode != SANDBOX_MODE_VM and image.context_only: raise ValueError(f"Can't deploy agent {self.id!r} on the container sandbox {sandbox.sandbox_id!r}, which " - f"runs its own image, never the agent's: {problem}") - links = provider.links if provider else () - if by_name := [link for link in links if self.accepts(link).problem(self.docker_image_artifact)]: - raise ValueError(f"Can't deploy agent {self.id!r} with {type(by_name[0]).__name__}, which runs an " - f"agent's image by name: {problem}") + f"runs its own image, never the agent's: {image.id!r} v{image.version} is only a build " + "context, which has to be built, not pulled") + else: + provider = skip_refusing(provider, functools.partial(self._refusal, image), what=f"agent {self.id!r}") try: if provider is not None: @@ -499,6 +505,14 @@ async def deploy( await self.close() raise + def _refusal(self, image: DockerImageArtifact, link: SandboxProvider) -> str | None: + """Why ``link`` can't run ``image`` as this agent's own sandbox, as a clause on its name, or None when it can.""" + accepts = self.accepts(link) + if problem := accepts.problem(image, on_this_machine=link.ON_THIS_MACHINE): + how = "whose VMs can't run it" if accepts is Accepts.LOADABLE else "which runs an agent's image by name" + return f"{how}: {problem}" + return None + def _build_merged_env(self, resolved_env: dict[str, str], a2a_port: int) -> dict[str, str]: merged_env = dict(self.default_env_vars) merged_env.update(resolved_env) diff --git a/src/agent_env/artifact/artifacts/docker_image.py b/src/agent_env/artifact/artifacts/docker_image.py index 6ea1d6e9..14af42d7 100644 --- a/src/agent_env/artifact/artifacts/docker_image.py +++ b/src/agent_env/artifact/artifacts/docker_image.py @@ -26,7 +26,7 @@ from agent_env.store.image_store.oci_registry_credentials import is_loopback_host, names_registry, registry_host_from_ref from agent_env.store.image_store.registry_api import pin_digest from agent_env.utils.build_context import BuildContext -from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, normalize_platform logger = logging.getLogger(__name__) @@ -125,7 +125,7 @@ def put( image_ref = image_store.image_ref(repository, f"v{version}") image_store.ensure_repository(repository) _push_local_image(image_name, image_ref, image_store) - platform = platform or _image_platform(image_ref) + platform = normalize_platform(platform) if platform else _image_platform(image_ref) with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp: tmp_path = Path(tmp.name) @@ -198,7 +198,7 @@ def put_tar( tar_gz_object_url=tar_gz_object_url, build_context_object_url=build_context_object_url, dockerfile_path=dockerfile_path, - platform=platform, + platform=normalize_platform(platform) if platform else None, source_digest=source_digest, ) return store.put_document(instance) @@ -238,6 +238,7 @@ def put_context(cls, id: str, *, description: str, context_path: str, dockerfile from agent_env.artifact.store import get_artifact_store from agent_env.config import get_config + platform = normalize_platform(platform) root = Path(context_path) dockerfile = _resolve_dockerfile(root, dockerfile_path) if not dockerfile.is_file(): @@ -274,21 +275,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) - def load_problem(self) -> str | None: - """Why no VM sandbox can get this image, or None when one can: a tar.gz is loaded, an image with none is pulled - when its name names a registry, and built from its build context when it has only that.""" - if self.tar_gz_object_url or names_registry(self.image_name) or self.context_only: - return 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.""" + if self.tar_gz_object_url or self.context_only: + return self.platform_problem(platform) + if names_registry(self.image_name): + return pull_problem(self.image_name, on_this_machine=on_this_machine) or self.platform_problem(platform) return (f"{self.id!r} v{self.version} has no tar.gz, and its image name {self.image_name!r} doesn't name a " "registry to pull it from") - def by_name_problem(self) -> str | None: + 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.""" + Docker Hub; and a sandbox not ``on_this_machine`` can't pull from a registry on this machine.""" if self.context_only: return f"{self.id!r} v{self.version} is only a build context, which has to be built, not pulled" - return None + return pull_problem(self.image_name, on_this_machine=on_this_machine) + + def platform_problem(self, platform: str | None) -> str | None: + """Why a sandbox that runs images of ``platform`` can't run this one, or None when it can: an image runs on the + platform it records, the one it's built for, however either is spelled (``normalize_platform``). One that + records none, or a sandbox that runs any (None), isn't checked.""" + if platform is None or self.platform is None or normalize_platform(self.platform) == normalize_platform(platform): + return None + return (f"{self.id!r} v{self.version} is a {self.platform} image, and the sandbox runs {platform}; rebuild it for " + f"{platform}, which a Linux host of another platform does only with QEMU emulation set up") @classmethod async def put_from_github( @@ -466,6 +479,14 @@ def _parse_github_url(url: str) -> DockerImageArtifact._GitHubURLParts: return DockerImageArtifact._GitHubURLParts(owner=owner, repo=repo, ref=ref, path=path) +def pull_problem(image_name: str, *, on_this_machine: bool) -> str | None: + """Why a sandbox pulling ``image_name`` can't, or None when it can: one that isn't ``on_this_machine`` can't reach a + registry on this machine.""" + if on_this_machine or not is_loopback_host(registry_host_from_ref(image_name)): + return None + return f"{image_name} is in a registry on this machine, which a sandbox elsewhere can't pull from" + + def refuse_local_github_build(entity_id: str) -> None: """A GitHub build publishes to the configured registry, so it takes a registry id; an ``@local`` image is built from its own Dockerfile.""" @@ -538,9 +559,7 @@ def _image_platform(image_ref: str) -> str | None: capture_output=True, text=True) if result.returncode != 0 or result.stdout.count("/") != 2: return None - os_name, architecture, variant = result.stdout.strip().split("/") - # arm64 has one variant, so it goes unnamed as in `--platform linux/arm64`; 32-bit arm's v6 and v7 differ. - return f"{os_name}/{architecture}/{variant}" if architecture == "arm" and variant else f"{os_name}/{architecture}" + return normalize_platform(result.stdout) def _resolve_dockerfile(context_dir: Path, dockerfile_path: str | None) -> Path | None: diff --git a/src/agent_env/bundle/preflight.py b/src/agent_env/bundle/preflight.py index 759031ac..d99b8a6a 100644 --- a/src/agent_env/bundle/preflight.py +++ b/src/agent_env/bundle/preflight.py @@ -2,12 +2,14 @@ Each deploy, an env's, an agent's, a sandbox's or a rubrics judge's, runs on its effective sandbox provider: the run's ``--sandbox``, else the step's own field, else the config default, resolved as the step resolves it when it runs. A -comma-separated provider is a chain whose deploys fall back from one provider to the next, so every provider in it must -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. An env the bundle writes is -checked from its planned env.toml. +comma-separated provider is a chain whose deploys skip a provider that refuses them and fall back from one provider to +the next, so a deploy is refused only when every provider in it refuses it. A run is refused when a deploy would fail on +its provider: an image the provider can't run, by the check the deploy itself makes, such as one a sandbox elsewhere +would pull from this machine's registry; a VM asked of a provider that can't create one; or containers on the local +provider without a Docker daemon. An image's tar.gz or build context +that only this machine's object store holds isn't refused: agent-env pushes it to the sandbox. 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 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 that build it, in a VM or, as Modal does, @@ -21,12 +23,12 @@ from __future__ import annotations -from collections.abc import Callable, Iterable, Mapping -from dataclasses import dataclass, field -from urllib.parse import urlparse +import functools +from collections.abc import Callable, Iterable +from dataclasses import dataclass from agent_env.a2a_agent import A2AAgent -from agent_env.artifact.artifacts.docker_image import DockerImageArtifact +from agent_env.artifact.artifacts.docker_image import 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 ( @@ -52,6 +54,7 @@ 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 from agent_env.providers.sandbox_providers.sandbox_provider import ( + SANDBOX_MODE_VM, Accepts, SandboxProvider, build_sandbox_provider, @@ -60,12 +63,11 @@ get_sandbox_provider, ) from agent_env.store.base import NotFoundError -from agent_env.store.image_store.oci_registry_credentials import is_loopback_host, registry_host_from_ref -from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep +from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep, linked_sandbox_problem from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep from agent_env.task_step.task_steps.verifiers.rubrics_verifier import RubricsVerifierTaskStep -from agent_env.utils.docker_build import docker_unreachable +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, docker_unreachable from ._fs import relative from .parse import BundleError, BundleKind @@ -98,8 +100,7 @@ class _Image: """An image a deploy runs, as its message names it.""" what: str - local_only: str | None # why only this machine has it, or None - refused: Mapping[Accepts, str] = field(default_factory=dict) # why a provider accepting each form can't run it + artifact: DockerImageArtifact | None = None # the store's image, checked as each provider runs it built: str | None = None # the id of the image when the bundle builds it, whose form the walk decides at the end @@ -119,7 +120,6 @@ def __init__(self, plan: Plan, sandbox: str | None): self.problems: dict[str, dict[str, None]] = {} # each deploy's problem, and the deploys it's found at self.run_problems: list[str] = [] # the run's as a whole self.infra: set[str] = set() # the infra kinds a deploy on the local provider needs - self.remote_infra: list[tuple[str, SandboxProvider, set[str]]] = [] # (where, provider, kinds) of a deploy elsewhere self.docker_users: list[str] = [] self.default_agent_users: list[tuple[str, str]] = [] # (where, what names no agent) self.written = {write.id for write in plan.writes} @@ -152,8 +152,6 @@ def finish(self) -> Preflight: infra = infra_to_build(self.infra) except InfraError as e: 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()): @@ -188,22 +186,20 @@ def _env(self, where: str, step: DeployEnvTaskStep) -> None: else: # a plugin's provider, or one deploy_env's own preflight refuses for this env return env_id, images, env_provider = step.env_id, deployment.images, deployment.provider - self._check(where, provider, env_provider.accepts, images) - if _local_link(provider): + elsewhere = functools.partial(self._gateway_elsewhere, env_id, env_provider, kinds) if GATEWAY in kinds else None + taking = self._check(where, provider, env_provider.accepts, images, vms_load=GATEWAY in kinds, elsewhere=elsewhere) + if any(link.ON_THIS_MACHINE for link in taking): self.infra |= kinds self.docker_users.append(where) - if (remote := _remote_links(provider)) and GATEWAY in kinds: - for link in remote: - if problem := env_provider.sandbox_problem(link, websites=WEBSITE_BROWSER in kinds): - self._problem(where, f"deploys env {env_id!r} on the {_shown(link)} sandbox provider, which " - f"{problem}; run it on a VM provider, such as --sandbox local") - break - containers = [link for link in remote if not env_provider.in_vm(link)] - vms = [link for link in remote if link not in containers] - if containers: - self.remote_infra.append((where, containers[0], kinds - {WEBSITE_BROWSER})) - if vms: - self.remote_infra.append((where, vms[0], kinds)) + + def _gateway_elsewhere(self, env_id: str, env_provider: type, kinds: set[str], link: SandboxProvider) -> list[str]: + """Why ``link``, a provider not on this machine, can't run env ``env_id``'s gateway deploy: its sandboxes, or + the infra envs of ``kinds`` it runs, which agent-env builds only for the local provider.""" + found = [] + if problem := env_provider.sandbox_problem(link, websites=WEBSITE_BROWSER in kinds): + found.append(f"deploys env {env_id!r} on the {_shown(link)} sandbox provider, which {problem}; run it on a VM " + "provider, such as --sandbox local") + return found + self._remote_infra(link, kinds if env_provider.in_vm(link) else kinds - {WEBSITE_BROWSER}) 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.""" @@ -260,11 +256,11 @@ 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 ref.id in self.built: - return _Image(what, None, built=ref.id) + return _Image(what, built=ref.id) if ref.id in self.written: return None # another of the bundle's writes, which materialize refuses or writes first try: - return _image(what, DockerImageArtifact.get(ref.id, self._planned_version(ref.kind, ref.id, ref.version))) + return _Image(what, 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 @@ -273,14 +269,19 @@ def _planned_version(self, kind: EntityKind, entity_id: str, version: int | None 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: + accepts: Callable[[SandboxProvider], Accepts] = A2AAgent.accepts if step.sandbox_name: linked = sandboxes.get(step.sandbox_name) if linked is None: return # the step itself refuses an agent linked to a sandbox the task doesn't deploy + if problem := linked_sandbox_problem(step.sandbox_name, linked.sandbox_mode, linked.exposed_ports or ()): + self._problem(where, problem) + return provider = _provider(self.sandbox or linked.sandbox_type, get_sandbox_provider) + accepts = functools.partial(A2AAgent.accepts, sandbox_mode=linked.sandbox_mode) # the VM loads its image else: provider = _provider(self.sandbox or step.sandbox_type, get_agent_sandbox_provider) - self._agent_deploy(where, provider, step.a2a_agent_id, step.a2a_agent_version, "names no agent") + self._agent_deploy(where, provider, step.a2a_agent_id, step.a2a_agent_version, "names no agent", accepts) def _judge(self, where: str, step: RubricsVerifierTaskStep) -> None: if not step.use_agent_judge or step.agent_name is not None: @@ -289,26 +290,30 @@ def _judge(self, where: str, step: RubricsVerifierTaskStep) -> None: self._agent_deploy(where, provider, step.judge_a2a_agent_id, None, "names no judge agent") def _agent_deploy(self, where: str, provider: SandboxProvider, agent_id: str | None, version: int | None, - unnamed: str) -> None: - if _local_link(provider): - self.docker_users.append(where) + unnamed: str, accepts: Callable[[SandboxProvider], Accepts] = A2AAgent.accepts) -> None: if agent_id is None: self.default_agent_users.append((where, unnamed)) agent_id = get_config().get_default_a2a_agent_id() - if (image := self._agent_image(agent_id, version)) is not None: - self._check(where, provider, A2AAgent.accepts, [image]) + image = self._agent_image(agent_id, version) + taking = self._check(where, provider, accepts, [image] if image else [], vms_load=True) + if any(link.ON_THIS_MACHINE for link in taking): + self.docker_users.append(where) def _sandbox(self, where: str, step: DeploySandboxTaskStep) -> None: provider = _provider(self.sandbox or step.sandbox_type, get_sandbox_provider) - if step.sandbox_mode == "vm": - if not provider.CREATES_VMS: - self._problem(where, f"deploys a VM sandbox, and the {_shown(provider)} sandbox provider can't create a " - "VM; run it on one that can, such as --sandbox local") + if step.sandbox_mode not in provider.SUPPORTED_SANDBOX_MODES: + kind = "VM" if step.sandbox_mode == SANDBOX_MODE_VM else step.sandbox_mode + self._problem(where, f"deploys a {kind} sandbox, and the {_shown(provider)} sandbox provider can't create a " + f"{kind}; run it on one that can, such as --sandbox local") + return + if step.sandbox_mode == SANDBOX_MODE_VM: return if _local_link(provider): self.docker_users.append(where) - if step.image and (remote := _remote_links(provider)): - self._reachable(where, remote, [_Image(f"the image {step.image}", _local_only(step.image))]) + if step.image and not _local_link(provider): # one on this machine pulls from its registry, and a deploy skips the rest + if problem := pull_problem(step.image, on_this_machine=False): + self._problem(where, f"deploys the image {step.image} on the {_shown(provider)} sandbox provider: " + f"{problem}; run it with --sandbox local") # What a deploy runs @@ -319,70 +324,86 @@ def _agent_image(self, agent_id: str, version: int | None) -> _Image | None: if (agent := self.agents.get(agent_id)) is not None: image_id, image_version = parse_toml_ref(EntityKind.ARTIFACT, agent.config.get("image")) if image_id in self.built: - return _Image(what, None, built=image_id) + return _Image(what, 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) - return _image(what, DockerImageArtifact.get(image_id, planned)) + return _Image(what, DockerImageArtifact.get(image_id, planned)) try: - return _image(what, A2AAgent.get(agent_id, version).docker_image_artifact) + return _Image(what, A2AAgent.get(agent_id, version).docker_image_artifact) except (NotFoundError, ValueError, KeyError, TypeError): return None def _check(self, where: str, provider: SandboxProvider, accepts: Callable[[SandboxProvider], Accepts], - images: list[_Image]) -> None: - """Check ``images`` on each provider a deploy may run on, as ``accepts`` says the deploy runs them there, and - that one not on this machine reaches each. An image the bundle builds is noted with them, for ``_contexts`` to - choose its form.""" + images: list[_Image], *, vms_load: bool, + elsewhere: Callable[[SandboxProvider], list[str]] | None = None) -> list[SandboxProvider]: + """The providers the deploy ``where`` may run on that take it, refusing the deploy, naming why each refuses + (``_link_refusals``), when none does: a deploy skips a provider that refuses it, so one that takes it is enough. + One on this machine that takes it runs it on this machine's Docker; one it skips needs none. An image the + bundle builds is noted with them, for ``_contexts`` to choose its form.""" links = list(provider.links) - for form in Accepts: - if accepting := [link for link in links if accepts(link) is form]: - self._accepted(where, accepting[0], form, images) - if remote := _remote_links(provider): - self._reachable(where, remote, images) + refusals = [(link, self._link_refusals(link, accepts, images, vms_load=vms_load, elsewhere=elsewhere)) + for link in links] + if all(found for _, found in refusals): + for _, found in refusals: + for problem in found: + self._problem(where, problem) for image in images: if image.built is not None: self.uses.setdefault(image.built, []).append((where, image.what, links, accepts)) - - def _accepted(self, where: str, link: SandboxProvider, form: Accepts, images: list[_Image]) -> None: - """Refuse each of ``images`` a deploy can't run on ``link``, which takes images in ``form``: a VM loads an - image's tar.gz, or pulls an image with none when its name names a registry, and a provider that runs an image - by name can't run one that is only a build context, whose name is a tag only its build gives it.""" - for image in images: - if (refusal := image.refused.get(form)) is None: - continue - if form is Accepts.LOADABLE: - self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider, which can't load " - f"it: {refusal}") - continue - builds_itself = link.CONTAINER_ACCEPTS is Accepts.NAME_OR_CONTEXT - builds = ("whose VMs build it, such as --sandbox modal_vm" if builds_itself - else "that builds it, such as --sandbox modal") - self._problem(where, f"deploys {image.what} on the {_shown(link)} sandbox provider, which runs it by name: " - f"{refusal}; run it on a provider {builds}") - - def _reachable(self, where: str, remote: list[SandboxProvider], images: list[_Image]) -> None: - for image in images: - if image.local_only: - self._problem(where, f"deploys {image.what} on the {_shown(remote[0])} sandbox provider, which can't " - f"reach it: {image.local_only}; run it with --sandbox local") - - def _remote_infra(self, where: str, provider: SandboxProvider, kinds: set[str]) -> None: + return [link for link, found in refusals if not found] + + def _link_refusals(self, link: SandboxProvider, accepts: Callable[[SandboxProvider], Accepts], images: list[_Image], + *, vms_load: bool, elsewhere: Callable[[SandboxProvider], list[str]] | None) -> list[str]: + """Why ``link`` can't take a deploy of ``images``: each it can't run as ``accepts`` says the deploy runs images + there (``_refusal``), and, when it isn't on this machine, whatever ``elsewhere`` says of it. ``vms_load`` when + the deploy loads images on a VM provider, as an agent's and a gateway's do.""" + found = [refusal for image in images if (refusal := self._refusal(link, accepts(link), image, vms_load=vms_load))] + if elsewhere is not None and not link.ON_THIS_MACHINE: + found += elsewhere(link) + return found + + def _refusal(self, link: SandboxProvider, form: Accepts, image: _Image, *, vms_load: bool) -> str | None: + """Why a deploy can't run ``image`` on ``link``, which takes images in ``form``, by the check the deploy makes + (``Accepts.problem``), or None when it can: a VM loads an image's tar.gz, or pulls an image with none when its + name names a registry it reaches, and a provider that runs an image by name can't run one that is only a build + context, whose name is a tag only its build gives it, nor, from elsewhere, pull one from this machine's + registry. A VM it would create checks the image's platform once it exists.""" + if image.artifact is None or (refusal := form.problem(image.artifact, on_this_machine=link.ON_THIS_MACHINE)) is None: + return None + if form.problem(image.artifact, on_this_machine=True) is None: # refused only because the sandbox isn't here + 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 "") + return (f"deploys {image.what} on the {_shown(link)} sandbox provider: {refusal}; run it with " + f"--sandbox local{loads}") + if form is Accepts.LOADABLE: + return f"deploys {image.what} on the {_shown(link)} sandbox provider, which can't load it: {refusal}" + builds_itself = link.CONTAINER_ACCEPTS is Accepts.NAME_OR_CONTEXT + builds = ("whose VMs build it, such as --sandbox modal_vm" if builds_itself + else "that builds it, such as --sandbox modal") + return (f"deploys {image.what} on the {_shown(link)} sandbox provider, which runs it by name: {refusal}; run it " + f"on a provider {builds}") + + def _remote_infra(self, link: SandboxProvider, kinds: set[str]) -> list[str]: + """Why ``link``, a provider not on this machine, can't run the infra envs of ``kinds``.""" + found = [] for kind in sorted(kinds): env_id = default_env_id(kind) try: env = Env.get(env_id) except NotFoundError: - self._problem(where, f"deploys on the {_shown(provider)} sandbox provider, which needs the {kind} env " - f"{env_id!r}, and the store doesn't hold it; agent-env builds it only for the local " - f"sandbox provider, so put it in a store that provider can reach (`{put_command(kind)}`)") + found.append(f"deploys on the {_shown(link)} sandbox provider, which needs the {kind} env {env_id!r}, and " + "the store doesn't hold it; agent-env builds it only for the local sandbox provider, so put " + f"it in a store that provider can reach (`{put_command(kind)}`)") continue artifacts = ([env.db_docker_image_artifact, env.db_web_docker_image_artifact, env.db_mcp_docker_image_artifact] if isinstance(env, ServiceDBEnv) else [env.docker_image_artifact]) - images = [_image(f"the {kind} env {env_id!r}'s image {image.id!r}", image) for image in artifacts] - accepts = EnvironmentGatewayProvider.accepts(provider, prepared=kind == GATEWAY) - self._accepted(where, provider, accepts, images) - self._reachable(where, [provider], images) + accepts = EnvironmentGatewayProvider.accepts(link, prepared=kind == GATEWAY) + found += [refusal for image in artifacts + if (refusal := self._refusal(link, accepts, _Image(f"the {kind} env {env_id!r}'s image {image.id!r}", + image), vms_load=True))] + return found def _contexts(self) -> frozenset[str]: """The images the bundle builds that it writes as build contexts: those every deploy runs on providers that @@ -443,7 +464,7 @@ 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}", image) for image in images], bool(topology.website_configs) + return [_Image(f"env {env.id!r}'s image {image.id!r}", image) for image in images], bool(topology.website_configs) def _state_type(step: DeployEnvTaskStep) -> str | None: @@ -464,10 +485,6 @@ def _local_link(provider: SandboxProvider) -> bool: return any(link.ON_THIS_MACHINE for link in provider.links) -def _remote_links(provider: SandboxProvider) -> list[SandboxProvider]: - return [link for link in provider.links if not link.ON_THIS_MACHINE] - - def _shown(provider: SandboxProvider) -> str: return _named(list(provider.links)) @@ -485,17 +502,3 @@ def _name(provider: SandboxProvider) -> str: return name return cls.__name__ - -def _image(what: str, image: DockerImageArtifact) -> _Image: - return _Image(what, _local_only(image), {form: problem for form in Accepts if (problem := form.problem(image))}) - - -def _local_only(image: DockerImageArtifact | str) -> str | None: - """Why only this machine has ``image``: its reference names a registry on this machine, or it's saved in this - machine's object store. None when neither.""" - ref = image if isinstance(image, str) else image.image_name - if is_loopback_host(registry_host_from_ref(ref)): - return f"{ref} is in a registry on this machine" - if not isinstance(image, str) and image.tar_gz_object_url and urlparse(image.tar_gz_object_url).scheme == "file": - return f"{image.id!r} is saved in this machine's object store" - return None diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index 3ff128e8..4adc83c3 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -12,6 +12,7 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING, Optional +from agent_env.artifact.artifacts.docker_image import pull_problem from agent_env.attribution import Attribution from agent_env.env.env import DeployedGatewayEnv, _mcp_url from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode @@ -34,7 +35,12 @@ ) from agent_env.providers.env_providers.env_provider import _SandboxEnvironmentProvider, _size_kwargs, _tool_names from agent_env.providers.sandbox_providers.sandbox import Sandbox, VmSandbox, port_bindings -from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER, Accepts, SandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import ( + SANDBOX_MODE_CONTAINER, + SANDBOX_MODE_VM, + Accepts, + SandboxProvider, +) if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -128,7 +134,7 @@ class EnvironmentGatewayProvider(_SandboxEnvironmentProvider): def in_vm(sandbox_provider: SandboxProvider) -> bool: """Whether a deploy on ``sandbox_provider`` runs in a VM, as one on a provider that creates VMs does; on one that doesn't, the gateway and each server run in a container of their own, on the provider's private network.""" - return sandbox_provider.CREATES_VMS + return SANDBOX_MODE_VM in sandbox_provider.SUPPORTED_SANDBOX_MODES @classmethod def accepts(cls, sandbox_provider: SandboxProvider, *, prepared: bool = True) -> Accepts: @@ -687,23 +693,6 @@ async def _deploy_via_vm( website_images = website_images or [] sidecars = sidecars or [] - if existing_sandbox is not None: - self._sandbox = existing_sandbox - else: - vm_kwargs: dict = {} - if cpu is not None: - vm_kwargs["cpu"] = cpu - if memory_mb is not None: - vm_kwargs["memory"] = memory_mb - self._sandbox = await sandbox_provider.create_vm( - exposed_ports=[gateway_port, DB_WEB_PORT, DB_MCP_PORT] + [sc.host_port for sc in sidecars], - timeout=ttl_seconds, - disk_size_gb=disk_size_gb, - attribution=attribution, - **vm_kwargs, - ) - sandbox = self._sandbox - config = get_config() gateway_env = Env.get(config.default_gateway_env_id) @@ -727,6 +716,29 @@ async def _deploy_via_vm( ] if self._needs_local_postgres: all_images += self._state_provider.store_images_to_load() + # Every image is checked before the VM is created, so one it can't get leaves nothing to close. + here = (existing_sandbox or sandbox_provider).ON_THIS_MACHINE + if problems := [problem for image in all_images + if (problem := Accepts.LOADABLE.problem(image, on_this_machine=here))]: + raise ValueError(f"Can't deploy a gateway on {type(sandbox_provider).__name__}, whose VM can't run its " + f"images: {'; '.join(problems)}") + + if existing_sandbox is not None: + self._sandbox = existing_sandbox + else: + vm_kwargs: dict = {} + if cpu is not None: + vm_kwargs["cpu"] = cpu + if memory_mb is not None: + vm_kwargs["memory"] = memory_mb + self._sandbox = await sandbox_provider.create_vm( + exposed_ports=[gateway_port, DB_WEB_PORT, DB_MCP_PORT] + [sc.host_port for sc in sidecars], + timeout=ttl_seconds, + disk_size_gb=disk_size_gb, + attribution=attribution, + **vm_kwargs, + ) + sandbox = self._sandbox await sandbox.load_docker_images(all_images) # Compose declares the local_state service by name, so "standing up" is just naming the host; @@ -882,9 +894,18 @@ async def _deploy_via_containers( config = get_config() gateway_env = Env.get(config.default_gateway_env_id) - # Each image is prepared (one that is only a build context, built) before anything is created, so a failed - # build leaves nothing. + # Each image is checked, and prepared (one that is only a build context, built), before anything is created, so + # an image the provider can't run, or a failed build, leaves nothing. images = [gateway_env.docker_image_artifact, *mcp_server_images] + accepts = self.accepts(sandbox_provider) + problems = [problem for image in images + if (problem := accepts.problem(image, on_this_machine=sandbox_provider.ON_THIS_MACHINE))] + if self._needs_local_postgres: # the servicedb and its sidecars, by the names the state provider picks + problems += [problem for name in self._state_provider.container_images() + if (problem := pull_problem(name, on_this_machine=sandbox_provider.ON_THIS_MACHINE))] + if problems: + raise ValueError(f"Can't deploy a gateway on {type(sandbox_provider).__name__}, which runs its images by " + f"name: {'; '.join(problems)}") await asyncio.gather(*(sandbox_provider.prepare_image(image, attribution=attribution) for image in images)) deploy = _ContainerDeploy( diff --git a/src/agent_env/providers/env_providers/env_server_provider.py b/src/agent_env/providers/env_providers/env_server_provider.py index 09a76c96..61f31ec1 100644 --- a/src/agent_env/providers/env_providers/env_server_provider.py +++ b/src/agent_env/providers/env_providers/env_server_provider.py @@ -75,7 +75,7 @@ async def _deploy_server( name = env.environment_name image = env.docker_image_artifact - if problem := self.accepts(sandbox_provider).problem(image): + if problem := self.accepts(sandbox_provider).problem(image, on_this_machine=sandbox_provider.ON_THIS_MACHINE): raise ValueError(f"Can't deploy {name!r} as a server on {type(sandbox_provider).__name__}, which runs its " f"image by name: {problem}") await sandbox_provider.prepare_image(image, attribution=attribution) diff --git a/src/agent_env/providers/env_state/local_postgres.py b/src/agent_env/providers/env_state/local_postgres.py index 34a19b98..52e600c3 100644 --- a/src/agent_env/providers/env_state/local_postgres.py +++ b/src/agent_env/providers/env_state/local_postgres.py @@ -335,6 +335,13 @@ def container_store_image(self) -> str: logger.warning(f"servicedb image {db_image} is not in the image store; running {_STOCK_POSTGRES_IMAGE}") return _STOCK_POSTGRES_IMAGE + def container_images(self) -> list[str]: + """The images the container path starts by name: the servicedb's, and each sidecar's it provisions (those the + image store owns, as ``sidecar_specs`` decides).""" + cfg = self.service_db_config + return [self.container_store_image(), + *(image for image in (cfg.db_web_image, cfg.db_mcp_image) if self._in_image_store(image))] + def sidecar_specs(self, *, instance: EnvStateInstance | None = None) -> list[ContainerSpec]: """pgweb + db-mcp container specs (Modal/container path), pointed at the stood-up servicedb. Only sidecar images the image store owns are provisionable in container mode; diff --git a/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py index 631214a8..dce3fb38 100644 --- a/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py @@ -2,11 +2,12 @@ import asyncio import logging +from collections.abc import Callable from typing import TYPE_CHECKING from agent_env.attribution import Attribution -from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError, Sandbox -from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError, Sandbox, VmSandbox +from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM, SandboxProvider if TYPE_CHECKING: from agent_env.artifact.artifacts.docker_image import DockerImageArtifact @@ -39,19 +40,13 @@ def filter_sandbox_providers( """``providers`` that can enforce ``policy``, so an incapable one is never called.""" if policy is None or not policy.restricts_egress: return providers - eligible: list[SandboxProvider] = [] - for p in providers: - if p.supports_network_policy(policy): - eligible.append(p) - else: - log = logger.warning if p is providers[0] else logger.info - log(f"sandbox_provider_skipped provider={type(p).__name__} mode={policy.mode.value}") - if not eligible: - raise NetworkPolicyUnsupportedError( + return _without( + providers, + lambda p: None if p.supports_network_policy(policy) else f"can't enforce network policy mode {policy.mode.value!r}", + lambda refused: NetworkPolicyUnsupportedError( f"No sandbox backend can enforce network policy mode {policy.mode.value!r} " - f"(providers: {[type(p).__name__ for p in providers]})" - ) - return eligible + f"(providers: {[type(p).__name__ for p in providers]})"), + ) def supports_network_policy(self, policy: NetworkPolicy) -> bool: return any(p.supports_network_policy(policy) for p in self._providers) @@ -81,6 +76,17 @@ async def create_sandbox( logger.warning(f"{name}.create_sandbox failed: {e}; trying next provider") errors.append((name, e)) continue + # The deploy loads the image into a VM, which refuses one it can't get or run; the next provider's may not. + if (image is not None and sandbox.mode == SANDBOX_MODE_VM and isinstance(sandbox, VmSandbox) + and (problems := sandbox.load_problems([image]))): + problem = "; ".join(problems) + logger.warning(f"{name}.create_sandbox gave a VM that can't load the image: {problem}; trying next provider") + errors.append((name, ValueError(problem))) + try: + await sandbox.terminate() + except Exception as e: + logger.warning(f"{name}: terminating sandbox {sandbox.sandbox_id} failed: {e}") + continue logger.info( f"chain_provider_attempt provider={name} status=success sandbox_type={sandbox.type} " f"network_policy={sandbox.network_policy.mode.value if sandbox.network_policy else 'unknown'}" @@ -113,3 +119,27 @@ async def close(self) -> None: await p.close() except Exception as e: logger.warning(f"close() failed on {type(p).__name__}: {e}") + + +def skip_refusing(provider: SandboxProvider, problem: Callable[[SandboxProvider], str | None], *, what: str + ) -> SandboxProvider: + """``provider``, less each of its links ``problem`` refuses, as a deploy of ``what`` runs on any link that takes it. + ``problem`` says why a link refuses as a clause on its name ("which ..."); a ValueError naming each is raised when + it refuses every link.""" + links = list(provider.links) + running = _without(links, problem, lambda refused: ValueError(f"Can't deploy {what} " + "; nor ".join( + f"with {type(link).__name__}, {reason}" for link, reason in refused))) + return provider if len(running) == len(links) else ChainedSandboxProvider(running) + + +def _without(providers: list[SandboxProvider], problem: Callable[[SandboxProvider], str | None], + refuse: Callable[[list[tuple[SandboxProvider, str]]], Exception]) -> list[SandboxProvider]: + """``providers`` less each one ``problem`` refuses, skipped with a warning when it's the first, the one preferred, + and noted otherwise; ``refuse(refusals)`` is raised when it refuses them all.""" + refused = [(p, reason) for p in providers if (reason := problem(p))] + if refused and len(refused) == len(providers): + raise refuse(refused) + for p, reason in refused: + log = logger.warning if p is providers[0] else logger.info + log(f"sandbox_provider_skipped provider={type(p).__name__}: {reason}") + return [p for p in providers if all(p is not refusing for refusing, _ in refused)] diff --git a/src/agent_env/providers/sandbox_providers/e2b/provider.py b/src/agent_env/providers/sandbox_providers/e2b/provider.py index b0a293a7..d2f25479 100644 --- a/src/agent_env/providers/sandbox_providers/e2b/provider.py +++ b/src/agent_env/providers/sandbox_providers/e2b/provider.py @@ -38,7 +38,6 @@ class E2BSandboxProvider(SandboxProvider): interpolation in ``[sandbox.providers.e2b.config]``. """ - CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name # AsyncSandbox.get_host(port) returns ``-.e2b.app``. diff --git a/src/agent_env/providers/sandbox_providers/local_sandbox.py b/src/agent_env/providers/sandbox_providers/local_sandbox.py index 6482aa52..aa285861 100644 --- a/src/agent_env/providers/sandbox_providers/local_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/local_sandbox.py @@ -135,6 +135,7 @@ class LocalSandbox(VmSandbox): """ ON_THIS_MACHINE = True + platform = None # this machine's Docker runs its own platform, and emulates others where it's set up def url_from_sandbox(self, url: str) -> str: return LocalSandboxProvider.get_external_url(url) @@ -497,9 +498,6 @@ class LocalSandboxProvider(SandboxProvider): EXTRA_CONTAINER_RUN_ARGS = " ".join(f"--add-host {entry}" for entry in _EXTRA_HOSTS) ON_THIS_MACHINE = True - # A sandbox or a container is a container from the image's name, which this machine's Docker pulls or already - # holds; a VM it creates (create_vm) loads images of its own. - CREATES_VMS = True def url_from_sandbox(self, url: str) -> str: return self.get_external_url(url) diff --git a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py index ca340771..d0762293 100644 --- a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py @@ -274,7 +274,6 @@ async def _download_ranged(self, url_factory: Callable[[int], Awaitable[str]], v class ModalVmSandboxProvider(SandboxProvider): EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.modal.host", "*.w.modal.host") - CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name @classmethod diff --git a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py index ba208f07..25d2e562 100644 --- a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py @@ -134,7 +134,6 @@ class SailVmSandboxProvider(SandboxProvider): Sail adds it to the agent's requests to the model endpoint (see ``model_key``).""" EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.sail.box",) - CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name def __init__( diff --git a/src/agent_env/providers/sandbox_providers/sandbox.py b/src/agent_env/providers/sandbox_providers/sandbox.py index d0b778b0..2cb83a35 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sandbox.py @@ -152,6 +152,10 @@ class Sandbox(ABC): # Whether it runs on this machine, as a local sandbox does. ON_THIS_MACHINE: ClassVar[bool] = False + # The platform of the images it runs, as an image records the one it's built for: linux/amd64, what agent-env + # builds for, unless its provider sets another when it creates it. None runs any, unchecked, as a local sandbox + # does, whose Docker runs this machine's platform and emulates others where it's set up. + platform: str | None = DEFAULT_BUILD_PLATFORM # The host other containers on its provider's private network reach it at, ready for a URL; None when it's on none. private_host: str | None = None @@ -290,11 +294,12 @@ async def wait_for_vm(self) -> None: async def load_docker_images(self, artifacts: list) -> None: """Put each DockerImageArtifact's image on the VM: those with a tar.gz are loaded from it, in parallel, those - with only a build context are built from it, and the rest are pulled by image name. An image no sandbox can get - is refused before anything is loaded.""" + with only a build context are built from it, and the rest are pulled by image name. An image this VM can't get, + such as one to pull from a registry on this machine when the VM isn't on it, or can't run, being built for + another platform than its own, is refused before anything is loaded.""" if not artifacts: return - if problems := [problem for artifact in artifacts if (problem := artifact.load_problem())]: + if problems := self.load_problems(artifacts): raise RuntimeError(f"Can't load images: {'; '.join(problems)}") if tarballs := [artifact for artifact in artifacts if artifact.tar_gz_object_url]: await self._load_tarballs(tarballs) @@ -304,6 +309,13 @@ async def load_docker_images(self, artifacts: list) -> None: if built := [artifact for artifact in artifacts if artifact.context_only]: await self.build_images(built) + def load_problems(self, artifacts: list) -> list[str]: + """Why this VM can't get or run each DockerImageArtifact it can't, as ``load_docker_images`` refuses them: one + to pull from a registry on this machine when the VM isn't on it, one with no tar.gz whose name names no + registry, or one built for another platform than its own.""" + return [problem for artifact in artifacts + if (problem := artifact.load_problem(on_this_machine=self.ON_THIS_MACHINE, platform=self.platform))] + async def build_images(self, artifacts: list) -> None: """Build each context-only image from its build context, one build at a time, so heavy builds don't contend for one VM; images with the same ``source_digest`` share a build. The build pulls its base images and packages @@ -311,14 +323,16 @@ async def build_images(self, artifacts: list) -> None: builds: dict[str, list] = {} for artifact in artifacts: builds.setdefault(artifact.source_digest or artifact.build_context_object_url, []).append(artifact) - for source, images in builds.items(): - await self._build_image(source, images) + for images in builds.values(): + await self._build_image(images) - async def _build_image(self, source: str, images: list) -> None: - """Download the context ``images`` share, extract it, and ``docker build`` it, tagged with each image's name.""" + async def _build_image(self, images: list) -> None: + """Download the context ``images`` share, extract it, and ``docker build`` it, tagged with each image's name. + The work folder is this build's own, since a host can run builds of one context at once: two of the local + provider's sandboxes, or two deploys onto one VM.""" first = images[0] names = list(dict.fromkeys(image.image_name for image in images)) - work = f"/tmp/agent-env-build-{hashlib.sha256(source.encode()).hexdigest()[:16]}" + work = f"/tmp/agent-env-build-{uuid.uuid4().hex}" platform = first.platform or DEFAULT_BUILD_PLATFORM tags = " ".join(f"-t {shlex.quote(name)}" for name in names) logger.info(f"Building {', '.join(names)} from its build context, for {platform}...") diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 994d96fc..5df448c7 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -5,7 +5,7 @@ import logging import os import shlex -from abc import ABC, abstractmethod +from abc import ABC from enum import Enum from typing import TYPE_CHECKING, Any, ClassVar, Optional, Self from urllib.parse import urlparse @@ -44,19 +44,19 @@ class Accepts(Enum): NAME = "name" # one it starts by its name, which an image that is only a build context gets only from a build NAME_OR_CONTEXT = "name_or_context" # one it starts by its name, or a build context it builds first (prepare_image) - def problem(self, image: DockerImageArtifact) -> str | None: - """Why a provider that accepts these can't run ``image``, or None when it can.""" + def problem(self, image: DockerImageArtifact, *, on_this_machine: bool) -> str | None: + """Why a provider that accepts these, whose sandboxes run ``on_this_machine`` or elsewhere, can't run ``image``, + 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() - if self is Accepts.NAME: - return image.by_name_problem() - return None + return image.load_problem(on_this_machine=on_this_machine) + if self is Accepts.NAME_OR_CONTEXT and image.context_only: + return None # the provider builds it + return image.by_name_problem(on_this_machine=on_this_machine) class SandboxProvider(ABC): """Compute backend that provisions sandboxes. Selected via [sandbox] default / build_sandbox_provider(); swap via set_sandbox_provider().""" - @abstractmethod async def create_sandbox( self, *, @@ -69,7 +69,8 @@ async def create_sandbox( timeout: int = 3600 * 2, attribution: Optional[Attribution] = None, network_policy: Optional[NetworkPolicy] = None, - ) -> Sandbox: ... + ) -> Sandbox: + raise NotImplementedError(f"{type(self).__name__} does not support create_sandbox") async def create_vm( self, @@ -166,19 +167,27 @@ def from_config(cls, **config: Any) -> Self: URL_REWRITES: ClassVar[dict[str, str]] = {} # What it declares, which nothing outside the providers reads any other way. A provider that declares nothing runs - # each image from its name, in a sandbox that isn't on this machine, and creates no VM and no private network. + # each image from its name, in a sandbox that isn't on this machine, on linux/amd64, and creates no VM and no + # private network. # # Whether its sandboxes run on this machine: an image only this machine holds (in a registry or object store on # it) reaches them, its containers need this machine's Docker, and a gateway's infra envs are built here for it. ON_THIS_MACHINE: ClassVar[bool] = False - # Whether create_vm gives a VM sandbox, as a provider that implements it does. - CREATES_VMS: ClassVar[bool] = False + # The sandbox modes it creates, derived for each provider class from what it implements (``__init_subclass__``): + # SANDBOX_MODE_CONTAINER when it implements create_sandbox, SANDBOX_MODE_VM when it implements create_vm. + SUPPORTED_SANDBOX_MODES: ClassVar[frozenset[str]] = frozenset() # The images create_sandbox and create_container run. SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.NAME CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME # Whether create_container(private_network=True) puts containers on a network they share. PRIVATE_NETWORK: ClassVar[bool] = False + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + cls.SUPPORTED_SANDBOX_MODES = frozenset( + mode for mode, method in ((SANDBOX_MODE_CONTAINER, "create_sandbox"), (SANDBOX_MODE_VM, "create_vm")) + if getattr(cls, method) is not getattr(SandboxProvider, method)) + @property def links(self) -> tuple[SandboxProvider, ...]: """The providers a deploy on this one may run on, in the order it tries them: itself, or a chain's.""" @@ -300,11 +309,18 @@ def _build_registry(source: Config | None = None) -> dict[str, dict]: def _validate_plugin(name: str, loaded: Any) -> type[SandboxProvider]: cls = _registration.require_subclass(loaded, SandboxProvider) - if problem := _registration.unimplemented(cls, SandboxProvider): + if problem := _unimplemented(cls): raise TypeError(problem) return cls +def _unimplemented(cls: type[SandboxProvider]) -> str | None: + """What ``cls`` still has to implement to work as a sandbox provider, if anything: a sandbox mode at least, + create_sandbox or create_vm.""" + return _registration.unimplemented(cls, SandboxProvider) or ( + None if cls.SUPPORTED_SANDBOX_MODES else f"{cls.__qualname__} must implement create_sandbox or create_vm") + + def _get_sandbox_registry() -> dict[str, dict]: from agent_env.config import runtime @@ -356,7 +372,7 @@ def _merge_config_toml_sandbox_providers( if not impl: raise ConfigError(f"config.toml sandbox provider {name!r} is missing an 'impl' dotted path") cls = load_impl(impl, SandboxProvider) - if problem := _registration.unimplemented(cls, SandboxProvider): + if problem := _unimplemented(cls): raise ConfigError(f"{where} impl {impl!r}: {problem}") if from_plugins.refuse(name, where): continue diff --git a/src/agent_env/task_step/task_steps/deploy_agent.py b/src/agent_env/task_step/task_steps/deploy_agent.py index 9555458a..4cc81176 100644 --- a/src/agent_env/task_step/task_steps/deploy_agent.py +++ b/src/agent_env/task_step/task_steps/deploy_agent.py @@ -3,6 +3,7 @@ import asyncio import logging +from collections.abc import Iterable from typing import ClassVar, Optional import httpx @@ -28,7 +29,12 @@ from agent_env.env.env import DeployedEnv, DeployedSandboxEnv, Env from agent_env.env.gateway import AGENT_ENV_ROLE_HEADER from agent_env.providers.sandbox_providers.local_sandbox import host_url_for -from agent_env.providers.sandbox_providers.sandbox_provider import reachable_url, sandbox_request_headers_for_url +from agent_env.a2a_agent.a2a_agent import DEFAULT_A2A_PORT +from agent_env.providers.sandbox_providers.sandbox_provider import ( + SANDBOX_MODE_VM, + reachable_url, + sandbox_request_headers_for_url, +) from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.entity_refs import EntityRef @@ -39,6 +45,18 @@ logger = logging.getLogger(__name__) +def linked_sandbox_problem(sandbox_name: str, sandbox_mode: str, ports: Iterable[int | str]) -> str | None: + """Why an agent can't be placed on the sandbox ``sandbox_name``, deployed in ``sandbox_mode`` with ``ports`` + reachable, or None when it can: only a VM can host another container, and the agent is reached on its A2A port.""" + if sandbox_mode != SANDBOX_MODE_VM: + return (f"Cannot link agent to sandbox '{sandbox_name}' (mode={sandbox_mode!r}); only VM-mode sandboxes can " + "host an additional agent container") + if str(DEFAULT_A2A_PORT) not in {str(port) for port in ports}: + return (f"Sandbox '{sandbox_name}' does not expose port {DEFAULT_A2A_PORT}; re-deploy DeploySandboxTaskStep " + f"with exposed_ports=[{DEFAULT_A2A_PORT}]") + return None + + def _mcp_add_request(mcp_ext: dict) -> dict: """The request contract the card declares for mcp-config `add`.""" request = mcp_ext.get("params", {}).get("methods", {}).get("add", {}).get("request", {}) @@ -252,9 +270,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: async def _execute(self, context: TaskStepContext, deployed_agents: list) -> TaskStepContext: from agent_env.a2a_agent import A2AAgent - from agent_env.a2a_agent.a2a_agent import DEFAULT_A2A_PORT from agent_env.providers.sandbox_providers.sandbox_provider import ( - SANDBOX_MODE_VM, build_sandbox_provider, get_sandbox_provider, ) @@ -274,16 +290,8 @@ async def _execute(self, context: TaskStepContext, deployed_agents: list) -> Tas f"Sandbox '{self.sandbox_name}' not found in context.deployed_sandboxes; " f"deploy it with DeploySandboxTaskStep before this step" ) - if ds.sandbox_mode != SANDBOX_MODE_VM: - raise RuntimeError( - f"Cannot link agent to sandbox '{self.sandbox_name}' (mode={ds.sandbox_mode!r}); " - f"only VM-mode sandboxes can host an additional agent container" - ) - if not ds.tunnel_urls or str(DEFAULT_A2A_PORT) not in ds.tunnel_urls: - raise RuntimeError( - f"Sandbox '{self.sandbox_name}' does not expose port {DEFAULT_A2A_PORT}; " - f"re-deploy DeploySandboxTaskStep with exposed_ports=[{DEFAULT_A2A_PORT}]" - ) + if problem := linked_sandbox_problem(self.sandbox_name, ds.sandbox_mode, ds.tunnel_urls or ()): + raise RuntimeError(problem) provider = ( build_sandbox_provider(ds.sandbox_type) if ds.sandbox_type else get_sandbox_provider() ) diff --git a/src/agent_env/task_step/task_steps/deploy_sandbox.py b/src/agent_env/task_step/task_steps/deploy_sandbox.py index 46a90237..f0d8cd37 100644 --- a/src/agent_env/task_step/task_steps/deploy_sandbox.py +++ b/src/agent_env/task_step/task_steps/deploy_sandbox.py @@ -3,13 +3,18 @@ from __future__ import annotations import logging -from typing import ClassVar, Optional +from typing import TYPE_CHECKING, ClassVar, Optional +from agent_env.artifact.artifacts.docker_image import pull_problem +from agent_env.providers.sandbox_providers.chained_sandbox_provider import skip_refusing from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy from agent_env.task_step.context import DeployedSandbox, TaskStepContext from agent_env.task_step.task_step import TaskStep, TaskStepDependency from agent_env.attribution import deploy_attribution +if TYPE_CHECKING: + from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider + logger = logging.getLogger(__name__) _SUPPORTED_MODES = {"vm", "container"} @@ -96,6 +101,12 @@ def from_dict(cls, data: dict) -> DeploySandboxTaskStep: network_policy=data.get("network_policy"), ) + def _pull_refusal(self, link: SandboxProvider) -> str | None: + """Why ``link``'s sandboxes can't pull this step's image by name, as a clause on its name, or None when they can.""" + if problem := pull_problem(self.image, on_this_machine=link.ON_THIS_MACHINE): + return f"whose sandboxes pull it by name: {problem}" + return None + async def execute(self, context: TaskStepContext) -> TaskStepContext: from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider, get_sandbox_provider @@ -140,6 +151,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: else: if self.image is None or self.port is None: raise ValueError("container-mode sandbox requires `image` and `port`") + provider = skip_refusing(provider, self._pull_refusal, what=f"sandbox '{self.sandbox_name}'") sandbox = await provider.create_sandbox( image_name=self.image, port=self.port, diff --git a/src/agent_env/task_step/task_steps/install_agent.py b/src/agent_env/task_step/task_steps/install_agent.py index 1c5f6b6b..b38157f4 100644 --- a/src/agent_env/task_step/task_steps/install_agent.py +++ b/src/agent_env/task_step/task_steps/install_agent.py @@ -21,7 +21,9 @@ import httpx +from agent_env.a2a_agent import A2AAgent from agent_env.providers.sandbox_providers.local_sandbox import host_url_for +from agent_env.store.base import NotFoundError from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.entity_refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -34,6 +36,16 @@ REQUIRED_PARAMS_HOST_KEY = "required_params_host" A2A_PORT_KEY = "a2a_port" + +def _install_problem(agent: A2AAgent) -> str | None: + """Why ``agent`` can't be installed, or None: install/v1 copies the agent's source files from its image's build + context, which an image registered by reference has none of.""" + if agent.docker_image_artifact.build_context_object_url: + return None + return (f"Agent {agent.id} v{agent.version} has no build_context_object_url on its docker_image_artifact; " + "install/v1 needs the build context (agent source files) to install into the task container") + + def _build_param_resolvers( *, container_name: Optional[str], @@ -140,8 +152,17 @@ def from_dict(cls, data: dict) -> InstallAgentTaskStep: workspace_dir=data.get("workspace_dir"), ) + def preflight(self) -> list[str]: + """An agent with no build context to install, reported at save instead. One the store can't give is left to the + run, whose ``a2a_agent_id`` override can name another, so a task saved naming a placeholder still saves.""" + try: + agent = A2AAgent.get(self.a2a_agent_id, self.a2a_agent_version) + except (NotFoundError, ValueError, KeyError, TypeError): + return [] + problem = _install_problem(agent) + return [f"install_agent '{self.id}': {problem}"] if problem else [] + async def execute(self, context: TaskStepContext) -> TaskStepContext: - from agent_env.a2a_agent import A2AAgent from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, build_sandbox_provider, @@ -187,11 +208,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: user_overrides = context.metadata.get("user_overrides", {}) a2a_agent_id = user_overrides.get("a2a_agent_id") or self.a2a_agent_id agent = A2AAgent.get(a2a_agent_id, self.a2a_agent_version) - if not agent.docker_image_artifact.build_context_object_url: - raise RuntimeError( - f"Agent {agent.id} v{agent.version} has no build_context_object_url on its docker_image_artifact; " - f"install/v1 needs the build context (agent source files) to install into the task container" - ) + if problem := _install_problem(agent): + raise RuntimeError(problem) logger.info(f"Loading agent image {agent.docker_image_artifact.image_name} onto VM (needed to read agent card)...") await sandbox.load_docker_images([agent.docker_image_artifact]) diff --git a/src/agent_env/utils/docker_build.py b/src/agent_env/utils/docker_build.py index 870fbd36..aedeae8a 100644 --- a/src/agent_env/utils/docker_build.py +++ b/src/agent_env/utils/docker_build.py @@ -11,6 +11,22 @@ DOCKER_INFO_TIMEOUT_SECONDS = 10 +# Docker's names for architectures that hosts and tools also call otherwise. +_ARCHITECTURES = {"x86_64": "amd64", "x86-64": "amd64", "aarch64": "arm64", "i386": "386"} + + +def normalize_platform(platform: str) -> str: + """``platform`` as ``docker build --platform`` names it, so the same platform compares equal however it's spelled: + lower case, the architecture by Docker's name for it, and no variant but 32-bit arm's, whose v6 and v7 differ, so an + amd64 or arm64 image is known by its architecture alone.""" + os_name, _, rest = platform.strip().lower().partition("/") + architecture, _, variant = rest.partition("/") + architecture = _ARCHITECTURES.get(architecture, architecture) + if architecture == "arm" and variant: + return f"{os_name}/{architecture}/{variant}" + return f"{os_name}/{architecture}" if architecture else os_name + + class DockerBuildError(RuntimeError): """A local ``docker build`` that failed or couldn't start; the message carries docker's output.""" diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index 9469736b..63e58bae 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -1,5 +1,7 @@ """An agent whose image is only a build context deploys only where it's built, in a VM or by Modal: a provider that runs an -agent in a container by its image's name refuses it before creating anything, and Modal is told of it first.""" +agent in a container by its image's name refuses it before creating anything, and Modal is told of it first. An image a +provider can't get otherwise, such as one a sandbox elsewhere would pull from this machine's registry, is refused before +anything is created too.""" import pytest @@ -22,7 +24,6 @@ async def create_sandbox(self, **_): class _VmProvider(SandboxProvider): - CREATES_VMS = True SANDBOX_ACCEPTS = Accepts.LOADABLE async def create_sandbox(self, **_): @@ -59,16 +60,32 @@ def litellm(monkeypatch): monkeypatch.setenv("LITELLM_BASE_URL", "http://litellm.example/v1") +BY_NAME = ("with _LocalProvider, which runs an agent's image by name: 'solver-image' v2 is only a build context, which has " + "to be built, not pulled") + + @pytest.mark.asyncio -@pytest.mark.parametrize("provider", [_LocalProvider(), ChainedSandboxProvider([_VmProvider(), _LocalProvider()])], - ids=["local", "chain-with-local"]) -async def test_a_provider_that_runs_the_agent_by_name_refuses_it(monkeypatch, local_stores, litellm, provider): +@pytest.mark.parametrize("provider, refusal", [ + (_LocalProvider(), BY_NAME), + (ChainedSandboxProvider([_LocalProvider(), _LocalProvider()]), f"{BY_NAME}; nor {BY_NAME}"), +], ids=["local", "chain-of-locals"]) +async def test_a_provider_that_runs_the_agent_by_name_refuses_it(monkeypatch, local_stores, litellm, provider, refusal): monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider) - with pytest.raises(ValueError, match="Can't deploy agent 'solver' with _LocalProvider, which runs an agent's image by " - "name: 'solver-image' v2 is only a build context, which has to be built, not " - "pulled"): + with pytest.raises(ValueError, match=f"Can't deploy agent 'solver' {refusal}"): + await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + + +@pytest.mark.asyncio +async def test_a_chain_skips_a_provider_that_runs_the_agent_by_name_for_one_that_builds_it(monkeypatch, local_stores, + litellm, caplog): + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", + lambda name: ChainedSandboxProvider([_LocalProvider(), _VmProvider()])) + + with pytest.raises(RuntimeError, match=r"All 1 providers failed: _VmProvider: _Created\(\)"): await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + assert ("sandbox_provider_skipped provider=_LocalProvider: which runs an agent's image by name: 'solver-image' v2 is " + "only a build context") in caplog.text @pytest.mark.asyncio @@ -129,3 +146,47 @@ async def test_an_existing_container_sandbox_refuses_it_since_it_never_runs_the_ with pytest.raises(ValueError, match="Can't deploy agent 'solver' on the container sandbox 'sb-container', which runs its " "own image, never the agent's: 'solver-image' v2 is only a build context"): await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox=_ContainerSandbox()) + + +PULLED_HERE = DockerImageArtifact(id="solver-image", version=2, description="d", + image_name="localhost:5000/local/solver:v2") +HERE = "localhost:5000/local/solver:v2 is in a registry on this machine, which a sandbox elsewhere can't pull from" +NO_REGISTRY = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="solver:v2") + + +@pytest.mark.asyncio +async def test_an_existing_container_sandbox_elsewhere_takes_an_agent_whose_image_is_in_this_machines_registry( + local_stores, litellm): + """It pulls nothing, so where the agent's image is doesn't matter: the deploy goes on to the container's card.""" + with pytest.raises(RuntimeError, match="No tunnel URL for port"): + await A2AAgent(id="solver", version=1, docker_image_artifact=PULLED_HERE).deploy(sandbox=_ContainerSandbox()) + + +class _LocalPulls(LocalSandboxProvider): + async def create_sandbox(self, **_): + raise _Created + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider, image, refusal", [ + (_VmProvider(), PULLED_HERE, f"with _VmProvider, whose VMs can't run it: {HERE}"), + (_ModalProvider(), PULLED_HERE, f"with _ModalProvider, which runs an agent's image by name: {HERE}"), + (_VmProvider(), NO_REGISTRY, "with _VmProvider, whose VMs can't run it: 'solver-image' v2 has no tar.gz, and its " + "image name 'solver:v2' doesn't name a registry to pull it from"), +], ids=["vm-elsewhere", "modal", "vm-nothing-to-pull"]) +async def test_an_image_a_provider_cant_get_is_refused_before_anything_is_created(monkeypatch, local_stores, litellm, + provider, image, refusal): + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider) + + with pytest.raises(ValueError, match=f"Can't deploy agent 'solver' {refusal}"): + await A2AAgent(id="solver", version=1, docker_image_artifact=image).deploy(sandbox_type="any") + assert getattr(provider, "steps", []) == [] + + +@pytest.mark.asyncio +async def test_the_local_provider_is_asked_for_a_sandbox_for_an_image_in_its_own_registry(monkeypatch, local_stores, + litellm): + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: _LocalPulls()) + + with pytest.raises(_Created): + await A2AAgent(id="solver", version=1, docker_image_artifact=PULLED_HERE).deploy(sandbox_type="any") diff --git a/tst/unit/artifact/test_docker_image_build_context.py b/tst/unit/artifact/test_docker_image_build_context.py index a39ae469..5662cc9b 100644 --- a/tst/unit/artifact/test_docker_image_build_context.py +++ b/tst/unit/artifact/test_docker_image_build_context.py @@ -69,8 +69,8 @@ def test_equal_sources_give_equal_digests_and_bytes(local_stores, context): def test_a_context_only_image_is_built_and_refused_where_run_by_name(local_stores, context): image = DockerImageArtifact.put_context("a", description="d", context_path=str(context), dockerfile_path="Dockerfile") - assert image.context_only and image.load_problem() is None - assert image.by_name_problem() == "'a' v1 is only a build context, which has to be built, not pulled" + assert image.context_only and image.load_problem(on_this_machine=False) is None + assert image.by_name_problem(on_this_machine=False) == "'a' v1 is only a build context, which has to be built, not pulled" with pytest.raises(ValueError, match="has no tar.gz; its image is built from its build context"): image.load() @@ -165,3 +165,13 @@ def test_put_records_the_platform_it_built_for_when_none_is_given(local_stores, assert (native.platform, explicit.platform) == ("linux/arm64", "linux/amd64") assert native.source_digest != explicit.source_digest + + + +def test_a_put_records_the_platform_as_docker_build_names_it(local_stores, context): + image = DockerImageArtifact.put_context("a", description="d", context_path=str(context), dockerfile_path="Dockerfile", + platform="linux/arm64/v8") + named = DockerImageArtifact.put_context("b", description="d", context_path=str(context), dockerfile_path="Dockerfile", + platform="linux/arm64") + + assert image.platform == "linux/arm64" and image.source_digest == named.source_digest diff --git a/tst/unit/artifact/test_docker_image_pulled.py b/tst/unit/artifact/test_docker_image_pulled.py index 8c37eee9..ac3166e9 100644 --- a/tst/unit/artifact/test_docker_image_pulled.py +++ b/tst/unit/artifact/test_docker_image_pulled.py @@ -16,7 +16,7 @@ def test_a_document_with_no_tar_gz_is_read(document): image = DockerImageArtifact.model_validate(document) assert image.tar_gz_object_url is None - assert image.load_problem() is None + assert image.load_problem(on_this_machine=False) is None def test_one_is_written_with_both_spellings_of_its_tar_gz_empty_and_reads_back_the_same(): @@ -39,7 +39,23 @@ def test_one_is_written_with_both_spellings_of_its_tar_gz_empty_and_reads_back_t def test_an_image_loads_from_its_tar_gz_or_by_pulling_a_name_that_names_its_registry(image_name, tar_gz, problem): image = DockerImageArtifact(id="img", version=1, description="d", image_name=image_name, tar_gz_object_url=tar_gz) - assert image.load_problem() == problem + assert image.load_problem(on_this_machine=True) == problem + + +@pytest.mark.parametrize("image_name", ["localhost:5000/img:v1", "127.0.0.1:5000/img:v1"]) +def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry(image_name): + image = DockerImageArtifact(id="img", version=1, description="d", image_name=image_name) + here = f"{image_name} is in a registry on this machine, which a sandbox elsewhere can't pull from" + + assert (image.load_problem(on_this_machine=True), image.by_name_problem(on_this_machine=True)) == (None, None) + assert image.load_problem(on_this_machine=False) == image.by_name_problem(on_this_machine=False) == here + + +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") + + assert image.load_problem(on_this_machine=False) is None def test_one_with_no_tar_gz_has_no_bytes_to_load(): diff --git a/tst/unit/artifact/test_docker_image_put_ref.py b/tst/unit/artifact/test_docker_image_put_ref.py index 3ec15635..480fb6f5 100644 --- a/tst/unit/artifact/test_docker_image_put_ref.py +++ b/tst/unit/artifact/test_docker_image_put_ref.py @@ -31,7 +31,7 @@ def test_a_ref_is_registered_with_its_tag_pinned_and_no_tarball(local_stores, re stored = DockerImageArtifact.get("tool-image", image.version) assert (stored.version, stored.image_name, stored.tar_gz_object_url) == (1, f"ghcr.io/org/tool:v1@{DIGEST}", None) - assert stored.load_problem() is None + assert stored.load_problem(on_this_machine=False) is None assert DockerImageArtifact.put_ref("tool-image", description="d", image_name="ghcr.io/org/tool:v1").version == 2 diff --git a/tst/unit/bundle/preflight_test.py b/tst/unit/bundle/preflight_test.py index 507c8a3e..cae607f2 100644 --- a/tst/unit/bundle/preflight_test.py +++ b/tst/unit/bundle/preflight_test.py @@ -1,6 +1,7 @@ -"""Where a bundle run's tasks deploy, checked before anything is written: images only this machine has on another -provider, VMs asked of a provider that can't create one, containers on the local provider without Docker, the default -agent, and the infra envs a gateway deploy on the local provider needs.""" +"""Where a bundle run's tasks deploy, checked before anything is written: images a provider can't run as it runs them +(one a sandbox elsewhere would pull from this machine's registry among them), VMs asked of a provider that can't create +one, containers on the local provider without Docker, the default agent, and the infra envs a gateway deploy on the +local provider needs.""" import asyncio import json @@ -39,6 +40,9 @@ LOCAL = ("localhost:5000/local/img:v1", "file:///state/img-v1.tar.gz") REMOTE = ("123456789012.dkr.ecr.us-west-2.amazonaws.com/img:v1", "s3://bucket/img-v1.tar.gz") +PULLED_HERE = ("localhost:5000/local/img:v1", None) # no tar.gz, so a sandbox pulls it from this machine's registry +ARM64_HERE = (*LOCAL, "linux/arm64") # built here on Apple silicon, as the infra bootstrap builds it +HERE = "localhost:5000/local/img:v1 is in a registry on this machine, which a sandbox elsewhere can't pull from" @pytest.fixture @@ -61,9 +65,10 @@ def _task(root, steps, name="t"): def _image(id, where=LOCAL): + """An image named ``where[0]``, its tar.gz at ``where[1]``, recording ``where[2]``'s platform when given.""" with namespace_routing(): return get_artifact_store().put_document(DockerImageArtifact( - id=id, description=id, image_name=where[0], tar_gz_s3_url=where[1])) + id=id, description=id, image_name=where[0], tar_gz_s3_url=where[1], platform=(where[2:] or (None,))[0])) def _agent(id, where=LOCAL): @@ -102,25 +107,63 @@ def _problems(fn): # Images only this machine has -@pytest.mark.parametrize("sandbox", ["modal", "local,modal"]) -def test_an_image_only_this_machine_has_is_refused_on_another_provider_before_anything_is_written(bundle_dir, sandbox): +def test_an_image_in_this_machines_registry_is_refused_where_a_sandbox_elsewhere_pulls_it_before_anything_is_written( + bundle_dir): _agent("solver") _task(bundle_dir, [AGENT]) - assert _problems(lambda: run_bundle(bundle_dir, sandbox=sandbox)) == [ - "tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal' sandbox provider, which can't reach it: " - "localhost:5000/local/img:v1 is in a registry on this machine; run it with --sandbox local", + assert _problems(lambda: run_bundle(bundle_dir, sandbox="modal")) == [ + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal' sandbox provider: {HERE}; run it " + "with --sandbox local, or on a provider whose VMs load its tar.gz, such as --sandbox modal_vm", ] assert not local_store().path.exists() -def test_an_image_saved_only_in_this_machines_object_store_is_refused_too(bundle_dir): +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]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal,local").runs] == ["t"] + + +@pytest.mark.parametrize("where", [LOCAL, (REMOTE[0], LOCAL[1])]) +def test_a_vm_elsewhere_loads_a_tar_gz_only_this_machine_holds_which_agent_env_pushes_it(bundle_dir, where): + """It never pulls the image, so a registry on this machine doesn't matter; the tar.gz goes over the VM's exec.""" + _agent("solver", where) + _task(bundle_dir, [AGENT]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal_vm").runs] == ["t"] + + +@pytest.mark.parametrize("sandbox, refused", [("modal_vm", True), ("modal", True), ("local", False)]) +def test_an_image_with_no_tar_gz_in_this_machines_registry_runs_only_on_the_local_provider(bundle_dir, sandbox, refused): + _agent("solver", PULLED_HERE) + _task(bundle_dir, [AGENT]) + + if refused: + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the {sandbox!r} sandbox provider: {HERE}; " + "run it with --sandbox local"] + else: + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox=sandbox).runs] == ["t"] + + +@pytest.mark.parametrize("sandbox", ["modal", "modal_vm"]) +def test_a_lone_server_runs_its_image_by_name_wherever_it_deploys_so_no_vm_provider_is_offered(bundle_dir, sandbox): + """A VM provider's containers pull by name too: only an agent or a gateway loads a tar.gz in a VM.""" + _env("crm", LOCAL, env_provider_type="server") + _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ + f"tasks/t.json: step 'env': deploys env 'crm''s image 'crm-image' on the {sandbox!r} sandbox provider: {HERE}; " + "run it with --sandbox local"] + + +def test_a_remote_image_with_a_tar_gz_only_this_machine_holds_is_pulled_where_a_sandbox_runs_it_by_name(bundle_dir): _agent("solver", (REMOTE[0], LOCAL[1])) _task(bundle_dir, [AGENT]) - (problem,) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) - assert problem.endswith("can't reach it: 'solver-image' is saved in this machine's object store; run it with " - "--sandbox local") + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal").runs] == ["t"] def test_a_remote_image_runs_anywhere_and_a_local_one_on_the_local_provider(bundle_dir): @@ -223,8 +266,9 @@ def test_a_sandbox_image_in_this_machines_registry_is_refused_on_another_provide "image": "python:3.12"}]) assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) == [ - "tasks/t.json: step 'box': deploys the image localhost:5000/box:v1 on the 'modal' sandbox provider, which can't " - "reach it: localhost:5000/box:v1 is in a registry on this machine; run it with --sandbox local", + "tasks/t.json: step 'box': deploys the image localhost:5000/box:v1 on the 'modal' sandbox provider: " + "localhost:5000/box:v1 is in a registry on this machine, which a sandbox elsewhere can't pull from; run it with " + "--sandbox local", ] @@ -287,17 +331,16 @@ def _context_image(id): @pytest.mark.parametrize("sandbox, refused", [("modal_vm", False), ("modal", False), ("modal_vm,modal", False), - ("local", True), ("modal_vm,local", True)]) + ("local", True), ("modal_vm,local", False)]) def test_an_agent_image_that_is_only_a_build_context_runs_where_a_vm_or_modal_builds_it(bundle_dir, sandbox, refused): + """A chain skips the local provider, which runs an image by name, so one that builds it is enough.""" with namespace_routing(): A2AAgent.put(id="solver", docker_image_artifact=_context_image("solver-image")) _task(bundle_dir, [AGENT]) if refused: - link = "local" if "local" in sandbox else sandbox assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) == [ - "tasks/t.json: step 'agent': deploys agent 'solver''s image " - + BY_NAME.format(sandbox=sandbox if link == sandbox else link, id="solver-image")] + "tasks/t.json: step 'agent': deploys agent 'solver''s image " + BY_NAME.format(sandbox=sandbox, id="solver-image")] else: assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox=sandbox).runs] == ["t"] @@ -343,6 +386,86 @@ def test_modal_builds_a_gateway_image_that_is_only_a_build_context_but_not_a_ser "built, not pulled; run it on a provider whose VMs build it, such as --sandbox modal_vm"] +# Images built for another platform than the sandboxes run, which a VM refuses as it loads them + + +def test_the_dry_run_leaves_an_images_platform_to_the_vm_that_loads_it(bundle_dir): + """No provider says beforehand what its sandboxes run; a sandbox carries its platform once it exists.""" + _agent("solver", ARM64_HERE) + _task(bundle_dir, [AGENT]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal_vm").runs] == ["t"] + + +def test_a_vm_provider_isnt_offered_for_an_image_its_vms_wouldnt_run(bundle_dir): + """Modal can't pull from this machine's registry; a VM would load the tar.gz, but it's for linux/arm64.""" + _agent("solver", ARM64_HERE) + _task(bundle_dir, [AGENT]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal")) == [ + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal' sandbox provider: {HERE}; run it with " + "--sandbox local"] + + +# Agents placed on a sandbox the task deploys + +BOX = {"id": "box", "type": "deploy_sandbox", "sandbox_name": "box", "sandbox_mode": "vm", "exposed_ports": [8000]} +PLACED = {**AGENT, "sandbox_name": "box"} + + +@pytest.mark.parametrize("sandbox", ["local", "modal_vm"]) +def test_an_agent_placed_on_a_vm_sandbox_runs_any_image_that_vm_loads(bundle_dir, sandbox): + """The VM loads the agent's image, so one that is only a build context runs there, on the local provider too, whose + agents run by image name only on sandboxes of their own.""" + with namespace_routing(): + A2AAgent.put(id="solver", docker_image_artifact=_context_image("solver-image")) + _task(bundle_dir, [BOX, PLACED]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox=sandbox).runs] == ["t"] + + +def test_an_agent_placed_on_a_vm_sandbox_elsewhere_is_refused_an_image_that_vm_cant_pull(bundle_dir): + _agent("solver", PULLED_HERE) + _task(bundle_dir, [BOX, PLACED]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) == [ + f"tasks/t.json: step 'agent': deploys agent 'solver''s image on the 'modal_vm' sandbox provider: {HERE}; run it " + "with --sandbox local"] + + +@pytest.mark.parametrize("box, problem", [ + ({**BOX, "sandbox_mode": "container", "image": "python:3.12", "port": 8000}, + "Cannot link agent to sandbox 'box' (mode='container'); only VM-mode sandboxes can host an additional agent " + "container"), + ({**BOX, "exposed_ports": [9000]}, + "Sandbox 'box' does not expose port 8000; re-deploy DeploySandboxTaskStep with exposed_ports=[8000]"), +], ids=["container", "no-a2a-port"]) +def test_an_agent_is_refused_a_sandbox_deploy_agent_cant_place_it_on_in_the_steps_own_words(bundle_dir, box, problem): + _agent("solver", REMOTE) + _task(bundle_dir, [box, PLACED]) + + assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) == [f"tasks/t.json: step 'agent': {problem}"] + + +# Preflight refuses with the words the deploy itself would + + +@pytest.mark.parametrize("where, sandbox", [(PULLED_HERE, "modal_vm"), (PULLED_HERE, "modal"), (LOCAL, "modal"), + (NO_REGISTRY, "modal_vm")]) +def test_preflight_refuses_an_agent_for_the_reason_its_deploy_would(bundle_dir, monkeypatch, where, sandbox): + """Both make the one check (``Accepts.problem``), so a deploy preflight passes isn't refused for its image.""" + agent = _agent("solver", where) + _task(bundle_dir, [AGENT]) + (problem,) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox=sandbox)) + monkeypatch.setenv("LITELLM_API_KEY", "k") + monkeypatch.setenv("LITELLM_BASE_URL", "http://litellm.example/v1") + + with pytest.raises(ValueError) as refused: + asyncio.run(agent.deploy(sandbox_type=sandbox)) + + assert str(refused.value).split(": ", 1)[1] in problem + + # What a provider can create @@ -385,6 +508,18 @@ def test_containers_on_the_local_provider_need_docker_and_a_vm_sandbox_doesnt(bu ] +def test_a_chain_that_skips_the_local_provider_doesnt_need_docker(bundle_dir, monkeypatch): + """The local provider runs an image by name, so a deploy of one that is only a build context skips it for a VM.""" + probes = [] + monkeypatch.setattr(preflight_module, "docker_unreachable", lambda: probes.append(1) or "docker isn't on PATH") + with namespace_routing(): + A2AAgent.put(id="solver", docker_image_artifact=_context_image("solver-image")) + _task(bundle_dir, [AGENT]) + + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal_vm,local").runs] == ["t"] + assert probes == [] + + class _DeploysItself(Env): """An env type with a deploy() of its own, as a plugin's env can have.""" @@ -556,12 +691,13 @@ def test_a_gateway_deploy_on_another_provider_needs_infra_it_can_reach(bundle_di (problem, *_) = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) assert problem.startswith("tasks/t.json: step 'env': deploys on the 'modal_vm' sandbox provider, which needs the " "gateway env 'default', and the store doesn't hold it") - _infra() + _infra(PULLED_HERE) problems = _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) assert problems[0] == ("tasks/t.json: step 'env': deploys the gateway env 'default''s image 'gateway-default' on the " - "'modal_vm' sandbox provider, which can't reach it: localhost:5000/local/img:v1 is in a " - "registry on this machine; run it with --sandbox local") + f"'modal_vm' sandbox provider: {HERE}; run it with --sandbox local") assert len(problems) == 4 # the gateway's image, and the service-db's three + _infra() # tar.gzs only this machine holds, which the gateway's VM loads once agent-env pushes them + assert [entry.name for entry in dry_run_bundle(bundle_dir, sandbox="modal_vm").runs] == ["t"] def test_infra_on_another_provider_with_no_tarball_and_no_registry_to_pull_it_from_is_refused(bundle_dir): @@ -579,17 +715,16 @@ def test_infra_on_another_provider_with_no_tarball_and_no_registry_to_pull_it_fr def test_a_problem_several_deploys_share_is_reported_once_naming_the_first(bundle_dir): - _env("crm") + _env("crm", PULLED_HERE) with namespace_routing(): - GatewayEnv.put(id="default", docker_image_artifact=_image("gateway-default")) - db = _image("db") # one image for all three of the service-db's, so each deploy finds its problem three times + GatewayEnv.put(id="default", docker_image_artifact=_image("gateway-default", PULLED_HERE)) + db = _image("db", PULLED_HERE) # one for all three of the service-db's, so each deploy finds its problem thrice ServiceDBEnv.put(id="default-db", db_docker_image_artifact=db, db_web_docker_image_artifact=db, db_mcp_docker_image_artifact=db) for name in ("a", "b", "c"): _task(bundle_dir, [{"id": "env", "type": "deploy_env", "env_id": "crm"}], name=name) - unreachable = ("on the 'modal_vm' sandbox provider, which can't reach it: localhost:5000/local/img:v1 is in a " - "registry on this machine; run it with --sandbox local") + unreachable = f"on the 'modal_vm' sandbox provider: {HERE}; run it with --sandbox local" assert _problems(lambda: dry_run_bundle(bundle_dir, sandbox="modal_vm")) == [ f"tasks/a.json: step 'env' (and 2 more deploys): deploys env 'crm''s image 'crm-image' {unreachable}", f"tasks/a.json: step 'env' (and 2 more deploys): deploys the gateway env 'default''s image 'gateway-default' " diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index 63d8dc84..79e483bb 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -48,12 +48,19 @@ EXTERNAL_STATE_TYPE, registered_external_provider, ) +from tst.util.sandbox_providers import CONTAINERS, VMS_AND_CONTAINERS def _image(image_name: str) -> DockerImageArtifact: return DockerImageArtifact(id=image_name, description="d", image_name=image_name) +def _loadable(image_name: str) -> DockerImageArtifact: + """An image a VM loads from its tar.gz, as a gateway's VM checks every image it runs before it's created.""" + return DockerImageArtifact(id=image_name, description="d", image_name=image_name, + tar_gz_object_url="s3://bucket/image.tar.gz") + + @pytest.fixture(autouse=True) def _fake_env_state_store(): """Keep acquire()'s persist step off a real backend — real store over an in-memory DocumentStore.""" @@ -102,9 +109,9 @@ async def write_file_from_text(self, content: str, destination_path: str) -> Non @pytest.mark.parametrize("facts, in_vm, accepts, by_name_otherwise", [ - ({"CREATES_VMS": True, "CONTAINER_ACCEPTS": Accepts.NAME}, True, Accepts.LOADABLE, Accepts.LOADABLE), - ({"CREATES_VMS": False, "CONTAINER_ACCEPTS": Accepts.NAME_OR_CONTEXT}, False, Accepts.NAME_OR_CONTEXT, Accepts.NAME), - ({"CREATES_VMS": False, "CONTAINER_ACCEPTS": Accepts.NAME}, False, Accepts.NAME, Accepts.NAME), + ({"SUPPORTED_SANDBOX_MODES": VMS_AND_CONTAINERS, "CONTAINER_ACCEPTS": Accepts.NAME}, True, Accepts.LOADABLE, Accepts.LOADABLE), + ({"SUPPORTED_SANDBOX_MODES": CONTAINERS, "CONTAINER_ACCEPTS": Accepts.NAME_OR_CONTEXT}, False, Accepts.NAME_OR_CONTEXT, Accepts.NAME), + ({"SUPPORTED_SANDBOX_MODES": CONTAINERS, "CONTAINER_ACCEPTS": Accepts.NAME}, False, Accepts.NAME, Accepts.NAME), ], ids=["vm", "containers-that-build", "containers-by-name"]) def test_a_gateway_runs_in_a_vm_where_its_provider_creates_them_and_else_in_containers(facts, in_vm, accepts, by_name_otherwise): @@ -118,10 +125,10 @@ def test_a_gateway_runs_in_a_vm_where_its_provider_creates_them_and_else_in_cont @pytest.mark.parametrize("facts, websites, problem", [ - ({"CREATES_VMS": True, "PRIVATE_NETWORK": False}, True, None), - ({"CREATES_VMS": False, "PRIVATE_NETWORK": True}, False, None), - ({"CREATES_VMS": False, "PRIVATE_NETWORK": True}, True, "they can't serve websites"), - ({"CREATES_VMS": False, "PRIVATE_NETWORK": False}, False, "can't put the gateway's containers on a private network"), + ({"SUPPORTED_SANDBOX_MODES": VMS_AND_CONTAINERS, "PRIVATE_NETWORK": False}, True, None), + ({"SUPPORTED_SANDBOX_MODES": CONTAINERS, "PRIVATE_NETWORK": True}, False, None), + ({"SUPPORTED_SANDBOX_MODES": CONTAINERS, "PRIVATE_NETWORK": True}, True, "they can't serve websites"), + ({"SUPPORTED_SANDBOX_MODES": CONTAINERS, "PRIVATE_NETWORK": False}, False, "can't put the gateway's containers on a private network"), ], ids=["vm-with-websites", "containers", "containers-with-websites", "containers-without-a-private-network"]) def test_a_gateway_in_containers_needs_their_private_network_and_serves_no_websites(facts, websites, problem): found = EnvironmentGatewayProvider.sandbox_problem(MagicMock(**facts), websites=websites) @@ -130,7 +137,7 @@ def test_a_gateway_in_containers_needs_their_private_network_and_serves_no_websi @pytest.mark.asyncio async def test_a_gateway_deploy_on_a_provider_with_no_vm_and_no_private_network_creates_nothing(): - provider = MagicMock(CREATES_VMS=False, PRIVATE_NETWORK=False, create_container=AsyncMock()) + provider = MagicMock(SUPPORTED_SANDBOX_MODES=CONTAINERS, PRIVATE_NETWORK=False, create_container=AsyncMock()) with pytest.raises(ValueError, match="private network"): await EnvironmentGatewayProvider()._deploy_gateway(provider, state_instance=None, mcp_servers=[], @@ -154,7 +161,7 @@ async def test_container_mode_rejects_websites(): def _vm_provider(**attrs) -> MagicMock: """A sandbox provider that creates VMs, where a gateway runs, as every VM provider does.""" - provider = MagicMock(ON_THIS_MACHINE=False, CREATES_VMS=True, SANDBOX_ACCEPTS=Accepts.LOADABLE, **attrs) + provider = MagicMock(ON_THIS_MACHINE=False, SUPPORTED_SANDBOX_MODES=VMS_AND_CONTAINERS, SANDBOX_ACCEPTS=Accepts.LOADABLE, **attrs) provider.links = (provider,) return provider @@ -190,6 +197,76 @@ async def build(image, *, attribution=None): gp._build_local_store.assert_not_awaited() +_IN_THIS_MACHINES_REGISTRY = DockerImageArtifact(id="slack-image", version=2, description="d", + image_name="localhost:5000/local/slack:v2") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("gateway_image, server_image", [(_IN_THIS_MACHINES_REGISTRY, _image("mcp-slack")), + (_image("agent-gateway"), _IN_THIS_MACHINES_REGISTRY)], + ids=["gateway", "server"]) +async def test_container_mode_refuses_an_image_in_this_machines_registry_before_preparing_anything(gateway_image, + server_image): + provider = ModalSandboxProvider() + gp = EnvironmentGatewayProvider() + gp._build_local_store = AsyncMock() + + with patch.object(provider, "prepare_image", side_effect=AssertionError("prepared an image")), \ + patch.object(provider, "create_container", side_effect=AssertionError("created a container")), \ + patch("agent_env.env.env.Env.get", return_value=MagicMock(docker_image_artifact=gateway_image)): + with pytest.raises(ValueError, match="Can't deploy a gateway on ModalSandboxProvider, which runs its images by " + "name: localhost:5000/local/slack:v2 is in a registry on this machine, " + "which a sandbox elsewhere can't pull from"): + await gp._deploy_via_containers( + sandbox_provider=provider, mcp_servers=[MCPServerConfig(image=server_image.image_name, environment_name="slack")], + mcp_server_images=[server_image], gateway_port=18765, website_configs=None, + gateway_mode=MagicMock(value="performance"), ttl_seconds=60, disk_size_gb=10, + ) + gp._build_local_store.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_container_mode_refuses_a_servicedb_image_in_this_machines_registry_before_preparing_anything(): + provider = ModalSandboxProvider() + gp = EnvironmentGatewayProvider() + gp._state_provider = MagicMock(spec=LocalPostgresStateProvider) + gp._state_provider.container_images.return_value = ["localhost:5000/local/servicedb:v1"] + + with patch.object(provider, "prepare_image", side_effect=AssertionError("prepared an image")), \ + patch("agent_env.env.env.Env.get", return_value=MagicMock(docker_image_artifact=_image("agent-gateway"))): + with pytest.raises(ValueError, match="localhost:5000/local/servicedb:v1 is in a registry on this machine"): + await gp._deploy_via_containers( + sandbox_provider=provider, mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], + mcp_server_images=[_image("mcp-slack")], gateway_port=18765, website_configs=None, + gateway_mode=MagicMock(value="performance"), ttl_seconds=60, disk_size_gb=10, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("unloadable", ["gateway", "server", "sidecar"]) +async def test_a_vm_gateway_refuses_an_image_its_vm_cant_get_before_creating_the_vm(unloadable): + """A VM elsewhere can't pull from this machine's registry; nothing is created to find that out.""" + from agent_env.env.gateway import GatewayMode + + here = DockerImageArtifact(id="here", description="d", image_name="localhost:5000/local/here:v1") + pick = {name: here if name == unloadable else _loadable(name) for name in ("gateway", "server", "sidecar")} + gp = EnvironmentGatewayProvider() + gp._state_provider = MagicMock() + provider = _vm_provider(create_vm=AsyncMock(side_effect=AssertionError("created a VM"))) + + with patch("agent_env.env.env.Env.get", return_value=MagicMock(docker_image_artifact=pick["gateway"])): + with pytest.raises(ValueError, match="whose VM can't run its images: localhost:5000/local/here:v1 is in a " + "registry on this machine"): + await gp._deploy_via_vm( + sandbox_provider=provider, mcp_servers=[MCPServerConfig(image="server", environment_name="slack")], + mcp_server_images=[pick["server"]], gateway_port=18765, website_configs=None, website_images=None, + gateway_mode=GatewayMode.PERFORMANCE, ttl_seconds=60, disk_size_gb=10, + sidecars=[SidecarConfig(environment_name="relay", image_artifact=pick["sidecar"], container_port=8000, + host_port=18768)], + ) + provider.create_vm.assert_not_awaited() + + @pytest.mark.asyncio async def test_container_mode_constructs_internal_mcp_servers_url_format(): """Container mode must pass INTERNAL_MCP_SERVERS as 'name=url,name=url' to the gateway.""" @@ -1091,7 +1168,7 @@ class _Stop(Exception): """Abort right after prepare — the rest of the VM deploy needs a real VM.""" def _fake_env_get(env_id, *a, **k): - env = MagicMock() + env = MagicMock(docker_image_artifact=_loadable(env_id)) env.environment_name = "website-browser" if env_id == "wb-id" else env_id return env @@ -1103,10 +1180,10 @@ def _fake_env_get(env_id, *a, **k): await gp._deploy_via_vm( sandbox_provider=sandbox_provider, mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], - mcp_server_images=[_image("mcp-slack")], + mcp_server_images=[_loadable("mcp-slack")], gateway_port=18765, website_configs=[WebsiteConfig(backend_image="b", frontend_image="f", environment_name="shop")], - website_images=[MagicMock(), MagicMock()], + website_images=[_loadable("b"), _loadable("f")], gateway_mode=GatewayMode.PERFORMANCE, ttl_seconds=60, disk_size_gb=10, @@ -1136,13 +1213,13 @@ async def test_deploy_via_vm_side_loads_sidecar_image(): sandbox_provider = MagicMock() sandbox_provider.create_vm = AsyncMock(return_value=sandbox) - sidecar_art = MagicMock(image_name="ecr/relay:1.0.4") + sidecar_art = _loadable("ecr/relay:1.0.4") class _Stop(Exception): pass def _fake_env_get(env_id, *a, **k): - env = MagicMock(); env.environment_name = env_id; return env + env = MagicMock(docker_image_artifact=_loadable(env_id)); env.environment_name = env_id; return env with patch("agent_env.env.env.Env.get", side_effect=_fake_env_get), \ patch("agent_env.config.get_config", return_value=MagicMock(default_gateway_env_id="gw-id")), \ @@ -1151,7 +1228,7 @@ def _fake_env_get(env_id, *a, **k): await gp._deploy_via_vm( sandbox_provider=sandbox_provider, mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], - mcp_server_images=[_image("mcp-slack")], + mcp_server_images=[_loadable("mcp-slack")], gateway_port=18765, website_configs=None, website_images=None, @@ -1570,13 +1647,13 @@ async def test_deploy_returns_the_gateway_record_for_the_env(caplog): state_provider = MagicMock(spec=DatabaseStateProvider, prepare=AsyncMock(), teardown=AsyncMock()) vm = MagicMock(sandbox_id="vm-1", type="modal_vm", tunnel_urls={18765: "https://vm.example"}, host_port=lambda port: port, host_ips=(), load_docker_images=AsyncMock(), exec_script=AsyncMock(), exec_with_output=AsyncMock(return_value=(0, "gateway", ""))) - env = MagicMock(id="crm-env", version=3, environment_name="slack", docker_image_artifact=MagicMock(image_name="mcp-slack")) + env = MagicMock(id="crm-env", version=3, environment_name="slack", docker_image_artifact=_loadable("mcp-slack")) card = {"name": "env1234", "children_environments": []} acquire = AsyncMock(return_value=external) with patch("agent_env.providers.env_state.acquire_state_for_deploy", acquire), \ patch("agent_env.providers.env_state.build_state_provider", return_value=state_provider), \ - patch("agent_env.env.env.Env.get", MagicMock()), \ + patch("agent_env.env.env.Env.get", MagicMock(return_value=MagicMock(docker_image_artifact=_loadable("gw")))), \ patch("agent_env.config.get_config", return_value=MagicMock(default_gateway_env_id="gw-id")), \ patch.object(gp, "create_docker_compose", return_value="services: {}") as compose, \ patch.object(gp, "_wait_for_gateway", AsyncMock(return_value=True)), \ diff --git a/tst/unit/providers/env_providers/env_server_provider_test.py b/tst/unit/providers/env_providers/env_server_provider_test.py index 451332ee..ed501a79 100644 --- a/tst/unit/providers/env_providers/env_server_provider_test.py +++ b/tst/unit/providers/env_providers/env_server_provider_test.py @@ -75,6 +75,20 @@ async def test_a_server_whose_image_is_only_a_build_context_is_refused_before_an assert run.calls == [] +_IN_THIS_MACHINES_REGISTRY = DockerImageArtifact(id="slack-image", version=3, description="d", + image_name="localhost:5000/local/slack:v3") + + +@pytest.mark.asyncio +async def test_a_server_whose_image_is_in_this_machines_registry_is_refused_by_a_provider_elsewhere(): + run = await _run_server_deploy(env=SimpleNamespace(**{**vars(_ENV), "docker_image_artifact": _IN_THIS_MACHINES_REGISTRY})) + + assert ("Can't deploy 'slack' as a server on _FakeSandboxProvider, which runs its image by name: " + "localhost:5000/local/slack:v3 is in a registry on this machine, which a sandbox elsewhere can't pull from" + ) in str(run.result) + assert run.calls == [] + + class _ModalProvider(ModalSandboxProvider): """Modal with its build and its container faked, recording the order they're asked for.""" diff --git a/tst/unit/providers/env_state/test_env_state_provider.py b/tst/unit/providers/env_state/test_env_state_provider.py index bac759bf..99507547 100644 --- a/tst/unit/providers/env_state/test_env_state_provider.py +++ b/tst/unit/providers/env_state/test_env_state_provider.py @@ -286,3 +286,13 @@ def test_sidecar_specs_skip_images_the_image_store_does_not_own(image_store, cap assert none == [] assert "pgweb image sosedoff/pgweb is not in the image store" in caplog.text assert "db-mcp image" not in caplog.text + + +def test_container_images_name_what_the_container_path_starts(image_store): + """The servicedb's image and each sidecar's the provider provisions, so a gateway can check them before it starts + any.""" + provider = LocalPostgresStateProvider(ServiceDBConfig(db_image=f"{_REPOSITORIES}/servicedb:v3", + db_web_image=f"{_REPOSITORIES}/pgweb:v1", + db_mcp_image="crystaldba/postgres-mcp")) + + assert provider.container_images() == [f"{_REPOSITORIES}/servicedb:v3", f"{_REPOSITORIES}/pgweb:v1"] diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 877131ea..35b3cb7b 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -1,6 +1,7 @@ """What each sandbox provider declares it runs, and that nothing outside the providers asks which provider it has: every -choice that depends on a provider reads what it declares (``ON_THIS_MACHINE``, ``CREATES_VMS``, ``SANDBOX_ACCEPTS``, -``CONTAINER_ACCEPTS``, ``PRIVATE_NETWORK``, ``links`` and ``url_from_sandbox``) and its sandboxes' ``private_host``.""" +choice that depends on a provider reads what it declares (``ON_THIS_MACHINE``, ``SUPPORTED_SANDBOX_MODES``, +``SANDBOX_ACCEPTS``, ``CONTAINER_ACCEPTS``, ``PRIVATE_NETWORK``, ``links`` and ``url_from_sandbox``) and its sandboxes' +``private_host`` and ``platform``.""" import ast from pathlib import Path @@ -12,11 +13,19 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider from agent_env.providers.sandbox_providers.e2b.provider import E2BSandboxProvider -from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider +from agent_env.providers.sandbox_providers.e2b.sandbox import E2BSandbox +from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox, LocalSandboxProvider from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandbox, ModalSandboxProvider -from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandboxProvider +from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandbox, ModalVmSandboxProvider from agent_env.providers.sandbox_providers.sail_vm.provider import SailVmSandboxProvider -from agent_env.providers.sandbox_providers.sandbox_provider import Accepts, SandboxProvider +from agent_env.providers.sandbox_providers.sail_vm.sandbox import SailVmSandbox +from agent_env.providers.sandbox_providers.sandbox import Sandbox +from agent_env.providers.sandbox_providers.sandbox_provider import ( + SANDBOX_MODE_CONTAINER, + SANDBOX_MODE_VM, + Accepts, + SandboxProvider, +) SRC = Path(__file__).resolve().parents[4] / "src" / "agent_env" PROVIDERS = SRC / "providers" / "sandbox_providers" @@ -35,13 +44,15 @@ ], ids=["local", "modal", "modal_vm", "e2b", "sail_vm"]) def test_each_provider_declares_where_it_runs_and_the_images_it_runs(cls, on_this_machine, creates_vms, sandbox, container, private_network): - assert (cls.ON_THIS_MACHINE, cls.CREATES_VMS, cls.PRIVATE_NETWORK) == (on_this_machine, creates_vms, private_network) + assert ((cls.ON_THIS_MACHINE, SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES, cls.PRIVATE_NETWORK) + == (on_this_machine, creates_vms, private_network)) assert (cls.SANDBOX_ACCEPTS, cls.CONTAINER_ACCEPTS) == (sandbox, container) @pytest.mark.parametrize("cls", PROVIDER_CLASSES, ids=lambda cls: cls.__name__) -def test_a_provider_declares_vms_and_a_private_network_only_where_it_implements_them(cls): - assert cls.CREATES_VMS == (cls.create_vm is not SandboxProvider.create_vm) +def test_a_provider_supports_the_modes_it_implements_and_declares_a_private_network_only_with_containers(cls): + assert cls.SUPPORTED_SANDBOX_MODES == ( + {SANDBOX_MODE_CONTAINER} | ({SANDBOX_MODE_VM} if cls.create_vm is not SandboxProvider.create_vm else set())) assert not cls.PRIVATE_NETWORK or cls.create_container is not SandboxProvider.create_container @@ -53,13 +64,36 @@ async def create_vm(self, **_): raise NotImplementedError -def test_a_provider_that_declares_nothing_runs_images_by_name_and_creates_no_vm(): - """Implementing create_vm declares nothing: a provider says it creates VMs, as it says everything else.""" - assert (_Vms.ON_THIS_MACHINE, _Vms.CREATES_VMS, _Vms.PRIVATE_NETWORK) == (False, False, False) +def test_a_provider_that_declares_nothing_runs_images_by_name_and_supports_vms_by_implementing_create_vm(): + assert (_Vms.ON_THIS_MACHINE, _Vms.PRIVATE_NETWORK) == (False, False) + assert _Vms.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} assert (_Vms.SANDBOX_ACCEPTS, _Vms.CONTAINER_ACCEPTS) == (NAME, NAME) assert _Vms().url_from_sandbox("http://localhost:4000") == "http://localhost:4000" +class _VmsOnly(SandboxProvider): + async def create_vm(self, **_): + raise NotImplementedError + + +class _ContainersOnly(SandboxProvider): + async def create_sandbox(self, **_): + raise NotImplementedError + + +class _Declared(_VmsOnly): + """One that declares its modes, which are derived regardless: what it implements says what it supports.""" + + SUPPORTED_SANDBOX_MODES = frozenset({SANDBOX_MODE_CONTAINER}) + + +def test_a_provider_supports_only_the_modes_it_implements(): + assert (_VmsOnly.SUPPORTED_SANDBOX_MODES, _ContainersOnly.SUPPORTED_SANDBOX_MODES) == ( + {SANDBOX_MODE_VM}, {SANDBOX_MODE_CONTAINER}) + assert SandboxProvider.SUPPORTED_SANDBOX_MODES == set() + assert _Declared.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_VM} + + def test_a_chain_is_its_providers_in_order_and_one_provider_is_itself(): first, second = _Vms(), ModalSandboxProvider() @@ -97,15 +131,81 @@ async def test_a_provider_whose_containers_are_vms_of_their_own_puts_none_on_a_p NO_REGISTRY = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2") +IN_THIS_MACHINES_REGISTRY = DockerImageArtifact(id="img", version=2, description="d", + image_name="localhost:5000/local/img:v2") +WITH_TAR_GZ = DockerImageArtifact(id="img", version=2, description="d", image_name="localhost:5000/local/img:v2", + tar_gz_object_url="file:///state/img.tar.gz") +HERE = "is in a registry on this machine, which a sandbox elsewhere can't pull from" + + @pytest.mark.parametrize("image, accepts, problem", [ (CONTEXT_ONLY, LOADABLE, None), (CONTEXT_ONLY, NAME_OR_CONTEXT, None), (CONTEXT_ONLY, NAME, "only a build context"), (NO_REGISTRY, LOADABLE, "doesn't name a registry"), (NO_REGISTRY, NAME, None), (NO_REGISTRY, NAME_OR_CONTEXT, None), ]) -def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, problem): - found = accepts.problem(image) +@pytest.mark.parametrize("on_this_machine", [True, False]) +def test_an_image_is_refused_by_the_form_its_provider_accepts(image, accepts, problem, on_this_machine): + found = accepts.problem(image, on_this_machine=on_this_machine) assert (found is None) if problem is None else (problem in found) +@pytest.mark.parametrize("image, accepts, problem", [ + (IN_THIS_MACHINES_REGISTRY, LOADABLE, HERE), (IN_THIS_MACHINES_REGISTRY, NAME, HERE), + (IN_THIS_MACHINES_REGISTRY, NAME_OR_CONTEXT, HERE), + (WITH_TAR_GZ, LOADABLE, None), (WITH_TAR_GZ, NAME, HERE), (WITH_TAR_GZ, NAME_OR_CONTEXT, HERE), +]) +def test_a_sandbox_elsewhere_cant_pull_from_this_machines_registry_and_one_on_it_can(image, accepts, problem): + """A VM loads an image's tar.gz, which agent-env pushes to it, and pulls one only when it has none.""" + found = accepts.problem(image, on_this_machine=False) + assert (found is None) if problem is None else (problem in found) + assert accepts.problem(image, on_this_machine=True) is None + + +ARM64_TAR_GZ = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", platform="linux/arm64", + tar_gz_object_url="file:///state/img.tar.gz") +ARM64_PULLED = DockerImageArtifact(id="img", version=2, description="d", platform="linux/arm64", + image_name="123456789012.dkr.ecr.us-west-2.amazonaws.com/img:v2") +ARM64_CONTEXT = DockerImageArtifact(id="img", version=2, description="d", image_name="local/img-0123456789ab:v2", + build_context_object_url="s3://bucket/ctx.tar.gz", platform="linux/arm64") +ARM64 = "'img' v2 is a linux/arm64 image, and the sandbox runs linux/amd64; rebuild it for linux/amd64" + + +@pytest.mark.parametrize("image", [ARM64_TAR_GZ, ARM64_PULLED, ARM64_CONTEXT], ids=["tar.gz", "pulled", "context"]) +def test_a_vm_runs_only_images_built_for_its_platform(image): + """A VM builds a context for the platform it records, so a context is checked too.""" + assert image.load_problem(on_this_machine=False, platform="linux/amd64").startswith(ARM64) + assert image.load_problem(on_this_machine=False, platform="linux/arm64") is None + assert image.load_problem(on_this_machine=False, platform=None) is None # a sandbox that runs any, as a local one does + + +@pytest.mark.parametrize("accepts", [LOADABLE, NAME, NAME_OR_CONTEXT]) +def test_a_provider_checks_no_platform_before_its_sandbox_exists(accepts): + assert accepts.problem(ARM64_PULLED, on_this_machine=False) is None + + +@pytest.mark.parametrize("recorded, runs", [ + ("linux/amd64/v2", "linux/amd64"), ("linux/arm64/v8", "linux/arm64"), ("linux/amd64", "linux/x86_64"), +]) +def test_a_platform_is_the_same_however_the_image_or_the_sandbox_spells_it(recorded, runs): + image = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", platform=recorded, + tar_gz_object_url="s3://bucket/img.tar.gz") + assert image.load_problem(on_this_machine=False, platform=runs) is None + + +def test_an_image_that_records_no_platform_isnt_checked(): + image = DockerImageArtifact(id="img", version=2, description="d", image_name="img:v2", + tar_gz_object_url="s3://bucket/img.tar.gz") + assert image.platform is None and image.load_problem(on_this_machine=False, platform="linux/arm64") is None + + +@pytest.mark.parametrize("sandbox", [Sandbox, ModalSandbox, ModalVmSandbox, E2BSandbox, SailVmSandbox]) +def test_a_sandbox_runs_linux_amd64_unless_its_provider_sets_another(sandbox): + assert sandbox.platform == "linux/amd64" + + +def test_a_local_sandbox_runs_any_platform_unchecked_since_its_docker_emulates_others(): + assert LocalSandbox.platform is None + + def _concrete_classes() -> set[str]: """The sandbox and sandbox provider classes the providers define, but the base classes everything else may name.""" names = set() diff --git a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py index f7f73923..dd94579d 100644 --- a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py +++ b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import pytest @@ -11,7 +12,7 @@ from agent_env.artifact.artifacts.docker_image import DockerImageArtifact from agent_env.config import get_config from agent_env.config.loader import load_impl -from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider +from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider, skip_refusing from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox, LocalSandboxProvider from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandbox, ModalSandboxProvider from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandbox, ModalVmSandboxProvider @@ -283,6 +284,74 @@ async def test_chain_create_sandbox_all_fail_raises_with_all_names(): assert "second boom" in msg +def test_skip_refusing_leaves_a_provider_none_of_whose_links_it_refuses(): + chain = ChainedSandboxProvider([_StubProvider(), _StubProvider()]) + + assert skip_refusing(chain, lambda link: None, what="it") is chain + + +def test_skip_refusing_chains_the_links_it_doesnt_refuse_noting_each_it_skips(caplog): + first, second, third = _StubProvider(), _StubProvider(), _StubProvider() + + with caplog.at_level(logging.INFO, logger=chained_module.__name__): + running = skip_refusing(ChainedSandboxProvider([first, second, third]), + lambda link: "which is too far" if link is not third else None, what="it") + + assert running.links == (third,) + warned, noted = [r for r in caplog.records if "sandbox_provider_skipped" in r.getMessage()] + assert (warned.levelno, noted.levelno) == (logging.WARNING, logging.INFO) # the first, preferred, is warned of + assert warned.getMessage() == "sandbox_provider_skipped provider=_StubProvider: which is too far" + + +@pytest.mark.parametrize("provider, refusal", [ + (_StubProvider(), "with _StubProvider, which is too far"), + (ChainedSandboxProvider([_StubProvider(), _StubProvider()]), + "with _StubProvider, which is too far; nor with _StubProvider, which is too far"), +], ids=["one", "chain"]) +def test_skip_refusing_names_each_refusal_when_it_refuses_every_link(provider, refusal): + with pytest.raises(ValueError, match=f"^Can't deploy it {refusal}$"): + skip_refusing(provider, lambda link: "which is too far", what="it") + + +class _Vm(VmSandbox): + """A VM sandbox of ``platform``, recording whether it was terminated.""" + + type = "fake" + + def __init__(self, sandbox_id: str, platform: str): + self.sandbox_id, self.tunnel_urls, self.vnc_url = sandbox_id, {}, None + self.mode, self.platform, self.terminated = "vm", platform, False + + async def terminate(self) -> None: + self.terminated = True + + +@pytest.mark.asyncio +async def test_a_chain_closes_a_vm_that_cant_run_the_image_and_tries_the_next_provider(caplog): + arm64 = DockerImageArtifact(id="img", version=1, description="d", image_name="img:v1", platform="linux/arm64", + tar_gz_object_url="s3://bucket/img.tar.gz") + amd, arm = _Vm("amd", "linux/amd64"), _Vm("arm", "linux/arm64") + chain = ChainedSandboxProvider([_StubProvider(sandbox=amd), _StubProvider(sandbox=arm)]) + await chain.prepare_image(arm64) + + assert await chain.create_sandbox(image_name="img:v1", port=8000, env={}) is arm + assert amd.terminated and not arm.terminated + assert "gave a VM that can't load the image: 'img' v1 is a linux/arm64 image" in caplog.text + + +@pytest.mark.asyncio +async def test_a_chain_none_of_whose_vms_runs_the_image_names_each_refusal(): + arm64 = DockerImageArtifact(id="img", version=1, description="d", image_name="img:v1", platform="linux/arm64", + tar_gz_object_url="s3://bucket/img.tar.gz") + chain = ChainedSandboxProvider([_StubProvider(sandbox=_Vm("one", "linux/amd64")), + _StubProvider(sandbox=_Vm("two", "linux/amd64"))]) + await chain.prepare_image(arm64) + + with pytest.raises(RuntimeError, match="All 2 providers failed: _StubProvider: ValueError.*linux/arm64 image.*; " + "_StubProvider: ValueError"): + await chain.create_sandbox(image_name="img:v1", port=8000, env={}) + + _CONTEXT_ONLY = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="local/solver-0123456789ab:v2", build_context_object_url="s3://bucket/ctx.tar.gz") diff --git a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py index 6a741fdc..edc937be 100644 --- a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py +++ b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py @@ -1,6 +1,7 @@ import asyncio import hashlib import io +import re import threading import time from concurrent.futures import ThreadPoolExecutor @@ -185,6 +186,64 @@ async def test_an_image_no_sandbox_can_get_is_refused_before_anything_is_loaded( assert sandbox.scripts == [] +class _VmOnThisMachine(_RecordingVmSandbox): + """A VM sandbox on this machine, as the local provider's are.""" + + ON_THIS_MACHINE = True + + +@pytest.mark.asyncio +async def test_a_vm_elsewhere_refuses_an_image_to_pull_from_this_machines_registry_before_anything_is_loaded( + signing_store): + sandbox = _RecordingVmSandbox(images_stdout="a\n") + + with pytest.raises(RuntimeError, match="Can't load images: localhost:5000/local/img:v1 is in a registry on this " + "machine, which a sandbox elsewhere can't pull from"): + await sandbox.load_docker_images([_image("s3://bucket/a.tar.gz", "a:1"), + _image(None, "localhost:5000/local/img:v1")]) + + assert sandbox.scripts == [] + + +@pytest.mark.asyncio +async def test_a_vm_on_this_machine_pulls_from_its_registry_and_one_elsewhere_loads_a_tar_gz_named_for_it(signing_store): + here = _VmOnThisMachine(images_stdout="localhost:5000/local/img\n") + await here.load_docker_images([_image(None, "localhost:5000/local/img:v1")]) + assert here.scripts == ["docker pull localhost:5000/local/img:v1"] + + elsewhere = _RecordingVmSandbox(images_stdout="localhost:5000/local/img\n") + await elsewhere.load_docker_images([_image("s3://bucket/a.tar.gz", "localhost:5000/local/img:v1")]) + assert "docker load" in elsewhere.scripts[0] and "docker pull" not in elsewhere.scripts[0] + + +class _AnyPlatformVm(_RecordingVmSandbox): + """A VM sandbox that runs any platform, as the local provider's do.""" + + platform = None + + +@pytest.mark.asyncio +async def test_a_vm_refuses_a_tar_gz_for_a_platform_it_doesnt_run_before_anything_is_loaded(signing_store): + """A VM runs linux/amd64 unless its provider set another when it created it, as it sets its mode.""" + arm64 = DockerImageArtifact(id="img", version=1, description="d", image_name="img:v1", platform="linux/arm64", + tar_gz_object_url="s3://bucket/img.tar.gz") + + amd = _RecordingVmSandbox() + with pytest.raises(RuntimeError, match="Can't load images: 'img' v1 is a linux/arm64 image, and the sandbox runs " + "linux/amd64; rebuild it for linux/amd64"): + await amd.load_docker_images([arm64]) + assert amd.platform == "linux/amd64" and amd.scripts == [] + + arm = _RecordingVmSandbox(images_stdout="img\n") + arm.platform = "linux/arm64" + await arm.load_docker_images([arm64]) + assert "docker load" in arm.scripts[0] + + anything = _AnyPlatformVm(images_stdout="img\n") + await anything.load_docker_images([arm64]) + assert "docker load" in anything.scripts[0] + + @pytest.mark.asyncio async def test_a_login_is_minted_once_per_registry(): minted = [] @@ -604,7 +663,7 @@ async def push(sandbox, store, object_url, vm_path): await sandbox.load_docker_images([image]) - work = _work(image.source_digest) + (work,) = _works(sandbox) 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] @@ -617,17 +676,19 @@ def _context_image(id: str, digest: str | None, *, platform: str | None = "linux platform=platform, source_digest=digest) -def _work(source: str) -> str: - return f"/tmp/agent-env-build-{hashlib.sha256(source.encode()).hexdigest()[:16]}" +def _works(sandbox: _RecordingVmSandbox) -> list[str]: + """The work folder of each build ``sandbox`` ran, in order.""" + return [match[1] for script in sandbox.scripts + if (match := re.fullmatch(r"rm -rf (/tmp/agent-env-build-[0-9a-f]{32}) && mkdir -p \1/context", script))] @pytest.mark.asyncio async def test_a_context_only_image_is_built_in_the_vm_from_its_context(signing_store): sandbox, digest = _RecordingVmSandbox(), "sha256:" + "1" * 64 - work = _work(digest) await sandbox.load_docker_images([_context_image("a", digest)]) + (work,) = _works(sandbox) assert sandbox.scripts == [ f"rm -rf {work} && mkdir -p {work}/context", f"curl -fsSL {sandbox_module.CURL_RETRY_FLAGS} https://signed/build-context.tar.gz -o {work}/context.tar.gz", @@ -648,13 +709,26 @@ async def test_images_with_one_source_share_a_build_and_builds_run_one_at_a_time assert len(builds) == 2 assert builds[0].endswith("-t local/a-0123456789ab:v1 -t local/c-0123456789ab:v1 .") assert builds[1].endswith("-t local/b-0123456789ab:v1 .") - assert sandbox.scripts.index(f"rm -rf {_work(shared)}") < sandbox.scripts.index(f"rm -rf {_work(other)} && mkdir -p " - f"{_work(other)}/context") + first, second = _works(sandbox) + assert sandbox.scripts.index(f"rm -rf {first}") < sandbox.scripts.index(f"rm -rf {second} && mkdir -p {second}/context") + + +@pytest.mark.asyncio +async def test_two_builds_of_one_context_on_one_host_each_have_a_work_folder_of_their_own(signing_store): + """Two of the local provider's sandboxes share this machine's /tmp, and two deploys can build on one VM: a folder + named after the context alone let one build's cleanup delete the other's context mid-build.""" + sandbox, digest = _RecordingVmSandbox(), "sha256:" + "1" * 64 + + await sandbox.load_docker_images([_context_image("a", digest)]) + await sandbox.load_docker_images([_context_image("a", digest)]) + + first, second = _works(sandbox) + assert first != second @pytest.mark.asyncio async def test_a_build_takes_the_recorded_dockerfile_and_platform_else_linux_amd64(signing_store): - sandbox = _RecordingVmSandbox() + sandbox = _AnyPlatformVm() await sandbox.load_docker_images([_context_image("a", "sha256:" + "1" * 64, platform=None, dockerfile="docker/app.Dockerfile"), @@ -680,7 +754,8 @@ async def test_a_failed_build_names_its_image_and_still_removes_its_folder(signi with pytest.raises(RuntimeError, match="(?s)Building local/a-0123456789ab:v1 from its build context failed: .*not found"): await sandbox.load_docker_images([_context_image("a", digest)]) - assert sandbox.scripts[-1] == f"rm -rf {_work(digest)}" + (work,) = _works(sandbox) + assert sandbox.scripts[-1] == f"rm -rf {work}" @pytest.mark.asyncio diff --git a/tst/unit/task_step/deploy_agent_linked_sandbox_test.py b/tst/unit/task_step/deploy_agent_linked_sandbox_test.py new file mode 100644 index 00000000..82cb77a4 --- /dev/null +++ b/tst/unit/task_step/deploy_agent_linked_sandbox_test.py @@ -0,0 +1,32 @@ +"""deploy_agent places an agent only on a VM sandbox that exposes its A2A port, and refuses any other before deploying +anything, in the words a bundle run's preflight refuses it with too.""" + +import re + +import pytest + +from agent_env.task_step.context import DeployedSandbox, TaskStepContext +from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep, linked_sandbox_problem + + +@pytest.mark.parametrize("mode, tunnel_urls, problem", [ + ("container", {"8000": "https://t-8000"}, "Cannot link agent to sandbox 'box' (mode='container'); only VM-mode " + "sandboxes can host an additional agent container"), + ("vm", {"9000": "https://t-9000"}, "Sandbox 'box' does not expose port 8000; re-deploy DeploySandboxTaskStep with " + "exposed_ports=[8000]"), + ("vm", None, "Sandbox 'box' does not expose port 8000; re-deploy DeploySandboxTaskStep with exposed_ports=[8000]"), +], ids=["container", "other-port", "no-ports"]) +@pytest.mark.asyncio +async def test_an_agent_is_refused_a_sandbox_it_cant_be_placed_on(mode, tunnel_urls, problem): + step = DeployAgentTaskStep(id="agent", version=1, env_ids=[], a2a_agent_id="solver", sandbox_name="box") + context = TaskStepContext() + context.deployed_sandboxes.append(DeployedSandbox(sandbox_name="box", sandbox_id="sb-1", sandbox_mode=mode, + tunnel_urls=tunnel_urls)) + + with pytest.raises(RuntimeError, match=f"^{re.escape(problem)}$"): + await step.execute(context) + + +def test_a_vm_sandbox_exposing_the_a2a_port_takes_the_agent(): + assert linked_sandbox_problem("box", "vm", {"8000": "https://t-8000"}) is None + assert linked_sandbox_problem("box", "vm", [8000]) is None # as a deploy_sandbox step's exposed_ports names it diff --git a/tst/unit/task_step/deploy_sandbox_image_test.py b/tst/unit/task_step/deploy_sandbox_image_test.py new file mode 100644 index 00000000..bbc038ea --- /dev/null +++ b/tst/unit/task_step/deploy_sandbox_image_test.py @@ -0,0 +1,55 @@ +"""A container-mode deploy_sandbox runs its image by name, so a provider whose sandboxes are elsewhere can't pull one from +this machine's registry; one on this machine can, and a chain skips the rest for it.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep + + +def _provider(on_this_machine: bool): + sandbox = AsyncMock() + sandbox.sandbox_id, sandbox.mode, sandbox.type = "sb-1", "container", "local" + sandbox.tunnel_urls, sandbox.vnc_url, sandbox.network_policy = {}, None, NetworkPolicy() + provider = AsyncMock(ON_THIS_MACHINE=on_this_machine) + provider.links = (provider,) + provider.create_sandbox.return_value = sandbox + return provider + + +@pytest.mark.asyncio +@pytest.mark.parametrize("on_this_machine", [False, True], ids=["elsewhere", "on-this-machine"]) +async def test_a_container_sandbox_pulls_an_image_from_this_machines_registry_only_on_this_machine(on_this_machine): + step = DeploySandboxTaskStep(id="s", version=1, sandbox_name="box", sandbox_mode="container", + image="localhost:5000/box:v1", port=80) + provider = _provider(on_this_machine) + + with patch("agent_env.providers.sandbox_providers.sandbox_provider.get_sandbox_provider", return_value=provider): + if on_this_machine: + await step.execute(TaskStepContext()) + provider.create_sandbox.assert_awaited_once() + else: + with pytest.raises(ValueError, match="Can't deploy sandbox 'box' with AsyncMock, whose sandboxes pull it by " + "name: localhost:5000/box:v1 is in a registry on this machine, which a " + "sandbox elsewhere can't pull from"): + await step.execute(TaskStepContext()) + provider.create_sandbox.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_a_chain_pulls_an_image_from_this_machines_registry_on_its_provider_here_skipping_one_elsewhere(): + step = DeploySandboxTaskStep(id="s", version=1, sandbox_name="box", sandbox_mode="container", + image="localhost:5000/box:v1", port=80) + elsewhere, here = _provider(on_this_machine=False), _provider(on_this_machine=True) + + with patch("agent_env.providers.sandbox_providers.sandbox_provider.get_sandbox_provider", + return_value=ChainedSandboxProvider([elsewhere, here])): + await step.execute(TaskStepContext()) + + elsewhere.create_sandbox.assert_not_awaited() + here.create_sandbox.assert_awaited_once() + diff --git a/tst/unit/task_step/install_agent_guards_test.py b/tst/unit/task_step/install_agent_guards_test.py index b6a521a8..c1ad01ee 100644 --- a/tst/unit/task_step/install_agent_guards_test.py +++ b/tst/unit/task_step/install_agent_guards_test.py @@ -1,5 +1,9 @@ -"""Same-sandbox tunnel-collision guard for multi-agent host installs (no I/O in the guard).""" +"""Same-sandbox tunnel-collision guard for multi-agent host installs (no I/O in the guard), and preflight's refusal of an +agent with no build context to install.""" import pytest +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 from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.task_step.task_steps.install_agent import InstallAgentTaskStep @@ -25,3 +29,29 @@ def test_distinct_port_passes(): def test_other_sandbox_ignored(): _step("solver-b")._check_tunnel_collision(_ctx(sandbox_id="vm-2"), "vm-1", "https://t-8000.gw", 8000) + + +# What preflight refuses at save: install/v1 copies an agent's source files from its image's build context + +def _agent_with(**image_fields): + image = get_artifact_store().put_document(DockerImageArtifact(id="agent-x-image", description="d", **image_fields)) + return A2AAgent.put(id="agent-x", docker_image_artifact=image) + + +def test_preflight_refuses_an_agent_whose_image_has_no_build_context(local_stores): + _agent_with(image_name="ghcr.io/team/agent@sha256:" + "0" * 64) + + assert _step("solver").preflight() == [ + "install_agent 's': Agent agent-x v1 has no build_context_object_url on its docker_image_artifact; install/v1 " + "needs the build context (agent source files) to install into the task container"] + + +def test_preflight_passes_an_agent_with_a_build_context(local_stores): + _agent_with(image_name="local/agent-x-0123456789ab:v1", build_context_object_url="s3://bucket/ctx.tar.gz") + + assert _step("solver").preflight() == [] + + +def test_preflight_leaves_an_agent_the_store_doesnt_hold_to_the_run(local_stores): + """The run's a2a_agent_id override can name another agent, so a task saved naming a placeholder still saves.""" + assert _step("solver").preflight() == [] diff --git a/tst/unit/utils/docker_build_test.py b/tst/unit/utils/docker_build_test.py index 68a10dd5..9ea3e8e2 100644 --- a/tst/unit/utils/docker_build_test.py +++ b/tst/unit/utils/docker_build_test.py @@ -1,5 +1,5 @@ """``build_image``: the argv it runs, what a failed or impossible build raises, and that importing it pulls in -neither the CLI nor the providers, so library code can build too.""" +neither the CLI nor the providers, so library code can build too; and ``normalize_platform``.""" import os import subprocess @@ -10,7 +10,7 @@ import pytest from agent_env.utils import docker_build -from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, DockerBuildError, build_image +from agent_env.utils.docker_build import DEFAULT_BUILD_PLATFORM, DockerBuildError, build_image, normalize_platform @pytest.fixture @@ -104,3 +104,13 @@ def run(argv, **kwargs): monkeypatch.setattr(docker_build.subprocess, "run", run) assert docker_build.docker_unreachable() == reason + + +@pytest.mark.parametrize("platform, normalized", [ + ("linux/amd64", "linux/amd64"), (" Linux/AMD64\n", "linux/amd64"), ("linux/x86_64", "linux/amd64"), + ("linux/amd64/v2", "linux/amd64"), ("linux/aarch64", "linux/arm64"), ("linux/arm64/v8", "linux/arm64"), + ("linux/arm/v7", "linux/arm/v7"), ("linux/arm/v6", "linux/arm/v6"), ("linux/i386", "linux/386"), +]) +def test_a_platform_is_named_by_os_and_architecture_keeping_only_32_bit_arms_variant(platform, normalized): + assert normalize_platform(platform) == normalized + diff --git a/tst/util/sandbox_providers.py b/tst/util/sandbox_providers.py index 1870e9d3..ee2bc46a 100644 --- a/tst/util/sandbox_providers.py +++ b/tst/util/sandbox_providers.py @@ -3,12 +3,15 @@ from unittest.mock import MagicMock -from agent_env.providers.sandbox_providers.sandbox_provider import Accepts +from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM, Accepts + +VMS_AND_CONTAINERS = frozenset({SANDBOX_MODE_VM, SANDBOX_MODE_CONTAINER}) +CONTAINERS = frozenset({SANDBOX_MODE_CONTAINER}) def mock_provider(*, on_this_machine: bool = False, **attrs) -> MagicMock: """A MagicMock provider that declares what a VM provider does, unless ``attrs`` declare otherwise.""" - facts = {"CREATES_VMS": True, "SANDBOX_ACCEPTS": Accepts.LOADABLE, "CONTAINER_ACCEPTS": Accepts.NAME, + facts = {"SUPPORTED_SANDBOX_MODES": VMS_AND_CONTAINERS, "SANDBOX_ACCEPTS": Accepts.LOADABLE, "CONTAINER_ACCEPTS": Accepts.NAME, "PRIVATE_NETWORK": False} provider = MagicMock(ON_THIS_MACHINE=on_this_machine, **(facts | attrs)) provider.links = (provider,)