diff --git a/AGENTS.md b/AGENTS.md index a377d8c8..be1458e6 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, 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). | +| `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`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), 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 71742f53..fe5644a6 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -462,6 +462,8 @@ async def deploy( ) self._owns_sandbox = True logger.info(f"Sandbox created: {self._sandbox.sandbox_id} (mode={self._sandbox.mode})") + if problem := provider.sandbox_problem(self._sandbox, image): + raise ValueError(problem) else: if network_policy is not None and network_policy.restricts_egress: raise NetworkPolicyUnsupportedError( 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 dce3fb38..5fadf549 100644 --- a/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING from agent_env.attribution import Attribution -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 +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError, Sandbox +from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider if TYPE_CHECKING: from agent_env.artifact.artifacts.docker_image import DockerImageArtifact @@ -48,6 +48,11 @@ def filter_sandbox_providers( f"(providers: {[type(p).__name__ for p in providers]})"), ) + def sandbox_problem(self, sandbox: Sandbox, image: DockerImageArtifact) -> str | None: + """None: create_sandbox checks each provider's sandbox as it tries it, and gives only one that runs + the image.""" + return None + def supports_network_policy(self, policy: NetworkPolicy) -> bool: return any(p.supports_network_policy(policy) for p in self._providers) @@ -76,11 +81,10 @@ 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") + # A VM refuses an image it can't get or run, and a container runs only what its provider's containers do; + # the next provider's sandbox may run it. + if image is not None and (problem := p.sandbox_problem(sandbox, image)): + logger.warning(f"{problem}; trying next provider") errors.append((name, ValueError(problem))) try: await sandbox.terminate() diff --git a/src/agent_env/providers/sandbox_providers/e2b/provider.py b/src/agent_env/providers/sandbox_providers/e2b/provider.py index d2f25479..050d74a4 100644 --- a/src/agent_env/providers/sandbox_providers/e2b/provider.py +++ b/src/agent_env/providers/sandbox_providers/e2b/provider.py @@ -20,7 +20,6 @@ from agent_env.providers.sandbox_providers.sandbox import NetworkMode, NetworkPolicy from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, - Accepts, SandboxProvider, apply_default_attribution, ) @@ -38,7 +37,6 @@ class E2BSandboxProvider(SandboxProvider): interpolation in ``[sandbox.providers.e2b.config]``. """ - 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``. # Workloads receive those public URLs, so an allowlisted sandbox must be diff --git a/src/agent_env/providers/sandbox_providers/local_sandbox.py b/src/agent_env/providers/sandbox_providers/local_sandbox.py index 1b34b309..3ece7364 100644 --- a/src/agent_env/providers/sandbox_providers/local_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/local_sandbox.py @@ -42,6 +42,7 @@ from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM, + Accepts, SandboxProvider, refuse_unenforceable_policy, ) @@ -538,6 +539,8 @@ class LocalSandboxProvider(SandboxProvider): EXTRA_CONTAINER_RUN_ARGS = " ".join(f"--add-host {entry}" for entry in _EXTRA_HOSTS) ON_THIS_MACHINE = True + # An agent's sandbox is a container from its image's name, though a VM it creates (create_vm) loads images. + SANDBOX_ACCEPTS = Accepts.NAME def url_from_sandbox(self, url: str) -> str: return self.get_external_url(url) diff --git a/src/agent_env/providers/sandbox_providers/modal_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_sandbox.py index 0aab7a45..af1ad617 100644 --- a/src/agent_env/providers/sandbox_providers/modal_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_sandbox.py @@ -191,7 +191,7 @@ class ModalSandboxProvider(SandboxProvider): # Each image in a container of its own, built first when it is only a build context (prepare_image); its # containers share i6pn, Modal's private network. - SANDBOX_ACCEPTS = CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT + CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT PRIVATE_NETWORK = True @classmethod 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 d0762293..0c44aa02 100644 --- a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py @@ -38,7 +38,7 @@ from agent_env.attribution import Attribution from agent_env.config import get_config from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, VmSandbox, push_object_over_stdin -from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM, Accepts, SandboxProvider +from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM, SandboxProvider if TYPE_CHECKING: from agent_env.store.object_store import ObjectStore @@ -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") - SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name @classmethod def supports_network_policy(cls, policy: NetworkPolicy) -> bool: 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 25d2e562..27116548 100644 --- a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py @@ -25,7 +25,6 @@ from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError from agent_env.providers.sandbox_providers.sandbox_provider import ( SANDBOX_MODE_VM, - Accepts, SandboxProvider, apply_default_attribution, ) @@ -134,7 +133,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",) - SANDBOX_ACCEPTS = Accepts.LOADABLE # a sandbox is a VM, which loads the image; a container pulls it by name def __init__( self, diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 5df448c7..39854700 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -166,9 +166,9 @@ def from_config(cls, **config: Any) -> Self: # Internal→external host rewrites for URLs issued on this platform ({} = none). 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, on linux/amd64, and creates no VM and no - # private network. + # What it declares, which nothing outside the providers reads any other way. A provider that declares nothing has + # its sandboxes off this machine, runs its containers' images by name on no private network, and gives an agent's + # sandbox the images that follow from what it implements (SANDBOX_ACCEPTS). # # 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. @@ -176,9 +176,14 @@ def from_config(cls, **config: Any) -> Self: # 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 + # The images create_container runs. CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME + # The images create_sandbox, an agent's sandbox, runs; derived for a provider class that doesn't declare them + # (``__init_subclass__``): those a VM loads when it implements create_vm, since its create_sandbox gives a bare VM + # the deploy loads the image into, and else CONTAINER_ACCEPTS, since its create_sandbox is one of its containers. + # One whose create_sandbox differs declares them, as the local provider does, and its subclasses keep them. + SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.NAME + _DECLARES_SANDBOX_ACCEPTS: ClassVar[bool] = False # Whether create_container(private_network=True) puts containers on a network they share. PRIVATE_NETWORK: ClassVar[bool] = False @@ -187,12 +192,33 @@ def __init_subclass__(cls, **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)) + if "SANDBOX_ACCEPTS" in cls.__dict__: + cls._DECLARES_SANDBOX_ACCEPTS = True + elif not cls._DECLARES_SANDBOX_ACCEPTS: + cls.SANDBOX_ACCEPTS = (Accepts.LOADABLE if SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES + else cls.CONTAINER_ACCEPTS) @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.""" return (self,) + def sandbox_problem(self, sandbox: Sandbox, image: DockerImageArtifact) -> str | None: + """Why ``sandbox``, which create_sandbox gave to run ``image``, can't run it, or None when it can: a VM that + can't load it, or a container from a provider whose SANDBOX_ACCEPTS takes images as a VM loads them, which runs + only the images its containers do (CONTAINER_ACCEPTS). One that declares other images is taken at its word.""" + name = type(self).__name__ + if sandbox.mode == SANDBOX_MODE_VM: + problems = sandbox.load_problems([image]) if isinstance(sandbox, VmSandbox) else [] + return f"{name} gave a VM that can't load the image: {'; '.join(problems)}" if problems else None + if self.SANDBOX_ACCEPTS is not Accepts.LOADABLE: + return None + if (problem := self.CONTAINER_ACCEPTS.problem(image, on_this_machine=sandbox.ON_THIS_MACHINE)) is None: + return None + return (f"{name} gave a container, which can't run the image: {problem}; its SANDBOX_ACCEPTS is LOADABLE, the " + "default for a provider that implements create_vm, so declare the images its containers run instead: " + f"SANDBOX_ACCEPTS = Accepts.{self.CONTAINER_ACCEPTS.name}") + def url_from_sandbox(self, url: str) -> str: """``url``, of a service this process reaches, as its sandboxes reach it.""" return url diff --git a/tst/unit/a2a_agent/context_only_deploy_test.py b/tst/unit/a2a_agent/context_only_deploy_test.py index 63e58bae..97e32f04 100644 --- a/tst/unit/a2a_agent/context_only_deploy_test.py +++ b/tst/unit/a2a_agent/context_only_deploy_test.py @@ -190,3 +190,125 @@ async def test_the_local_provider_is_asked_for_a_sandbox_for_an_image_in_its_own with pytest.raises(_Created): await A2AAgent(id="solver", version=1, docker_image_artifact=PULLED_HERE).deploy(sandbox_type="any") + + +class _Container: + """A container sandbox, recording whether it was terminated.""" + + sandbox_id, mode, type, tunnel_urls, vnc_url, network_policy = "sb-agent", "container", "fake", {}, None, None + ON_THIS_MACHINE = False + + def __init__(self): + self.terminated = False + + async def terminate(self): + self.terminated = True + + +class _ContainerAgentsUndeclared(SandboxProvider): + """One that creates VMs, so is taken to load an agent's image on one, but gives the agent a container.""" + + def __init__(self): + self.sandbox = _Container() + + async def create_vm(self, **_): + raise AssertionError("an agent's sandbox comes from create_sandbox") + + async def create_sandbox(self, **_): + return self.sandbox + + +class _BuildingContainerAgentsUndeclared(_ContainerAgentsUndeclared): + """The same, but its containers run a build context it builds first (prepare_image), as Modal's do.""" + + CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT + + +class _ContainerAgentsDeclared(_ContainerAgentsUndeclared): + """The same, but declaring that its agents' containers run a build context it builds, unlike create_container's.""" + + SANDBOX_ACCEPTS = Accepts.NAME_OR_CONTEXT + + +class _ModalContainer(_ModalProvider): + """Modal with its build faked, giving a container.""" + + async def create_container(self, **kwargs): + self.steps.append(("create", kwargs["image_name"])) + return _Container() + + +PULLED = DockerImageArtifact(id="solver-image", version=2, description="d", image_name="registry.example/solver:v2") + + +MISDECLARED = (r"_ContainerAgentsUndeclared gave a container, which can't run the image: 'solver-image' v2 is only a " + r"build context, which has to be built, not pulled; its SANDBOX_ACCEPTS is LOADABLE, the default for a " + r"provider that implements create_vm, so declare the images its containers run instead: " + r"SANDBOX_ACCEPTS = Accepts.NAME") + + +@pytest.mark.asyncio +async def test_a_provider_taken_to_load_the_agents_image_that_gives_a_container_names_the_declaration_it_lacks( + monkeypatch, local_stores, litellm): + provider = _ContainerAgentsUndeclared() + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider) + + with pytest.raises(ValueError, match=f"^{MISDECLARED}$"): + await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + assert provider.sandbox.terminated + + +@pytest.mark.asyncio +async def test_a_chain_closes_a_container_that_cant_run_the_agents_image_and_tries_the_next_provider( + monkeypatch, local_stores, litellm, caplog): + misdeclared, modal = _ContainerAgentsUndeclared(), _ModalContainer() + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", + lambda name: ChainedSandboxProvider([misdeclared, modal])) + + with pytest.raises(RuntimeError, match="No tunnel URL for port"): + await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + assert misdeclared.sandbox.terminated + assert modal.steps == [("prepare", IMAGE.image_name), ("create", IMAGE.image_name)] + assert f"{MISDECLARED.replace(chr(92), '')}; trying next provider" in caplog.text + + +@pytest.mark.asyncio +async def test_a_chain_none_of_whose_sandboxes_runs_the_agents_image_names_each_refusal(monkeypatch, local_stores, + litellm): + """The provider after one that gives a container it can't run is tried, a VM provider here.""" + misdeclared = _ContainerAgentsUndeclared() + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", + lambda name: ChainedSandboxProvider([misdeclared, _VmProvider()])) + + with pytest.raises(RuntimeError, match=r"All 2 providers failed: _ContainerAgentsUndeclared: ValueError\(.*declare " + r"the images its containers run instead: " + r"SANDBOX_ACCEPTS = Accepts.NAME.*\); _VmProvider: _Created\(\)$"): + await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + assert misdeclared.sandbox.terminated + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider_class, image", [ + (_ContainerAgentsUndeclared, PULLED), + (_BuildingContainerAgentsUndeclared, IMAGE), + (_ContainerAgentsDeclared, IMAGE), +], ids=["pulled-by-name", "built-first", "declared"]) +async def test_a_container_that_can_run_the_agents_image_goes_on_to_serve_it(monkeypatch, local_stores, litellm, + provider_class, image): + """Its provider should say so, unless it declared it, but the deploy it gave works, so it isn't refused.""" + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider_class()) + + with pytest.raises(RuntimeError, match="No tunnel URL for port"): + await A2AAgent(id="solver", version=1, docker_image_artifact=image).deploy(sandbox_type="any") + + +@pytest.mark.asyncio +async def test_a_chain_that_falls_back_from_a_vm_provider_to_modal_serves_the_agent_from_modals_container( + monkeypatch, local_stores, litellm): + modal = _ModalContainer() + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", + lambda name: ChainedSandboxProvider([_VmProvider(), modal])) + + with pytest.raises(RuntimeError, match="No tunnel URL for port"): + await A2AAgent(id="solver", version=1, docker_image_artifact=IMAGE).deploy(sandbox_type="any") + assert modal.steps == [("prepare", IMAGE.image_name), ("create", IMAGE.image_name)] diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 35b3cb7b..3870748e 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -64,10 +64,10 @@ async def create_vm(self, **_): raise NotImplementedError -def test_a_provider_that_declares_nothing_runs_images_by_name_and_supports_vms_by_implementing_create_vm(): +def test_a_provider_that_declares_nothing_has_what_it_implements_and_loads_an_agents_image_on_a_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.SANDBOX_ACCEPTS, _Vms.CONTAINER_ACCEPTS) == (LOADABLE, NAME) assert _Vms().url_from_sandbox("http://localhost:4000") == "http://localhost:4000" @@ -94,6 +94,35 @@ def test_a_provider_supports_only_the_modes_it_implements(): assert _Declared.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_VM} +class _BuildingContainers(_ContainersOnly): + CONTAINER_ACCEPTS = NAME_OR_CONTEXT + + +class _ContainerAgents(_Vms): + """One whose agents run as containers by name although it creates VMs, as the local provider's do.""" + + SANDBOX_ACCEPTS = NAME + + +class _ContainerAgentsAgain(_ContainerAgents): + pass + + +class _VmsAgain(_Vms): + CONTAINER_ACCEPTS = NAME_OR_CONTEXT + + +@pytest.mark.parametrize("cls, accepts", [ + (_Vms, LOADABLE), (_VmsOnly, LOADABLE), # create_sandbox gives a bare VM the deploy loads the image into + (_ContainersOnly, NAME), (_BuildingContainers, NAME_OR_CONTEXT), # create_sandbox is one of its containers + (_ContainerAgents, NAME), (_ContainerAgentsAgain, NAME), # declared, and kept by a subclass + (_VmsAgain, LOADABLE), # derived again for a subclass that declares nothing + (LocalSandboxProvider, NAME), +], ids=lambda value: getattr(value, "__name__", getattr(value, "name", value))) +def test_an_agents_sandbox_takes_what_create_sandbox_gives_unless_the_provider_declares_otherwise(cls, accepts): + assert cls.SANDBOX_ACCEPTS is accepts + + def test_a_chain_is_its_providers_in_order_and_one_provider_is_itself(): first, second = _Vms(), ModalSandboxProvider()