Skip to content

Commit 703747d

Browse files
authored
Toggle modal dynamic network configuration based on phase_network_policies (harbor-framework#2092)
1 parent 701c90a commit 703747d

4 files changed

Lines changed: 69 additions & 26 deletions

File tree

docs/content/docs/tasks/network-policy.mdx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,12 @@ Network policies can be specified for the following phases:
7272
| Phase | Description | Supported environments |
7373
| --- | --- | --- |
7474
| `[environment]` | The baseline network policy configured at environment start time. | Any environment that supports the requested network mode |
75-
| `[agent]` | Network access during `agent.run()` phase. This requires the environment provider to support dynamic network policy switching. This overrides the `[environment]` baseline. | `docker`⁴, `e2b`, `islo`, `modal` |
76-
| `[verifier]` | Network access during `verify()` phase. This requires the environment provider to support dynamic network policy switching. This overrides the `[environment]` baseline. | `docker`⁴, `e2b`, `islo`, `modal` |
75+
| `[agent]` | Network access during `agent.run()` phase. This requires the environment provider to support dynamic network policy switching. This overrides the `[environment]` baseline. | `docker`⁴, `e2b`, `islo`, `modal`¹ |
76+
| `[verifier]` | Network access during `verify()` phase. This requires the environment provider to support dynamic network policy switching. This overrides the `[environment]` baseline. | `docker`⁴, `e2b`, `islo`, `modal`¹ |
7777
| `[verifier.environment]` | The baseline network policy configured at verifier environment start time, when using a separate verifier environment. | Any environment that supports the requested network mode |
7878

7979
Baseline phases are subject to the environment supporting the requested network mode (see the table above).
8080

81-
⁵ Modal requires the `dynamic_network=true` environment kwarg (`--ek dynamic_network=true`).
82-
8381
These phases can also be specified at the step-level, when using [multi-step tasks](/docs/tasks/multi-step).
8482

8583
## Capabilities
@@ -90,13 +88,12 @@ Each `BaseEnvironment` implementation declares an `EnvironmentCapabilities` mode
9088
| --- | --- | --- |
9189
| `disable_internet` | The environment can run containers without internet access (`no-network`). | `docker`³, `daytona`, `e2b`, `langsmith`, `tensorlake`, `cwsandbox`, `wandb`, `runloop`, `modal`¹, `gke`², `ec2`, `novita`, `islo`, `blaxel` |
9290
| `network_allowlist` | The environment can restrict egress to configured hostnames (`allowlist`). | `docker`⁴, `e2b`, `islo`, `runloop`, `modal`¹, `novita`¹, `blaxel`¹ |
93-
| `dynamic_network_policy` | The environment can switch the active network policy after start, enabling `[agent]` and `[verifier]` phase overrides. | `docker`⁴, `e2b`, `islo`, `modal` |
91+
| `dynamic_network_policy` | The environment can switch the active network policy after start, enabling `[agent]` and `[verifier]` phase overrides. | `docker`⁴, `e2b`, `islo`, `modal`¹ |
9492

9593
¹ Single-container tasks only (not in Docker Compose mode).
9694
² Docker Compose (multi-container) tasks only.
9795
³ Docker support requires Linux containers; Docker Windows containers do not support this network policy mode.
9896
⁴ Docker support requires Linux containers and local Docker runtime support for the nftables kernel features used by Harbor's egress-control sidecar.
99-
⁵ Modal requires the `dynamic_network=true` environment kwarg (`--ek dynamic_network=true`).
10097

10198
If a task requests a network mode the environment does not declare a capability for, Harbor rejects the trial at validation time rather than running with a weaker policy.
10299

src/harbor/environments/modal.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import tempfile
88
from abc import abstractmethod
99
from collections import OrderedDict
10-
from collections.abc import Mapping
10+
from collections.abc import Mapping, Sequence
1111
from pathlib import Path
1212
from threading import Lock
1313
from typing import Any, override
@@ -883,6 +883,8 @@ def __init__(
883883
auto_labels: bool = True,
884884
labels: dict[str, str] | None = None,
885885
extra_docker_compose: list[Path] | None = None,
886+
network_policy: NetworkPolicy | None = None,
887+
phase_network_policies: Sequence[NetworkPolicy] | None = None,
886888
*args,
887889
**kwargs,
888890
):
@@ -921,26 +923,23 @@ def __init__(
921923
kwargs: Model-specific settings from ``environment.kwargs`` / ``--ek``
922924
- ``modal_vm_runtime=true``: Use vm_runtime (alpha feature).
923925
See https://modal.com/docs/guide/vm-sandboxes for more details.
924-
- ``dynamic_network=true``: Allow sandbox egress
925-
networking to be dynamically configured at runtime.
926926
"""
927927
self._vm_runtime_enabled = parse_bool_env_value(
928928
kwargs.get("modal_vm_runtime", False), name="modal_vm_runtime"
929929
)
930-
self._dynamic_network = parse_bool_env_value(
931-
kwargs.get("dynamic_network", False), name="dynamic_network"
930+
startup_network_policy = network_policy or NetworkPolicy()
931+
resolved_phase_network_policies = tuple(phase_network_policies or ())
932+
self._dynamic_network = self._requires_dynamic_network(
933+
startup_network_policy=startup_network_policy,
934+
phase_network_policies=resolved_phase_network_policies,
932935
)
933936

934937
# Detect compose mode *before* super().__init__ which calls
935938
# _validate_definition
936939
self._compose_mode = (environment_dir / "docker-compose.yaml").exists() or bool(
937940
extra_docker_compose
938941
)
939-
if self._dynamic_network and self._compose_mode:
940-
raise ValueError(
941-
"dynamic_network is only supported in Modal direct "
942-
"(single-container) mode, not Docker Compose mode."
943-
)
942+
self._dynamic_network = self._dynamic_network and not self._compose_mode
944943
# DinD mode requires host networking — cannot enforce network isolation.
945944
self._capabilities = EnvironmentCapabilities(
946945
gpus=not self._vm_runtime_enabled, # Not supported as of 2026-06-11
@@ -967,6 +966,8 @@ def __init__(
967966
trial_paths=trial_paths,
968967
task_env_config=task_env_config,
969968
extra_docker_compose=extra_docker_compose,
969+
network_policy=startup_network_policy,
970+
phase_network_policies=resolved_phase_network_policies,
970971
**kwargs,
971972
)
972973
self._image: Image | None = None
@@ -987,6 +988,16 @@ def __init__(
987988
if self._vm_runtime_enabled:
988989
self.logger.debug(f"Using vm_runtime: {self._vm_runtime_enabled}")
989990

991+
@staticmethod
992+
def _requires_dynamic_network(
993+
*,
994+
startup_network_policy: NetworkPolicy,
995+
phase_network_policies: Sequence[NetworkPolicy],
996+
) -> bool:
997+
return any(
998+
policy != startup_network_policy for policy in phase_network_policies
999+
)
1000+
9901001
@property
9911002
def _default_shell(self) -> str:
9921003
"""Shell available on the sandbox VM.

tests/integration/environments/test_modal_network_live.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def _make_live_env(
3636
tmp_path: Path,
3737
network_policy: NetworkPolicy,
3838
*,
39-
dynamic_network: bool = False,
39+
phase_network_policies: list[NetworkPolicy] | None = None,
4040
) -> ModalEnvironment:
4141
env_dir = tmp_path / "environment"
4242
env_dir.mkdir()
@@ -54,7 +54,7 @@ def _make_live_env(
5454
trial_paths=trial_paths,
5555
task_env_config=EnvironmentConfig(),
5656
network_policy=network_policy,
57-
dynamic_network=dynamic_network,
57+
phase_network_policies=phase_network_policies,
5858
)
5959

6060

@@ -116,7 +116,13 @@ async def test_modal_dynamic_network_switching(tmp_path):
116116
env = _make_live_env(
117117
tmp_path,
118118
NetworkPolicy(network_mode=NetworkMode.PUBLIC),
119-
dynamic_network=True,
119+
phase_network_policies=[
120+
NetworkPolicy(
121+
network_mode=NetworkMode.ALLOWLIST,
122+
allowed_hosts=["example.com"],
123+
),
124+
NetworkPolicy(network_mode=NetworkMode.NO_NETWORK),
125+
],
120126
)
121127
try:
122128
await env.start(force_build=False)

tests/unit/environments/test_modal.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def _make_env(
5050
mounts: list[ServiceVolumeConfig] | None = None,
5151
extra_docker_compose: list[Path] | None = None,
5252
network_policy: NetworkPolicy | None = None,
53+
phase_network_policies: list[NetworkPolicy] | None = None,
5354
environment_kwargs: dict[str, object] | None = None,
5455
) -> ModalEnvironment:
5556
env_dir = temp_dir / "environment"
@@ -89,6 +90,7 @@ def _make_env(
8990
env=task_env or {},
9091
),
9192
network_policy=network_policy or NetworkPolicy(network_mode=NetworkMode.PUBLIC),
93+
phase_network_policies=phase_network_policies,
9294
cpu_enforcement_policy=cpu_mode,
9395
memory_enforcement_policy=memory_mode,
9496
**extra,
@@ -257,23 +259,50 @@ async def _create_kwargs(env, monkeypatch) -> dict:
257259
return sandbox_cls.create.aio.await_args.kwargs
258260

259261
@staticmethod
260-
def _dynamic_env(temp_dir, policy: NetworkPolicy | None = None) -> ModalEnvironment:
262+
def _dynamic_env(
263+
temp_dir,
264+
policy: NetworkPolicy | None = None,
265+
phase_policies: list[NetworkPolicy] | None = None,
266+
) -> ModalEnvironment:
267+
baseline = policy or NetworkPolicy(network_mode=NetworkMode.PUBLIC)
261268
return _make_env(
262269
temp_dir,
263-
network_policy=policy,
264-
environment_kwargs={"dynamic_network": True},
270+
network_policy=baseline,
271+
phase_network_policies=phase_policies
272+
or [
273+
NetworkPolicy(
274+
network_mode=NetworkMode.ALLOWLIST,
275+
allowed_hosts=["api.example.com"],
276+
)
277+
],
265278
)
266279

267280
def test_disabled_by_default(self, temp_dir):
268281
assert _make_env(temp_dir).capabilities.dynamic_network_policy is False
269282

270-
def test_opt_in_advertises_dynamic_policy(self, temp_dir):
283+
def test_mismatched_phase_policy_advertises_dynamic_policy(self, temp_dir):
271284
assert self._dynamic_env(temp_dir).capabilities.dynamic_network_policy is True
272285

273-
def test_compose_mode_rejects_dynamic(self, temp_dir):
274-
with pytest.raises(ValueError, match="dynamic_network"):
286+
def test_matching_phase_policy_does_not_advertise_dynamic_policy(self, temp_dir):
287+
policy = NetworkPolicy(network_mode=NetworkMode.PUBLIC)
288+
env = _make_env(
289+
temp_dir,
290+
network_policy=policy,
291+
phase_network_policies=[policy],
292+
)
293+
assert env.capabilities.dynamic_network_policy is False
294+
295+
def test_compose_mode_rejects_mismatched_phase_policy(self, temp_dir):
296+
with pytest.raises(ValueError, match="allowlist"):
275297
_make_env(
276-
temp_dir, compose=True, environment_kwargs={"dynamic_network": True}
298+
temp_dir,
299+
compose=True,
300+
phase_network_policies=[
301+
NetworkPolicy(
302+
network_mode=NetworkMode.ALLOWLIST,
303+
allowed_hosts=["api.example.com"],
304+
)
305+
],
277306
)
278307

279308
async def test_create_public_starts_allow_all(self, temp_dir, monkeypatch):

0 commit comments

Comments
 (0)