Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ dependencies = [
"pyyaml>=6.0",
"starlette>=0.40", # the local grant server
"uvicorn>=0.47", # 0.47 adds ssl_context_factory, how the local grant server gets its TLS context
"modal>=1.5.2.dev22", # dev22 adds _experimental_create(tags=); dev2 added VM-mode V2 (vm_runtime)
"modal>=1.6.3",
"e2b~=2.46.4", # AsyncSandbox/AsyncTemplate APIs validated against the 2.46 line
"fastapi>=0.110", # the explorer, agent-env up
"tzdata>=2024.1", # sync_env_clock reads a task's timezone on a worker image with no system zone files
Expand Down
26 changes: 10 additions & 16 deletions src/agent_env/providers/sandbox_providers/modal_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ async def _terminate_by_id(client, sandbox_id: str) -> None:


def _modal_network_kwargs(policy: NetworkPolicy) -> dict:
"""``policy`` as Modal ``_experimental_create`` kwargs.
"""``policy`` as Modal sandbox creation kwargs.

ALLOW_ALL contributes nothing: passing ``outbound_domain_allowlist`` at all flips Modal
OPEN -> ALLOWLIST and drops raw-IP egress, even for ``["*"]``.
Expand Down Expand Up @@ -249,11 +249,7 @@ def __init__(
self._ecr_pull_secret_name = ecr_pull_secret_name
self._environment_name = environment_name
self._client: Optional[modal.Client] = None
# Optional Modal GPU spec (e.g. "H100", "A100", "H100:2"). When set, create_container
# provisions the sandbox with the V1 ``modal.Sandbox.create`` factory (which accepts
# ``gpu=`` + docker-in-gVisor) instead of the V2 ``_experimental_create``, which
# cannot attach a GPU. V1 has no ``i6pn``, so a GPU sandbox has no private-IPv6
# east-west networking (fine for a single sandbox; not for an i6pn mesh).
# GPU sandboxes use the V1 backend, which supports Docker in gVisor but not i6pn.
self._gpu = gpu
# Looked up on demand and cached by name.
self._apps: dict[str, modal.App] = {}
Expand Down Expand Up @@ -334,11 +330,7 @@ async def create_container(
# The private network is i6pn, Modal's network between sandboxes, in the configured region.
i6pn = private_network
region = get_config().modal_default_region if private_network else None
# GPU sandboxes need the V1 ``Sandbox.create`` factory: the V2
# ``_experimental_create`` has no ``gpu`` parameter and cannot attach one. V1 in
# turn has no ``i6pn``, so reject that combination up front — before any Modal
# call — rather than silently returning one whose i6pn_address is None
# and failing downstream (e.g. the gateway's "service-db has no i6pn address").
# The GPU backend does not support private IPv6 networking.
if self._gpu and i6pn:
raise ValueError(
"i6pn is unavailable on GPU sandboxes: the GPU-capable factory "
Expand Down Expand Up @@ -375,7 +367,7 @@ async def create_container(
f"app={app_name} region={region} i6pn={i6pn}"
)

# ``i6pn`` is threaded per-factory below (V2 only); everything else is shared.
# Private networking requires the experimental factory; other calls use the public API.
create_kwargs = dict(
app=app, cpu=cpu, memory=memory, timeout=timeout,
env=env or {}, client=client, **port_kwargs,
Expand All @@ -395,9 +387,11 @@ async def create(image: modal.Image) -> modal.Sandbox:
image=image,
**create_kwargs,
)
return await modal.Sandbox._experimental_create.aio(
*(command or []), i6pn=i6pn, image=image, **create_kwargs
)
if i6pn:
return await modal.Sandbox._experimental_create.aio(
*(command or []), i6pn=True, image=image, **create_kwargs
)
return await modal.Sandbox.create.aio(*(command or []), image=image, **create_kwargs)

async def create_rebuilding_once() -> modal.Sandbox:
try:
Expand Down Expand Up @@ -515,7 +509,7 @@ async def _registry_image(self, image_name: str) -> modal.Image:
)
auth = None if uses_ecr_pull_secret else await asyncio.to_thread(image_store.auth, image_name)
if uses_ecr_pull_secret:
return modal.Image.from_aws_ecr(image_name, secret=modal.Secret.from_name(
return modal.Image.from_registry(image_name, secret=modal.Secret.from_name(
self._ecr_pull_secret_name, required_keys=ECR_READER_SECRET_KEYS,
environment_name=self._environment_name))
if auth is not None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This is the Modal analogue of a KubeVirt-style one-VM provider rather than of the
container-per-service ModalSandboxProvider. It provisions a single Modal VM sandbox
(`experimental_options={"vm_runtime": True}` → real Linux kernel + nested Docker),
(`runtime="vm"` → real Linux kernel + nested Docker),
so the gateway's VM path (`EnvironmentGatewayProvider._deploy_via_vm`) can `docker compose up`
the whole stack (postgres + N MCP servers + gateway) inside one box behind one tunnel.

Expand Down Expand Up @@ -123,7 +123,7 @@


class ModalVmSandbox(VmSandbox):
"""A Modal VM sandbox (vm_runtime) presented through the VmSandbox contract.
"""A Modal VM sandbox (runtime="vm") presented through the VmSandbox contract.

Subclasses VmSandbox so it inherits exec_script / load_docker_images / load_object_file /
write_file_* unchanged — they are built on exec_with_output ->
Expand Down Expand Up @@ -363,7 +363,7 @@ async def create_vm(
return await self._created(_KEEPALIVE_CMD, _VM_IMAGE, cpu=cpu, memory=memory, timeout=timeout, ports=ports,
attribution=attribution, network_policy=network_policy,
setup_for_gateway=setup_for_gateway, idle_timeout=self._idle_timeout,
experimental_options={"vm_runtime": True})
runtime="vm")
(platform,) = self.disk_image_platforms()
disk = await asyncio.to_thread(exported_disk, image, platform)
variables = {**launcher_variables(ports, env or {}, cpu, memory), "VM_DISK": _MOUNTED_DISK}
Expand Down Expand Up @@ -404,7 +404,7 @@ async def _created(self, command: tuple[str, ...], image: modal.Image, *, cpu: f
# (retry_transient is documented for read-only calls only). Modal's client already
# retries RESOURCE_EXHAUSTED / queueing internally, which is the real high-load case.
try:
sb = await modal.Sandbox._experimental_create.aio(
sb = await modal.Sandbox.create.aio(
*command,
app=app, image=image, cpu=cpu, memory=memory, timeout=timeout,
encrypted_ports=ports, client=client,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def _provider():
async def test_the_shared_app_gets_the_defaults_and_the_sandbox_gets_the_run():
provider = _provider()
attribution = {"team": "t", "cost_center": "research", RUN_ID_KEY: "inst-1"}
with patch("agent_env.providers.sandbox_providers.modal_sandbox.modal.Sandbox._experimental_create") as create:
with patch("agent_env.providers.sandbox_providers.modal_sandbox.modal.Sandbox.create") as create:
create.aio = AsyncMock(side_effect=RuntimeError("stop"))
with pytest.raises(RuntimeError):
await provider.create_container(image_name="img:latest", port=8000, env={}, attribution=attribution)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,10 @@ async def test_an_image_modal_pulls_drops_one_prepared_earlier_under_its_name(lo


def _fake_create():
"""A patched ``modal.Sandbox._experimental_create`` that stops right after recording its call."""
"""A patched ``modal.Sandbox.create`` that stops right after recording its call."""
create = MagicMock()
create.aio = AsyncMock(side_effect=RuntimeError("stop"))
return patch.object(modal_sandbox.modal.Sandbox, "_experimental_create", create)
return patch.object(modal_sandbox.modal.Sandbox, "create", create)


@pytest.mark.asyncio
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,18 @@ def _provider(cls, **kwargs):


_CREATES = {
"container": (ModalSandboxProvider, {}, "_experimental_create", lambda p: p.create_container(
"private container": (ModalSandboxProvider, {}, "_experimental_create", lambda p: p.create_container(
image_name="img:latest", port=8000, env={"A": "1"}, network_policy=_ALLOWLIST, attribution=_ATTRIBUTION,
private_network=True)),
"container": (ModalSandboxProvider, {}, "create", lambda p: p.create_container(
image_name="img:latest", port=8000, env={"A": "1"}, network_policy=_ALLOWLIST, attribution=_ATTRIBUTION)),
"gpu container": (ModalSandboxProvider, {"gpu": "H100"}, "create", lambda p: p.create_container(
image_name="img:latest", port=8000, env={"A": "1"}, network_policy=_ALLOWLIST, attribution=_ATTRIBUTION)),
"vm": (ModalVmSandboxProvider, {}, "_experimental_create", lambda p: p.create_vm(
"vm": (ModalVmSandboxProvider, {}, "create", lambda p: p.create_vm(
exposed_ports=[8000], network_policy=_ALLOWLIST, attribution=_ATTRIBUTION)),
"vm in a region": (ModalVmSandboxProvider, {"region": "us-west", "idle_timeout": 600}, "_experimental_create",
"vm in a region": (ModalVmSandboxProvider, {"region": "us-west", "idle_timeout": 600}, "create",
lambda p: p.create_vm(exposed_ports=[8000], attribution=_ATTRIBUTION)),
"disk": (ModalVmSandboxProvider, {"region": "us-west"}, "_experimental_create", lambda p: p.create_vm(
"disk": (ModalVmSandboxProvider, {"region": "us-west"}, "create", lambda p: p.create_vm(
image="localhost:5000/disk@sha256:" + "a" * 64, env={"ENVIRONMENT_NAME": "desk"}, exposed_ports=[18765],
cpu=2, memory=4096, network_policy=_ALLOWLIST, attribution=_ATTRIBUTION)),
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""GPU support on ModalSandboxProvider.

A ``gpu`` spec must route create_container through the V1 ``modal.Sandbox.create`` factory
(the only one that accepts ``gpu=``), with docker-in-gVisor enabled; the default path must
stay on V2 ``_experimental_create`` with ``i6pn`` unchanged. ``gpu`` + ``i6pn`` is
(the only one that accepts ``gpu=``), with docker-in-gVisor enabled; private networking uses
``_experimental_create`` with ``i6pn`` enabled. ``gpu`` + ``i6pn`` is
unsatisfiable (V1 has no i6pn) and must fail before a sandbox is created.
"""

Expand Down Expand Up @@ -55,7 +55,7 @@ async def test_gpu_uses_v1_create_with_gpu_and_docker_in_gvisor():


@pytest.mark.asyncio
async def test_default_stays_on_v2_experimental_create_with_i6pn():
async def test_private_network_uses_experimental_create_with_i6pn():
captured, fake = _capture()
provider = _provider() # no gpu
with _patched("modal.Sandbox._experimental_create", fake), pytest.raises(Exception):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""The allowlist must actually reach Modal's create call.

modal_network_policy_test.py covers the lowering as a pure function; that passes even if
neither backend spreads the result into _experimental_create. Modal is the only backend
neither backend spreads the result into create. Modal is the only backend
that enforces anything, so losing that spread would provision open while the deployment
record still claimed the allowlist.
"""
Expand Down Expand Up @@ -33,7 +33,7 @@ async def fake_create(*args, **kwargs):
def _patched_create(fake):
create = MagicMock()
create.aio = fake
return patch("modal.Sandbox._experimental_create", create)
return patch("modal.Sandbox.create", create)


@pytest.mark.asyncio
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async def test_create_container_emits_the_started_line(caplog):
provider = ModalSandboxProvider(app_name="agent-env-test")
provider._get_app = AsyncMock(return_value=MagicMock()) # type: ignore[method-assign]
provider._get_client = AsyncMock(return_value=MagicMock()) # type: ignore[method-assign]
with patch("agent_env.providers.sandbox_providers.modal_sandbox.modal.Sandbox._experimental_create") as create, \
with patch("agent_env.providers.sandbox_providers.modal_sandbox.modal.Sandbox.create") as create, \
caplog.at_level(logging.INFO, logger="agent_env.providers.sandbox_providers.modal_sandbox"):
create.aio = AsyncMock(return_value=_fake_sb())
await provider.create_container(image_name="img:latest", port=8000, env={}, attribution=_TAGS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def _patch_clients(provider):
)
async def test_container_create_passes_sandbox_tags(attribution, expected):
provider = _patch_clients(ModalSandboxProvider(app_name="agent-env-test"))
with patch("agent_env.providers.sandbox_providers.modal_sandbox.modal.Sandbox._experimental_create") as create:
with patch("agent_env.providers.sandbox_providers.modal_sandbox.modal.Sandbox.create") as create:
create.aio = AsyncMock(side_effect=_StopAfterCreate())
with pytest.raises(RuntimeError):
await provider.create_container(
Expand All @@ -146,7 +146,7 @@ async def test_gpu_container_create_passes_sandbox_tags():
@pytest.mark.asyncio
async def test_vm_create_passes_sandbox_tags():
provider = _patch_clients(ModalVmSandboxProvider(app_name="agent-env-test"))
with patch("agent_env.providers.sandbox_providers.modal_vm_sandbox.modal.Sandbox._experimental_create") as create:
with patch("agent_env.providers.sandbox_providers.modal_vm_sandbox.modal.Sandbox.create") as create:
create.aio = AsyncMock(side_effect=_StopAfterCreate())
with pytest.raises(RuntimeError):
await provider.create_vm(attribution={PIPELINE_STEP_KEY: "t_s"})
Expand Down
Loading