diff --git a/.gitmodules b/.gitmodules index 97ae8b8..6ac7bd5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "src/sandboxd"] path = src/sandboxd - url = https://github.com/inclusionAI/sandboxd.git + url = https://github.com/akernel-dev/sandboxd.git [submodule "src/distill-fs"] path = src/distill-fs url = https://github.com/inclusionAI/distill-fs.git diff --git a/AGENTS.md b/AGENTS.md index e405e96..cf5b95a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,11 @@ AKernel provides cluster-backed remote sandbox environments for agents and developer workflows. The current public user-facing surface is the Python `akernel-sdk`, including the `akernel_sdk.Sandbox` API and the `ak` CLI. The default sandbox runtime is gVisor runsc; callers may select Kata -Containers when the cluster has a KVM-capable node. Experimental whole-device -NVIDIA GPU and configurable writable-storage requests currently require -runsc. +Containers when the cluster has a KVM-capable node. Creation-time network +policies support unrestricted networking, blocking all traffic except the +YuanRong control proxy, or denying exact and leading-wildcard DNS names. +Experimental whole-device NVIDIA GPU and configurable writable-storage +requests currently require runsc. Use AKernel when a task needs an isolated remote environment with command execution, file operations, interactive PTYs, port forwarding, or reverse @@ -170,6 +172,15 @@ runsc; a Kata request fails scheduling with a no-resource error when no eligible node exists. Do not treat a configured runtime as an advertised runtime. +The bundled sandboxd configuration enables per-sandbox network ACLs. +ACL-capable nodes require eBPF `SCHED_CLS`, TC `clsact`, writable bpffs, +permission to load BPF programs and manage TC filters, and free TCP/UDP port +53 on the sandbox bridge. Drain existing sandboxes before enabling ACLs or +upgrading a node from a pre-ACL configuration; sandboxd refuses to initialize +ACLs when old sandbox records remain. A sandbox without a network policy stays +unrestricted. See `deploy/README.md` for deployment requirements and +`sdk/python/README.md` for API limits. + Dragonfly distribution is optional and disabled by default. Enable it during profile generation with `make config INSTALL_DRAGONFLY=true`. This installs the pinned public chart and, by default, creates three seed nodes and one server @@ -257,6 +268,15 @@ with Sandbox(xpu="gpu:l20:1", storage_mb=20 * 1024) as sb: print(sb.commands.run("nvidia-smi -L").stdout) ``` +Configure a creation-time network policy: + +```python +from akernel_sdk import NetworkPolicy, Sandbox + +with Sandbox(network=NetworkPolicy.block()) as sb: + print(sb.commands.run("echo control-plane-access").stdout) +``` + Required environment: ```bash diff --git a/README.md b/README.md index 4be7bed..ef1e345 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,16 @@ with Sandbox(cpu=2000, memory=4096) as sb: print(result.stdout) ``` +Creation-time network policies can either preserve unrestricted networking, +block all traffic except the YuanRong control proxy, or deny selected DNS +names: + +```python +from akernel_sdk import NetworkPolicy + +sandbox = Sandbox(network=NetworkPolicy.deny_dns("github.com", "*.github.com")) +``` + ### One-Click Deployment: From Laptop to Multi-Cloud One all-in-one image, multiple deployment targets — deploy in under 10 minutes: diff --git a/deploy/README.md b/deploy/README.md index 17e6fd0..36f12a7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -34,6 +34,27 @@ The node must support TC eBPF and bpffs. bpfnat does not manage host firewall policy, so custom host-network deployments must allow forwarding to and from the sandbox bridge when their `FORWARD` policy is `DROP`. +### Network ACLs + +The bundled standalone, Helm, and Terraform sandboxd configurations enable +per-sandbox network ACLs. A sandbox created without a policy remains on the +unrestricted fast path. ACL nodes require Linux eBPF `SCHED_CLS`, TC +`clsact`, supported hash and array maps, a writable bpffs at +`/sys/fs/bpf` (or permission to mount one), and permission to load BPF +programs and manage TC filters. TCP and UDP port 53 on the sandbox bridge +must be free, and sandboxd must have at least one usable upstream nameserver. +AKernel's node container is privileged so it can meet these requirements. + +Drain all sandboxes from a node before enabling ACLs or upgrading an existing +deployment to a release that enables them. Sandboxd deliberately refuses to +start ACL support when its store contains pre-ACL sandboxes, preventing a +silent fail-open migration. Start new sandboxes only after the upgraded +sandboxd is healthy. + +ACL enforcement is independent of the selected `iptables` or `bpfnat` NAT +backend. DNS policies manage each sandbox's `/etc/resolv.conf`; a caller +mount that owns that path is rejected while ACL support is enabled. + `make config` is interactive by default. It writes: - `.akernel/default/config.env` diff --git a/deploy/akernel/charts/core/values.yaml b/deploy/akernel/charts/core/values.yaml index 20d9982..5774525 100644 --- a/deploy/akernel/charts/core/values.yaml +++ b/deploy/akernel/charts/core/values.yaml @@ -464,6 +464,7 @@ node: [plugin.network] ip_range="172.17.0.1/16" nat_backend="iptables" + enable_network_acl=true [plugin.resource] cgroup_cache_size=800 diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md index 35d42a1..9814d35 100644 --- a/deploy/standalone/README.md +++ b/deploy/standalone/README.md @@ -55,6 +55,14 @@ later creation of `sandbox0` cannot change the advertised node address. Set `AKERNEL_NODE_IP` only when a multi-homed deployment requires an explicit override. +The standalone configuration enables per-sandbox network ACLs. Its privileged +node container can mount bpffs and manage the required eBPF TC filters. TCP +and UDP port 53 on the sandbox bridge must remain free for sandboxd's managed +DNS proxy. Before upgrading an existing standalone data directory to an +ACL-enabled image, terminate its sandboxes and stop the old node cleanly; +sandboxd refuses to initialize ACLs while pre-ACL sandboxes remain in its +store. + ## Directory Structure ``` diff --git a/deploy/standalone/config/sandboxd_config.toml b/deploy/standalone/config/sandboxd_config.toml index b0fa7dc..c26ea82 100644 --- a/deploy/standalone/config/sandboxd_config.toml +++ b/deploy/standalone/config/sandboxd_config.toml @@ -14,6 +14,7 @@ stream_server_port="" ip_range="10.88.0.1/16" # start.sh overrides this value when AKERNEL_NAT_BACKEND is set. nat_backend="iptables" +enable_network_acl=true # The all-in-one frontend shares this network namespace with sandboxd and # reaches forwarded sandbox ports through the node-local address. enable_local_dnat=true diff --git a/deploy/terraform/aliyun/values-akernel.yaml.tmpl b/deploy/terraform/aliyun/values-akernel.yaml.tmpl index 474fb19..c7f5358 100644 --- a/deploy/terraform/aliyun/values-akernel.yaml.tmpl +++ b/deploy/terraform/aliyun/values-akernel.yaml.tmpl @@ -179,6 +179,7 @@ node: [plugin.network] ip_range="172.17.0.1/16" nat_backend="${sandboxd_nat_backend}" + enable_network_acl=true [plugin.resource] cgroup_cache_size=800 diff --git a/deploy/terraform/huaweicloud/values-akernel.yaml.tmpl b/deploy/terraform/huaweicloud/values-akernel.yaml.tmpl index 1d8622a..ef94361 100644 --- a/deploy/terraform/huaweicloud/values-akernel.yaml.tmpl +++ b/deploy/terraform/huaweicloud/values-akernel.yaml.tmpl @@ -137,6 +137,7 @@ node: [plugin.network] ip_range="172.17.0.1/16" nat_backend="${sandboxd_nat_backend}" + enable_network_acl=true [plugin.resource] cgroup_cache_size=800 diff --git a/sdk/python/README.md b/sdk/python/README.md index bdc9633..8aa79d7 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -16,6 +16,7 @@ It supports two backends: - [Install and configure](#install-and-configure) - [Create a sandbox](#create-a-sandbox) - [Experimental GPU and writable storage](#experimental-gpu-and-writable-storage) + - [Network ACLs](#network-acls) - [Sandbox runtimes](#sandbox-runtimes) - [Commands](#commands) - [Filesystem](#filesystem) @@ -99,6 +100,7 @@ Sandbox( *, xpu: str | None = None, storage_mb: int | None = None, + network: NetworkPolicy | None = None, ) ``` @@ -129,6 +131,63 @@ default 10 GiB memory-backed writable overlay. See [`examples/gpu_sandbox.py`](./examples/gpu_sandbox.py) and [`examples/storage_sandbox.py`](./examples/storage_sandbox.py). +### Network ACLs + +Omit `network` to leave all sandbox networking unrestricted. An empty +`NetworkPolicy()` is equivalent and is omitted from the creation request: + +```python +from akernel_sdk import NetworkPolicy, Sandbox + +with Sandbox() as unrestricted: + print(unrestricted.commands.run("python3 -c 'import socket; " + "socket.getaddrinfo(\"github.com\", 443)'")) +``` + +Block all sandbox traffic except the YuanRong control proxy: + +```python +with Sandbox(network=NetworkPolicy.block()) as sandbox: + result = sandbox.commands.run("printf 'control plane still works'") + assert result.exit_code == 0 +``` + +Commands and lifecycle operations continue to work in block mode. The SDK +also falls back from its direct filesystem data path to RuntimeRPC transfers, +so file reads, writes, and copies keep working but bulk transfers can be +slower. + +Deny conventional DNS lookups for exact names or leading `*.` suffix +patterns: + +```python +policy = NetworkPolicy.deny_dns("github.com", "*.github.com") +with Sandbox(network=policy) as sandbox: + blocked = sandbox.commands.run( + "python3 -c 'import socket; socket.getaddrinfo(\"github.com\", 443)'" + ) + assert blocked.exit_code != 0 +``` + +An exact pattern matches only that name. For example, `github.com` does not +match `api.github.com`, while `*.github.com` matches descendants but not +the apex. Supply both when both should be denied. Patterns are normalized to +lower case without a trailing dot; international names must use ASCII +punycode. + +Network policies are fixed when a sandbox is created. `block_network` and +`dns_blacklist` cannot be combined in the current SDK. DNS blacklists cover +ordinary UDP and TCP DNS and return a refused response for blocked queries; +DNS-over-HTTPS and connections to a known IP are outside their scope. The +packet ACL is currently IPv4 and stateless. + +See [`examples/network_policy.py`](./examples/network_policy.py) for all +three modes. Deployment nodes must have network ACL support enabled; the +bundled standalone, Helm, and Terraform configurations enable it. Drain +existing sandboxes before upgrading a node to an ACL-enabled sandboxd +configuration, as described in the +[deployment guide](../../deploy/README.md#network-acls). + ## Sandbox runtimes AKernel uses the gVisor `runsc` runtime when `runtime` is omitted. Callers may also select `runsc` explicitly or request Kata Containers: @@ -389,6 +448,7 @@ Maintained examples are under [`examples/`](./examples): - `custom_image.py` - `gpu_sandbox.py` - `named_sandbox.py` +- `network_policy.py` - `pty.py` - `port_forwarding.py` - `reverse_tunnel.py` @@ -425,3 +485,4 @@ not part of the default test suite. | `S3Config` | `endpoint`, `bucket`, `object`, optional credentials | | `Mount` | `target`, one source, and `type` | | `HttpReverseTunnel` | `target`, `reverse_port`, `listen_port`, `connect_timeout` | +| `NetworkPolicy` | `block_network`, `dns_blacklist` | diff --git a/sdk/python/akernel_sdk/__init__.py b/sdk/python/akernel_sdk/__init__.py index 7226f65..0134a4c 100644 --- a/sdk/python/akernel_sdk/__init__.py +++ b/sdk/python/akernel_sdk/__init__.py @@ -29,6 +29,7 @@ EntryInfo, HttpReverseTunnel, Mount, + NetworkPolicy, NodeInfo, S3Config, SandboxInfo, @@ -38,6 +39,7 @@ "Sandbox", "S3Config", "Mount", + "NetworkPolicy", "HttpReverseTunnel", "CommandResult", "CommandInfo", diff --git a/sdk/python/akernel_sdk/_backends/base.py b/sdk/python/akernel_sdk/_backends/base.py index 93070c1..171b3c8 100644 --- a/sdk/python/akernel_sdk/_backends/base.py +++ b/sdk/python/akernel_sdk/_backends/base.py @@ -28,6 +28,7 @@ EntryInfo, HttpReverseTunnel, Mount, + NetworkPolicy, S3Config, SandboxInfo, ) @@ -75,6 +76,7 @@ class SandboxSpec: node_id: str | None xpu: str | None storage_mb: int | None + network: NetworkPolicy | None class CommandsDriver(Protocol): diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py index fa04b82..3159dcd 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py @@ -327,7 +327,7 @@ def create(self, spec: SandboxSpec) -> BackendSession: self._validate(spec) sandbox_type = yr_sandbox.Sandbox if spec.runtime == "kata" and spec.image is None and spec.rootfs is None: - # openyuanrong-sandbox 0.9.3 forwards the isolation runtime only + # openyuanrong-sandbox forwards the isolation runtime only # through an explicit rootfs. Keep this aligned with the actor # backend until frontend can override the service rootfs runtime. sandbox_type = _LocalRootfsSandbox @@ -340,6 +340,12 @@ def create(self, spec: SandboxSpec) -> BackendSession: access_key=spec.rootfs.access_key, secret_key=spec.rootfs.secret_key, ) + network = None + if spec.network is not None: + network = yr_sandbox.NetworkPolicy( + block_network=spec.network.block_network, + dns_blacklist=spec.network.dns_blacklist, + ) mounts = [ yr_sandbox.Mount( target=mount.target, @@ -390,6 +396,7 @@ def create(self, spec: SandboxSpec) -> BackendSession: node_id=spec.node_id, xpu=spec.xpu, storage_mb=spec.storage_mb, + network=network, create_timeout=create_timeout, ) except Exception as error: diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py index 63d1485..9b0a0ee 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py @@ -290,6 +290,7 @@ def create(self, spec: SandboxSpec) -> BackendSession: node_id=spec.node_id, xpu=spec.xpu, storage_mb=spec.storage_mb, + network=spec.network, ) try: instance = _impl.create_instance( diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py index c45674d..0ddf912 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py @@ -40,7 +40,13 @@ validate_storage_mb, xpu_custom_resource, ) -from ..types import HttpReverseTunnel, Mount, NodeInfo, S3Config +from ..types import ( + HttpReverseTunnel, + Mount, + NetworkPolicy, + NodeInfo, + S3Config, +) logger = logging.getLogger(__name__) @@ -159,6 +165,7 @@ def build_options( node_id: str | None, xpu: str | None, storage_mb: int | None, + network: NetworkPolicy | None, ) -> Any: """Translate the stable SDK configuration to openYuanrong options.""" @@ -209,6 +216,8 @@ def build_options( options.custom_resources[resource_name] = count if storage_mb is not None: options.custom_resources["storage"] = storage_bytes(storage_mb) + if network is not None: + options.custom_extensions["network_policy"] = json.dumps(network.to_dict()) forwarded = list(port_forwardings) if reverse_tunnel is not None: diff --git a/sdk/python/akernel_sdk/sandbox.py b/sdk/python/akernel_sdk/sandbox.py index f372538..9738495 100644 --- a/sdk/python/akernel_sdk/sandbox.py +++ b/sdk/python/akernel_sdk/sandbox.py @@ -31,7 +31,7 @@ from .commands import Commands from .filesystem import Filesystem from .pty import Pty -from .types import HttpReverseTunnel, Mount, S3Config, SandboxInfo +from .types import HttpReverseTunnel, Mount, NetworkPolicy, S3Config, SandboxInfo _SUPPORTED_RUNTIMES = ("runsc", "kata") _traefik_internal_ip_cache: str | None = None @@ -134,6 +134,7 @@ def __init__( *, xpu: str | None = None, storage_mb: int | None = None, + network: NetworkPolicy | None = None, ) -> None: """Create and wait for a sandbox to become ready. @@ -161,6 +162,8 @@ def __init__( storage_mb: Experimental writable root filesystem quota in MiB. When omitted, the configured default is used. Explicit quotas currently require the ``runsc`` runtime. + network: Optional creation-time network policy. Omitting it leaves + sandbox networking unrestricted. Raises: TypeError: An argument has an invalid type. @@ -181,6 +184,8 @@ def __init__( ) normalized_xpu = normalize_xpu(xpu) validate_storage_mb(storage_mb) + if network is not None and not isinstance(network, NetworkPolicy): + raise TypeError("network must be a NetworkPolicy or None") if normalized_xpu is not None and runtime != "runsc": raise ValueError("xpu is currently supported only by runsc") if storage_mb is not None and runtime != "runsc": @@ -265,6 +270,7 @@ def __init__( node_id=node_id, xpu=normalized_xpu, storage_mb=storage_mb, + network=None if network is None or network.is_empty else network, ) self._session = load_backend().create(spec) try: diff --git a/sdk/python/akernel_sdk/types.py b/sdk/python/akernel_sdk/types.py index f93a1f3..42d99e3 100644 --- a/sdk/python/akernel_sdk/types.py +++ b/sdk/python/akernel_sdk/types.py @@ -16,6 +16,7 @@ from __future__ import annotations +import re from dataclasses import dataclass, field from typing import Any from urllib.parse import urlparse @@ -28,6 +29,85 @@ YR_GET_TIMEOUT_BUFFER = 30 +_DNS_LABEL_PATTERN = re.compile(r"^[a-z0-9_-]+$") + + +def _normalize_dns_pattern(pattern: str) -> str: + if not isinstance(pattern, str): + raise TypeError("dns blacklist patterns must be strings") + value = pattern.strip().lower().rstrip(".") + wildcard = value.startswith("*.") + if wildcard: + value = value[2:] + if not value or "*" in value or "?" in value or len(value) > 253: + raise ValueError(f"invalid DNS blacklist pattern: {pattern!r}") + for label in value.split("."): + if ( + not label + or len(label) > 63 + or label.startswith("-") + or label.endswith("-") + or _DNS_LABEL_PATTERN.fullmatch(label) is None + ): + raise ValueError(f"invalid DNS blacklist pattern: {pattern!r}") + return f"*.{value}" if wildcard else value + + +@dataclass(frozen=True) +class NetworkPolicy: + """Creation-time network policy for an AKernel sandbox. + + Use :meth:`block` to deny all traffic except the YuanRong control proxy, + or :meth:`deny_dns` to reject conventional DNS queries matching exact + names or leading ``*.`` suffix patterns. + """ + + block_network: bool = False + dns_blacklist: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.block_network, bool): + raise TypeError("block_network must be a boolean") + if isinstance(self.dns_blacklist, (str, bytes)): + raise TypeError("dns_blacklist must be a sequence of patterns") + normalized = tuple( + dict.fromkeys(_normalize_dns_pattern(item) for item in self.dns_blacklist) + ) + if self.block_network and normalized: + raise ValueError("block_network and dns_blacklist cannot be combined") + object.__setattr__(self, "dns_blacklist", normalized) + + @classmethod + def block(cls) -> NetworkPolicy: + """Deny all network traffic except the YuanRong control proxy.""" + + return cls(block_network=True) + + @classmethod + def deny_dns(cls, *patterns: str) -> NetworkPolicy: + """Deny DNS queries matching the supplied domain patterns.""" + + if not patterns: + raise ValueError("deny_dns requires at least one domain pattern") + return cls(dns_blacklist=patterns) + + @property + def is_empty(self) -> bool: + """Whether this policy has no effect and should be omitted.""" + + return not self.block_network and not self.dns_blacklist + + def to_dict(self) -> dict[str, Any]: + """Return the JSON-compatible public API representation.""" + + value: dict[str, Any] = {} + if self.block_network: + value["blockNetwork"] = True + if self.dns_blacklist: + value["dnsBlacklist"] = list(self.dns_blacklist) + return value + + @dataclass(frozen=True) class EntryInfo: """Metadata for a filesystem entry inside a sandbox.""" diff --git a/sdk/python/examples/network_policy.py b/sdk/python/examples/network_policy.py new file mode 100644 index 0000000..206fed5 --- /dev/null +++ b/sdk/python/examples/network_policy.py @@ -0,0 +1,65 @@ +# Copyright (c) 2026 Ant Group Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exercise unrestricted, fully blocked, and DNS-denylisted networking.""" + +import shlex + +from akernel_sdk import NetworkPolicy, Sandbox + + +def dns_lookup(domain: str) -> str: + program = "import socket,sys; print(socket.getaddrinfo(sys.argv[1], 443)[0][4][0])" + return f"python3 -c {shlex.quote(program)} {shlex.quote(domain)}" + + +def direct_connection() -> str: + program = ( + "import socket; " + "connection=socket.create_connection(('1.1.1.1', 53), 3); " + "connection.close()" + ) + return f"python3 -c {shlex.quote(program)}" + + +def main() -> None: + with Sandbox() as unrestricted: + result = unrestricted.commands.run(dns_lookup("github.com"), timeout=30) + assert result.exit_code == 0, result.stderr + print(f"Unrestricted DNS result: {result.stdout.strip()}") + + with Sandbox(network=NetworkPolicy.block()) as blocked: + control = blocked.commands.run("printf 'control plane works'") + assert control.exit_code == 0, control.stderr + + external = blocked.commands.run(direct_connection(), timeout=10) + assert external.exit_code != 0 + print("Block policy denied an external connection.") + + blocked.files.write("/tmp/acl.txt", "RuntimeRPC fallback") + assert blocked.files.read("/tmp/acl.txt") == "RuntimeRPC fallback" + print("Commands and filesystem operations remain available.") + + dns_policy = NetworkPolicy.deny_dns("github.com", "*.github.com") + with Sandbox(network=dns_policy) as dns_filtered: + denied = dns_filtered.commands.run(dns_lookup("github.com"), timeout=30) + assert denied.exit_code != 0 + + allowed = dns_filtered.commands.run(dns_lookup("example.com"), timeout=30) + assert allowed.exit_code == 0, allowed.stderr + print(f"Allowed DNS result: {allowed.stdout.strip()}") + + +if __name__ == "__main__": + main() diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index b4d284f..3b03fa2 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ "Topic :: System :: Distributed Computing", ] dependencies = [ - "openyuanrong-sandbox==0.9.3", + "openyuanrong-sandbox==0.10.0", "websockets>=10.0", ] diff --git a/sdk/python/tests/unit/test_backends.py b/sdk/python/tests/unit/test_backends.py index ef0cda7..d2689d4 100644 --- a/sdk/python/tests/unit/test_backends.py +++ b/sdk/python/tests/unit/test_backends.py @@ -36,6 +36,7 @@ EntryInfo, HttpReverseTunnel, Mount, + NetworkPolicy, S3Config, ) @@ -61,6 +62,7 @@ def _spec(**overrides): "node_id": None, "xpu": None, "storage_mb": None, + "network": None, } values.update(overrides) return SandboxSpec(**values) @@ -279,6 +281,25 @@ def test_create_converts_inputs_and_preserves_akernel_outputs(self): session.close() native.kill.assert_called_once_with() + def test_create_converts_network_policy_to_native_sdk_type(self): + native = MagicMock() + native.id = "default-worker" + native.commands = MagicMock() + native.files = MagicMock() + policy = NetworkPolicy.deny_dns("github.com", "*.github.com") + with patch.object( + openyuanrong_sandbox.yr_sandbox, + "Sandbox", + return_value=native, + ) as sandbox_type: + session = self.backend.create(_spec(network=policy)) + + network = sandbox_type.call_args.kwargs["network"] + self.assertIsInstance(network, openyuanrong_sandbox.yr_sandbox.NetworkPolicy) + self.assertFalse(network.block_network) + self.assertEqual(network.dns_blacklist, ("github.com", "*.github.com")) + session.close() + def test_terminate_forces_deletion_of_detached_native_sandbox(self): native = MagicMock() native.id = "default-worker" diff --git a/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py b/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py index 3ad9a01..81992df 100644 --- a/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py +++ b/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py @@ -16,7 +16,7 @@ import unittest from unittest.mock import MagicMock, patch -from akernel_sdk import HttpReverseTunnel, Mount, S3Config +from akernel_sdk import HttpReverseTunnel, Mount, NetworkPolicy, S3Config from akernel_sdk._backends import openyuanrong_sdk_impl as _impl @@ -41,6 +41,7 @@ def build_options(self, **overrides): "node_id": None, "xpu": None, "storage_mb": None, + "network": None, } values.update(overrides) return _impl.build_options(**values) @@ -125,6 +126,16 @@ def test_xpu_and_storage_require_runsc(self): with self.assertRaisesRegex(ValueError, "storage_mb.*runsc"): self.build_options(runtime="kata", storage_mb=256) + def test_network_policy_uses_custom_extension_wire_format(self): + options = self.build_options( + network=NetworkPolicy.deny_dns("github.com", "*.github.com") + ) + + self.assertEqual( + json.loads(options.custom_extensions["network_policy"]), + {"dnsBlacklist": ["github.com", "*.github.com"]}, + ) + def test_node_info_conversion(self): node = _impl._to_node_info( { diff --git a/sdk/python/tests/unit/test_sandbox.py b/sdk/python/tests/unit/test_sandbox.py index d7b4285..08620a0 100644 --- a/sdk/python/tests/unit/test_sandbox.py +++ b/sdk/python/tests/unit/test_sandbox.py @@ -16,7 +16,7 @@ import unittest from unittest.mock import MagicMock, patch -from akernel_sdk import HttpReverseTunnel, S3Config, Sandbox +from akernel_sdk import HttpReverseTunnel, NetworkPolicy, S3Config, Sandbox from akernel_sdk import sandbox as sandbox_module from akernel_sdk.types import SandboxInfo @@ -61,6 +61,7 @@ def test_default_constructor_and_info(self): self.assertEqual(dict(spec.env), {}) self.assertIsNone(spec.xpu) self.assertIsNone(spec.storage_mb) + self.assertIsNone(spec.network) sandbox.kill() self.session.terminate.assert_called_once_with() self.session.close.assert_called_once_with() @@ -181,6 +182,53 @@ def test_storage_request_validation(self): Sandbox(runtime="kata", storage_mb=256) self.backend.create.assert_not_called() + def test_block_network_policy_is_passed_to_backend(self): + policy = NetworkPolicy.block() + + sandbox = Sandbox(network=policy) + + spec = self.backend.create.call_args.args[0] + self.assertIs(spec.network, policy) + self.assertEqual(policy.to_dict(), {"blockNetwork": True}) + sandbox.kill() + + def test_dns_blacklist_is_normalized_and_passed_to_backend(self): + policy = NetworkPolicy.deny_dns("GitHub.COM.", "*.GitHub.com", "github.com") + + sandbox = Sandbox(network=policy) + + spec = self.backend.create.call_args.args[0] + self.assertEqual( + spec.network.to_dict(), + {"dnsBlacklist": ["github.com", "*.github.com"]}, + ) + sandbox.kill() + + def test_empty_network_policy_is_treated_as_unrestricted(self): + sandbox = Sandbox(network=NetworkPolicy()) + + spec = self.backend.create.call_args.args[0] + self.assertIsNone(spec.network) + sandbox.kill() + + def test_invalid_network_policy_is_rejected_before_backend(self): + invalid_factories = ( + lambda: NetworkPolicy(block_network="yes"), + lambda: NetworkPolicy(dns_blacklist="github.com"), + lambda: NetworkPolicy.deny_dns(), + lambda: NetworkPolicy.deny_dns("github.*"), + lambda: NetworkPolicy(block_network=True, dns_blacklist=("github.com",)), + ) + for factory in invalid_factories: + with ( + self.subTest(factory=factory), + self.assertRaises((TypeError, ValueError)), + ): + factory() + with self.assertRaisesRegex(TypeError, "NetworkPolicy"): + Sandbox(network={"blockNetwork": True}) + self.backend.create.assert_not_called() + def test_cwd_must_be_absolute(self): with self.assertRaisesRegex(ValueError, "absolute POSIX"): Sandbox(cwd="workspace") diff --git a/sdk/python/tests/unit/test_types.py b/sdk/python/tests/unit/test_types.py index d214962..13ac5cc 100644 --- a/sdk/python/tests/unit/test_types.py +++ b/sdk/python/tests/unit/test_types.py @@ -53,6 +53,7 @@ def test_public_exports_are_minimal(self): "Sandbox", "S3Config", "Mount", + "NetworkPolicy", "HttpReverseTunnel", "CommandResult", "CommandInfo", diff --git a/src/sandboxd b/src/sandboxd index 87acb31..9fe4af3 160000 --- a/src/sandboxd +++ b/src/sandboxd @@ -1 +1 @@ -Subproject commit 87acb317ca38b7c7b4c87ee75eb99ac50eb9237c +Subproject commit 9fe4af3e5bf2f0ff9ece5085f590b61620b92107 diff --git a/src/yuanrong b/src/yuanrong index 0ec671c..8830c1b 160000 --- a/src/yuanrong +++ b/src/yuanrong @@ -1 +1 @@ -Subproject commit 0ec671c97cdbaf758583a5a5c50da9ac36050826 +Subproject commit 8830c1b1081b83d3371407221f5086f9bee7d474