From 1185199c7fb3b0ef27186c66e14119c8579332d7 Mon Sep 17 00:00:00 2001 From: Tianyu Zhou Date: Mon, 24 Aug 2026 01:31:22 +0800 Subject: [PATCH 1/5] feat(network): expose ACL v2 policies Add backend-neutral Python types for bidirectional IPv4 traffic and DNS policies, plus a creation-time egress allowlist helper. Normalize domains, CIDRs, protocols, ranges, and priorities in the SDK and translate the model to the openYuanRong sandbox backend. Advance YuanRong and sandboxd to the matching ACL v2 implementations and prepare standalone, Helm, and Terraform nodes with the required iptables, ipset, conntrack, bridge-netfilter, or eBPF capabilities. Document runtime semantics, host requirements, and migration constraints, and cover the new public contract with SDK and deployment tests. Signed-off-by: Tianyu Zhou --- AGENTS.md | 29 +- README.md | 2 +- builder/node.Dockerfile | 1 + builder/scripts/sandboxd_network_prepare.sh | 4 +- deploy/README.md | 28 +- deploy/standalone/README.md | 7 +- deploy/standalone/start.sh | 33 +- deploy/terraform/aliyun/variables.tf | 2 +- deploy/terraform/huaweicloud/variables.tf | 2 +- .../terraform/shared/node-bootstrap.sh.tftpl | 5 +- sdk/python/README.md | 94 ++++- sdk/python/akernel_sdk/__init__.py | 10 + .../_backends/openyuanrong_sandbox.py | 68 +++- sdk/python/akernel_sdk/types.py | 328 +++++++++++++++++- sdk/python/examples/network_policy.py | 29 +- sdk/python/pyproject.toml | 9 +- sdk/python/tests/unit/test_backends.py | 40 ++- .../tests/unit/test_openyuanrong_sdk_impl.py | 32 +- sdk/python/tests/unit/test_sandbox.py | 136 +++++++- sdk/python/tests/unit/test_types.py | 5 + src/sandboxd | 2 +- src/yuanrong | 2 +- 22 files changed, 781 insertions(+), 87 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b6e11c3..497e310 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,14 +219,19 @@ usable `/dev/kvm` device. The bundled sandboxd configuration enables per-sandbox network ACLs. Pooled TAP networking requires the host `tun` module and a usable `/dev/net/tun`. The -default iptables backend additionally requires `br_netfilter`, conntrack, -connmark/CONNMARK, and bridge netfilter. The optional bpfnat backend instead -requires eBPF `SCHED_CLS`, TC `clsact`, writable bpffs, and permission to load -BPF programs and manage TC filters. Both require free TCP/UDP port 53 on the -sandbox bridge. Drain existing sandboxes before enabling ACLs or upgrading a +default iptables backend additionally requires `iptables`, `ip6tables`, +`ipset`, IPv4/IPv6 filter tables, `br_netfilter`, `xt_physdev`, conntrack, +conntrack-netlink, connmark/CONNMARK, timeout-capable `hash:ip` sets, and +IPv4/IPv6 bridge netfilter. The optional bpfnat backend +instead requires Linux 5.17 or newer for `bpf_loop`, eBPF `SCHED_CLS`, TC +`clsact`, writable bpffs, and permission to load BPF programs and manage TC +filters. Both require 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 +unrestricted. Schema v2 supports independent ingress and egress defaults, +allow and deny rules over IPv4 CIDRs, domains, protocols, and ports, plus an +independent DNS policy. 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 @@ -352,6 +357,18 @@ with Sandbox(network_policy=NetworkPolicy.block()) as sb: print(sb.commands.run("echo control-plane-access").stdout) ``` +Configure a generic egress allowlist: + +```python +from akernel_sdk import NetworkPolicy, NetworkRule + +policy = NetworkPolicy.allowlist( + [NetworkRule(domain="*.example.com", protocol="tcp", port_range=443)] +) +with Sandbox(network_policy=policy) as sb: + print(sb.commands.run("curl https://api.example.com").stdout) +``` + Required environment: ```bash diff --git a/README.md b/README.md index b5754c3..d59b62d 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ See the complete [basic usage example](./sdk/python/examples/basic_usage.py), th - [x] Kata Containers runtime on KVM-capable nodes - [x] Firecracker microVM runtime on KVM-capable nodes - [x] Optional native Linux runc runtime -- [x] Sandbox network ACL +- [x] Stateful sandbox network ACLs for CIDRs, domains, protocols, and ports - [ ] Fork-based sandbox launch based on gVisor - [x] Same-node checkpoint recovery for runsc and Firecracker - [ ] Support for GKE and AWS diff --git a/builder/node.Dockerfile b/builder/node.Dockerfile index fd6f4f9..543d069 100644 --- a/builder/node.Dockerfile +++ b/builder/node.Dockerfile @@ -243,6 +243,7 @@ RUN apt-get update && \ fuse3 \ gnupg \ iproute2 \ + ipset \ iptables \ jq \ kmod \ diff --git a/builder/scripts/sandboxd_network_prepare.sh b/builder/scripts/sandboxd_network_prepare.sh index 5bbe7f8..2e30cb0 100755 --- a/builder/scripts/sandboxd_network_prepare.sh +++ b/builder/scripts/sandboxd_network_prepare.sh @@ -57,11 +57,13 @@ fi # netfilter hooks. Host provisioning must load br_netfilter; this hook only # configures the node container's network namespace. if [[ "${enable_network_acl,,}" == "true" && "${nat_backend}" == "iptables" ]]; then - if [[ ! -e /proc/sys/net/bridge/bridge-nf-call-iptables ]]; then + if [[ ! -e /proc/sys/net/bridge/bridge-nf-call-iptables || + ! -e /proc/sys/net/bridge/bridge-nf-call-ip6tables ]]; then echo "br_netfilter is unavailable; load it on the host before starting the AKernel node" >&2 exit 1 fi "${SYSCTL_BIN}" -w net.bridge.bridge-nf-call-iptables=1 + "${SYSCTL_BIN}" -w net.bridge.bridge-nf-call-ip6tables=1 fi # bpfnat validates this setting when its local-DNAT path is enabled. Apply it diff --git a/deploy/README.md b/deploy/README.md index e5dde05..9ba6a48 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -57,17 +57,21 @@ The bundled standalone, Helm, and Terraform sandboxd configurations enable per-sandbox network ACLs. Runsc, Kata, and Firecracker require the host `tun` module and a usable `/dev/net/tun` for their pooled TAP endpoints. A sandbox created without a policy remains on the unrestricted fast path. The default -`iptables` backend additionally requires `ip_tables`, `br_netfilter`, -conntrack, connmark/CONNMARK support, and -`net.bridge.bridge-nf-call-iptables=1`. The optional `bpfnat` backend instead -requires 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. Both backends require TCP and UDP -port 53 on the sandbox bridge to be free and at least one usable upstream -nameserver. AKernel's privileged node container prepares the selected -backend's namespace-local settings. Host provisioning must load the required -kernel modules before the node pod starts; the Terraform node bootstrap does -this automatically. +`iptables` backend additionally requires the `iptables`, `ip6tables`, and +`ipset` userspace commands; IPv4/IPv6 filter-table, `br_netfilter`, +`xt_physdev`, conntrack and conntrack-netlink, connmark/CONNMARK, and +timeout-capable `hash:ip` ipset support; and both bridge netfilter sysctls for +iptables and ip6tables set to `1`. Sandboxd probes the IPv6 physdev rule and +the required ipset type when it initializes this backend. The optional `bpfnat` +backend instead requires Linux 5.17 or newer for `bpf_loop`, 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. Both backends require TCP and UDP port 53 on the sandbox bridge to be +free and at least one usable upstream nameserver. AKernel's privileged node +container prepares the selected backend's namespace-local settings. Host +provisioning must load the required kernel modules before the node pod starts; +the Terraform node bootstrap does this automatically. Drain all sandboxes from a node before enabling ACLs or upgrading an existing deployment to a release that enables them. Sandboxd deliberately refuses to @@ -78,6 +82,8 @@ sandboxd is healthy. Sandboxd selects the ACL implementation matching the configured `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. +Schema v2 domain traffic rules also use the managed DNS proxy to install +TTL-bound address grants, even when no separate DNS policy is supplied. `make config` is interactive by default. It writes: diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md index 60f3af9..b24912b 100644 --- a/deploy/standalone/README.md +++ b/deploy/standalone/README.md @@ -92,9 +92,10 @@ later creation of `sandbox0` cannot change the advertised node address. Set override. The standalone configuration enables per-sandbox network ACLs. With the -default iptables backend, `start.sh` loads `br_netfilter` on the host before -the node starts; the node then enables bridge netfilter in its own network -namespace. The host also requires conntrack plus connmark/CONNMARK support. +default iptables backend, `start.sh` loads IPv6 filter-table, `br_netfilter`, +`xt_physdev`, conntrack/connmark, and timeout-capable ipset modules on the host +before the node starts; the node then enables IPv4 and IPv6 bridge netfilter in +its own network namespace. The optional bpfnat backend instead requires TC eBPF support and a writable bpffs. TCP and UDP port 53 on the sandbox bridge must remain free for sandboxd's managed DNS proxy. Before diff --git a/deploy/standalone/start.sh b/deploy/standalone/start.sh index 101fa32..1fff5d2 100755 --- a/deploy/standalone/start.sh +++ b/deploy/standalone/start.sh @@ -308,19 +308,44 @@ prepare_host_network_modules() { fi if [[ "$(id -u)" -eq 0 ]]; then + "${modprobe_bin}" ip_tables + "${modprobe_bin}" iptable_filter + "${modprobe_bin}" ip6_tables + "${modprobe_bin}" ip6table_filter "${modprobe_bin}" br_netfilter - elif sudo -n "${modprobe_bin}" br_netfilter; then + "${modprobe_bin}" xt_physdev + "${modprobe_bin}" nf_conntrack + "${modprobe_bin}" nf_conntrack_netlink + "${modprobe_bin}" xt_conntrack + "${modprobe_bin}" xt_connmark + "${modprobe_bin}" ip_set + "${modprobe_bin}" ip_set_hash_ip + "${modprobe_bin}" xt_set + elif sudo -n "${modprobe_bin}" ip_tables && + sudo -n "${modprobe_bin}" iptable_filter && + sudo -n "${modprobe_bin}" ip6_tables && + sudo -n "${modprobe_bin}" ip6table_filter && + sudo -n "${modprobe_bin}" br_netfilter && + sudo -n "${modprobe_bin}" xt_physdev && + sudo -n "${modprobe_bin}" nf_conntrack && + sudo -n "${modprobe_bin}" nf_conntrack_netlink && + sudo -n "${modprobe_bin}" xt_conntrack && + sudo -n "${modprobe_bin}" xt_connmark && + sudo -n "${modprobe_bin}" ip_set && + sudo -n "${modprobe_bin}" ip_set_hash_ip && + sudo -n "${modprobe_bin}" xt_set; then : else - log_error "Unable to load br_netfilter; run this script as root or allow passwordless sudo for modprobe" + log_error "Unable to load required iptables ACL modules; run this script as root or allow passwordless sudo for modprobe" exit 1 fi - if [[ ! -e /proc/sys/net/bridge/bridge-nf-call-iptables ]]; then + if [[ ! -e /proc/sys/net/bridge/bridge-nf-call-iptables || + ! -e /proc/sys/net/bridge/bridge-nf-call-ip6tables ]]; then log_error "br_netfilter loaded but bridge netfilter sysctls are unavailable" exit 1 fi - log_info "Loaded host br_netfilter module for the iptables ACL backend" + log_info "Loaded host filter, bridge, conntrack, and ipset modules for the iptables ACL backend" } # Start the AKernel all-in-one container. Traefik runs separately so traffic diff --git a/deploy/terraform/aliyun/variables.tf b/deploy/terraform/aliyun/variables.tf index 40ba488..dbf7eea 100644 --- a/deploy/terraform/aliyun/variables.tf +++ b/deploy/terraform/aliyun/variables.tf @@ -321,7 +321,7 @@ variable "extra_node_pools" { variable "sandboxd_nat_backend" { type = string - description = "Sandboxd NAT backend. When set to 'iptables', ip_tables kernel module will be loaded at boot." + description = "Sandboxd NAT backend. The iptables mode loads IPv4/IPv6 bridge-netfilter modules at boot." default = "iptables" } diff --git a/deploy/terraform/huaweicloud/variables.tf b/deploy/terraform/huaweicloud/variables.tf index 6ba608c..5c668bb 100644 --- a/deploy/terraform/huaweicloud/variables.tf +++ b/deploy/terraform/huaweicloud/variables.tf @@ -365,7 +365,7 @@ variable "extra_node_pools" { variable "sandboxd_nat_backend" { type = string - description = "Sandboxd NAT backend. When set to 'iptables', ip_tables kernel module will be loaded at boot." + description = "Sandboxd NAT backend. The iptables mode loads IPv4/IPv6 bridge-netfilter modules at boot." default = "iptables" } diff --git a/deploy/terraform/shared/node-bootstrap.sh.tftpl b/deploy/terraform/shared/node-bootstrap.sh.tftpl index 940d12c..f5cb907 100644 --- a/deploy/terraform/shared/node-bootstrap.sh.tftpl +++ b/deploy/terraform/shared/node-bootstrap.sh.tftpl @@ -27,7 +27,8 @@ systemctl enable akernel-tun-module.service systemctl start akernel-tun-module.service %{ if sandboxd_nat_backend == "iptables" ~} -# ip_tables provides NAT and br_netfilter provides bridged ACL enforcement. +# ip_tables provides NAT. IPv6 bridge filtering prevents traffic from bypassing +# the IPv4-only ACL policy surface. cat >/etc/systemd/system/akernel-network-modules.service <<'EOF' [Unit] Description=Load AKernel iptables networking modules on boot @@ -35,7 +36,7 @@ After=network-pre.target [Service] Type=oneshot -ExecStart=/bin/sh -c 'modprobe ip_tables && modprobe br_netfilter' +ExecStart=/bin/sh -c 'modprobe ip_tables && modprobe iptable_filter && modprobe ip6_tables && modprobe ip6table_filter && modprobe br_netfilter && modprobe xt_physdev && modprobe nf_conntrack && modprobe nf_conntrack_netlink && modprobe xt_conntrack && modprobe xt_connmark && modprobe ip_set && modprobe ip_set_hash_ip && modprobe xt_set' RemainAfterExit=yes [Install] diff --git a/sdk/python/README.md b/sdk/python/README.md index d687fe1..1c03c3a 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -153,7 +153,7 @@ with Sandbox() as unrestricted: ``` Block new sandbox flows except the YuanRong control proxy and published -sandbox-port routes: +sandbox-port routes with the compatibility helper: ```python with Sandbox(network_policy=NetworkPolicy.block()) as sandbox: @@ -181,24 +181,81 @@ with Sandbox(network_policy=policy) as sandbox: 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. Each pattern may be an exact ASCII name or begin with one leading +lower-case ASCII without a trailing dot; international names are converted to +punycode. Each pattern may be an exact name or begin with one leading `*.`. The remaining name is at most 253 characters; each dot-separated label is 1-63 letters, digits, underscores, or hyphens, and a hyphen cannot start or end a label. Other wildcard placements and `?` are rejected. -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 -block-network packet ACL is currently stateful IPv4. DNS-only policies do not -install a general packet allowlist. - -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 +Create an egress allowlist with the high-level helper. Rules may select an +IPv4 address or CIDR, an exact or leading-wildcard DNS name, TCP or UDP peer +ports, and a priority: + +```python +from akernel_sdk import NetworkRule, PortRange + +policy = NetworkPolicy.allowlist( + [ + NetworkRule( + domain="*.example.com", + protocol="tcp", + port_range=PortRange(443), + priority=200, + ), + NetworkRule( + cidr="192.0.2.10", + protocol="tcp", + port_range=PortRange(8000, 8010), + ), + ] +) +with Sandbox(network_policy=policy) as sandbox: + print(sandbox.commands.run("curl https://api.example.com").stdout) +``` + +For independent ingress and egress defaults, deny rules, sandbox-side port +ranges, DNS allowlists, or stateless matching, construct the schema v2 model +directly with `TrafficPolicy`, `NetworkRule`, `DNSPolicy`, and `DNSRule`. +Traffic rules are evaluated by highest priority first; an equal-priority deny +wins. Stateful mode is the default and permits reply traffic. Priority +`4294967295` is reserved for control-plane and published-port rules, so user +priorities are limited to `1..4294967294`. +In stateless mode, a protected published-port rule covers ingress only; add an +explicit egress rule for the matching sandbox source port when the application +must send a reply. This avoids turning a published port into an unrestricted +egress escape hatch. + +Domain traffic rules authorize IPv4 addresses learned from an allowed +original DNS query, following its complete CNAME chain. The authorization +uses the answer TTL, clamped to 1..3600 seconds, and is replaced when the name +is resolved again with an IPv4 A or ANY query. Existing connections that +depended on an expired or replaced authorization are removed. Parallel AAAA +queries do not revoke IPv4 grants. DNS names not covered by a domain traffic +rule can still resolve when the DNS policy allows the query, but their answers +do not grant packet access. While a DNS policy or domain traffic rule is +active, ordinary TCP and UDP DNS is accepted only through sandboxd's managed +resolver; a traffic rule for another port-53 resolver does not bypass it. +The resulting enforcement is at IPv4 and transport layers. Another virtual +host sharing an authorized address and port is not distinguishable; use an +application proxy when hostname-level isolation is required. + +Network policies are fixed when a sandbox is created. The legacy +`block_network` and `dns_blacklist` fields cannot be combined with schema v2 +sections. DNS policies cover ordinary UDP and TCP DNS and return a refused +response for denied queries; DNS-over-HTTPS and connections to a known IP are +outside DNS filtering. Packet rules are IPv4. sandboxd accepts 256 combined +traffic rules; the backend reserves entries from that limit for the Function +Proxy and each distinct published sandbox port. IPv6 traffic is dropped +whenever a traffic or DNS policy is active, preventing an alternate resolver +from bypassing the IPv4 policy. Arbitrary non-IP Ethernet protocols are +outside the portable ACL contract. Domain and DNS patterns are normalized +through IDNA. + +See [`examples/network_policy.py`](./examples/network_policy.py) for the +compatibility modes and generic allowlist. 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 @@ -595,7 +652,12 @@ 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` | +| `PortRange` | `first`, `last` | +| `NetworkRule` | action, direction, protocol, peer and sandbox port selectors, priority | +| `TrafficPolicy` | independent ingress/egress defaults, rules, mode | +| `DNSRule` | `pattern`, `action` | +| `DNSPolicy` | `default_action`, `rules` | +| `NetworkPolicy` | legacy fields or schema v2 `traffic` and `dns` sections | | `DockerfileLaunch` | `context`, `auto_start_cmd`, `run_timeout` | | `DockerContext` | Abstract Dockerfile and build-context source | | `DockerContextEntry` | `path`, `kind`, `mode` | diff --git a/sdk/python/akernel_sdk/__init__.py b/sdk/python/akernel_sdk/__init__.py index 5e77dd9..752aef1 100644 --- a/sdk/python/akernel_sdk/__init__.py +++ b/sdk/python/akernel_sdk/__init__.py @@ -26,13 +26,18 @@ from .types import ( CommandInfo, CommandResult, + DNSPolicy, + DNSRule, EntryInfo, HttpReverseTunnel, Mount, NetworkPolicy, + NetworkRule, NodeInfo, + PortRange, S3Config, SandboxInfo, + TrafficPolicy, ) __all__ = [ @@ -40,6 +45,11 @@ "S3Config", "Mount", "NetworkPolicy", + "NetworkRule", + "PortRange", + "TrafficPolicy", + "DNSPolicy", + "DNSRule", "HttpReverseTunnel", "CommandResult", "CommandInfo", diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py index 32c1a27..018217a 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py @@ -26,8 +26,14 @@ from ..types import ( CommandInfo, CommandResult, + DNSPolicy, + DNSRule, EntryInfo, + NetworkPolicy, + NetworkRule, + PortRange, SandboxInfo, + TrafficPolicy, ) from .base import ( Backend, @@ -42,6 +48,59 @@ _DEFAULT_LISTEN_PORT = 8766 +def _native_port_range(value: PortRange | int | None) -> Any: + if value is None: + return None + assert isinstance(value, PortRange) + return yr_sandbox.PortRange(first=value.first, last=value.last) + + +def _native_network_rule(rule: NetworkRule) -> Any: + return yr_sandbox.NetworkRule( + action=rule.action, + direction=rule.direction, + protocol=rule.protocol, + cidr=rule.cidr, + domain=rule.domain, + port_range=_native_port_range(rule.port_range), + sandbox_port_range=_native_port_range(rule.sandbox_port_range), + priority=rule.priority, + ) + + +def _native_traffic_policy(policy: TrafficPolicy | None) -> Any: + if policy is None: + return None + return yr_sandbox.TrafficPolicy( + ingress_default_action=policy.ingress_default_action, + egress_default_action=policy.egress_default_action, + rules=tuple(_native_network_rule(rule) for rule in policy.rules), + mode=policy.mode, + ) + + +def _native_dns_rule(rule: DNSRule) -> Any: + return yr_sandbox.DNSRule(pattern=rule.pattern, action=rule.action) + + +def _native_dns_policy(policy: DNSPolicy | None) -> Any: + if policy is None: + return None + return yr_sandbox.DNSPolicy( + default_action=policy.default_action, + rules=tuple(_native_dns_rule(rule) for rule in policy.rules), + ) + + +def _native_network_policy(policy: NetworkPolicy) -> Any: + return yr_sandbox.NetworkPolicy( + block_network=policy.block_network, + dns_blacklist=policy.dns_blacklist, + traffic=_native_traffic_policy(policy.traffic), + dns=_native_dns_policy(policy.dns), + ) + + def _convert_error(operation: str, error: Exception) -> BackendOperationError: return BackendOperationError(f"{operation} failed: {error}") @@ -324,9 +383,7 @@ def __init__(self, config: BackendConfig) -> None: os.environ["YR_SERVER_ADDRESS"] = config.api_endpoint.authority() os.environ["YR_TLS"] = "1" if config.api_endpoint.use_tls else "0" os.environ["YR_GATEWAY_ADDRESS"] = config.gateway_endpoint.authority() - os.environ["YR_GATEWAY_TLS"] = ( - "1" if config.gateway_endpoint.use_tls else "0" - ) + os.environ["YR_GATEWAY_TLS"] = "1" if config.gateway_endpoint.use_tls else "0" os.environ["YR_TOKEN"] = config.token def _validate(self, spec: SandboxSpec) -> None: @@ -357,10 +414,7 @@ def create(self, spec: SandboxSpec) -> BackendSession: ) network = None if spec.network_policy is not None: - network = yr_sandbox.NetworkPolicy( - block_network=spec.network_policy.block_network, - dns_blacklist=spec.network_policy.dns_blacklist, - ) + network = _native_network_policy(spec.network_policy) mounts = [ yr_sandbox.Mount( target=mount.target, diff --git a/sdk/python/akernel_sdk/types.py b/sdk/python/akernel_sdk/types.py index 67a43cd..6f50aa7 100644 --- a/sdk/python/akernel_sdk/types.py +++ b/sdk/python/akernel_sdk/types.py @@ -16,11 +16,15 @@ from __future__ import annotations +import ipaddress import re +from collections.abc import Sequence from dataclasses import dataclass, field from typing import Any from urllib.parse import urlparse +import idna + # Default yr.get() timeout in seconds. # Ref: yr.common.constants.DEFAULT_GET_TIMEOUT YR_GET_DEFAULT_TIMEOUT = 300 @@ -29,20 +33,37 @@ YR_GET_TIMEOUT_BUFFER = 30 -# DNS blacklist patterns use exact ASCII names or a single leading ``*.``. -# Each label is 1-63 characters from this set; a hyphen cannot be an endpoint. _DNS_LABEL_PATTERN = re.compile(r"^[a-z0-9_-]+$") +_NETWORK_ACTIONS = frozenset({"allow", "deny"}) +_NETWORK_DIRECTIONS = frozenset({"ingress", "egress", "both"}) +_NETWORK_PROTOCOLS = frozenset({"any", "tcp", "udp", "icmp"}) +_TRAFFIC_POLICY_MODES = frozenset({"stateless", "stateful"}) +_MAX_TRAFFIC_RULES = 256 +# UINT32_MAX is reserved for FunctionSystem's control-plane and published-port +# rules, which must remain effective even when user traffic is default-deny. +_MAX_USER_RULE_PRIORITY = (1 << 32) - 2 -def _normalize_dns_pattern(pattern: str) -> str: +def _normalize_domain_pattern(pattern: str, description: str) -> str: if not isinstance(pattern, str): - raise TypeError("dns blacklist patterns must be strings") - value = pattern.strip().lower().rstrip(".") + raise TypeError(f"{description} patterns must be strings") + value = pattern.strip().lower() + if value.endswith("."): + value = value[:-1] 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}") + if not value or "*" in value or "?" in value: + raise ValueError(f"invalid {description} pattern: {pattern!r}") + try: + value = idna.encode(value, uts46=True).decode("ascii") + except idna.IDNAError: + # Preserve DNS-SD-compatible underscores in ASCII owner names while + # normalizing ordinary international names to punycode. + if any(ord(char) > 127 for char in value): + raise ValueError(f"invalid {description} pattern: {pattern!r}") from None + if len(value) > 253: + raise ValueError(f"invalid {description} pattern: {pattern!r}") for label in value.split("."): if ( not label @@ -51,10 +72,247 @@ def _normalize_dns_pattern(pattern: str) -> str: or label.endswith("-") or _DNS_LABEL_PATTERN.fullmatch(label) is None ): - raise ValueError(f"invalid DNS blacklist pattern: {pattern!r}") + raise ValueError(f"invalid {description} pattern: {pattern!r}") return f"*.{value}" if wildcard else value +def _normalize_dns_pattern(pattern: str) -> str: + return _normalize_domain_pattern(pattern, "DNS blacklist") + + +def _normalize_choice(value: str, name: str, allowed: frozenset[str]) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + normalized = value.strip().lower() + if normalized not in allowed: + choices = ", ".join(sorted(allowed)) + raise ValueError(f"{name} must be one of: {choices}") + return normalized + + +def _normalize_cidr(value: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError("cidr must be a non-empty IPv4 address or CIDR") + try: + network = ipaddress.ip_network(value.strip(), strict=False) + except ValueError as error: + raise ValueError(f"invalid IPv4 address or CIDR: {value!r}") from error + if network.version != 4: + raise ValueError(f"invalid IPv4 address or CIDR: {value!r}") + return str(network) + + +@dataclass(frozen=True) +class PortRange: + """Inclusive TCP or UDP port interval. + + Omit ``last`` to select one port. Both endpoints must be in 1..65535. + """ + + first: int + last: int | None = None + + def __post_init__(self) -> None: + last = self.first if self.last is None else self.last + for name, value in (("first", self.first), ("last", last)): + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"port range {name} must be an integer") + if value < 1 or value > 65535: + raise ValueError(f"port range {name} must be in 1..65535") + if self.first > last: + raise ValueError("port range first must not exceed last") + object.__setattr__(self, "last", last) + + def to_dict(self) -> dict[str, int]: + """Return the JSON-compatible inclusive interval.""" + + assert self.last is not None + return {"first": self.first, "last": self.last} + + +def _normalize_port_range(value: object | None, name: str) -> PortRange | None: + if value is None or isinstance(value, PortRange): + return value + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an integer, PortRange, or None") + return PortRange(value) + + +@dataclass(frozen=True) +class NetworkRule: + """One IPv4 traffic rule expressed from the sandbox's point of view. + + ``cidr`` and ``domain`` are mutually exclusive. Omitting both matches any + peer address. A domain is valid only for egress and may be exact or start + with ``*.``. ``port_range`` selects the peer port and + ``sandbox_port_range`` selects the local sandbox port. + """ + + action: str = "allow" + direction: str = "egress" + protocol: str = "any" + cidr: str | None = None + domain: str | None = None + port_range: PortRange | int | None = None + sandbox_port_range: PortRange | int | None = None + priority: int = 100 + + def __post_init__(self) -> None: + action = _normalize_choice(self.action, "action", _NETWORK_ACTIONS) + direction = _normalize_choice(self.direction, "direction", _NETWORK_DIRECTIONS) + protocol = _normalize_choice(self.protocol, "protocol", _NETWORK_PROTOCOLS) + if self.cidr is not None and self.domain is not None: + raise ValueError("cidr and domain cannot be combined in one rule") + cidr = _normalize_cidr(self.cidr) if self.cidr is not None else None + domain = ( + _normalize_domain_pattern(self.domain, "domain") + if self.domain is not None + else None + ) + if domain is not None and direction != "egress": + raise ValueError("domain rules are valid only for egress") + port_range = _normalize_port_range(self.port_range, "port_range") + sandbox_port_range = _normalize_port_range( + self.sandbox_port_range, "sandbox_port_range" + ) + if (port_range is not None or sandbox_port_range is not None) and ( + protocol not in ("tcp", "udp") + ): + raise ValueError("port ranges require protocol='tcp' or 'udp'") + if isinstance(self.priority, bool) or not isinstance(self.priority, int): + raise TypeError("priority must be an integer") + if self.priority < 1 or self.priority > _MAX_USER_RULE_PRIORITY: + raise ValueError(f"priority must be in 1..{_MAX_USER_RULE_PRIORITY}") + object.__setattr__(self, "action", action) + object.__setattr__(self, "direction", direction) + object.__setattr__(self, "protocol", protocol) + object.__setattr__(self, "cidr", cidr) + object.__setattr__(self, "domain", domain) + object.__setattr__(self, "port_range", port_range) + object.__setattr__(self, "sandbox_port_range", sandbox_port_range) + + def to_dict(self) -> dict[str, Any]: + """Return the JSON-compatible schema v2 rule.""" + + value: dict[str, Any] = { + "action": self.action, + "direction": self.direction, + "protocol": self.protocol, + "priority": self.priority, + } + peer: dict[str, Any] = {} + if self.cidr is not None: + peer["cidr"] = self.cidr + if self.domain is not None: + peer["domain"] = self.domain + if self.port_range is not None: + assert isinstance(self.port_range, PortRange) + peer["portRange"] = self.port_range.to_dict() + if peer: + value["peer"] = peer + if self.sandbox_port_range is not None: + assert isinstance(self.sandbox_port_range, PortRange) + value["sandboxPortRange"] = self.sandbox_port_range.to_dict() + return value + + +@dataclass(frozen=True) +class TrafficPolicy: + """Generic IPv4 policy with independent direction defaults.""" + + ingress_default_action: str = "allow" + egress_default_action: str = "allow" + rules: Sequence[NetworkRule] = () + mode: str = "stateful" + + def __post_init__(self) -> None: + ingress = _normalize_choice( + self.ingress_default_action, + "ingress_default_action", + _NETWORK_ACTIONS, + ) + egress = _normalize_choice( + self.egress_default_action, + "egress_default_action", + _NETWORK_ACTIONS, + ) + mode = _normalize_choice(self.mode, "mode", _TRAFFIC_POLICY_MODES) + if isinstance(self.rules, (str, bytes)): + raise TypeError("rules must be a sequence of NetworkRule values") + rules = tuple(self.rules) + if len(rules) > _MAX_TRAFFIC_RULES: + raise ValueError( + f"traffic policies support at most {_MAX_TRAFFIC_RULES} rules" + ) + if any(not isinstance(rule, NetworkRule) for rule in rules): + raise TypeError("rules must contain only NetworkRule values") + object.__setattr__(self, "ingress_default_action", ingress) + object.__setattr__(self, "egress_default_action", egress) + object.__setattr__(self, "rules", rules) + object.__setattr__(self, "mode", mode) + + def to_dict(self) -> dict[str, Any]: + """Return the JSON-compatible schema v2 traffic policy.""" + + return { + "ingressDefaultAction": self.ingress_default_action, + "egressDefaultAction": self.egress_default_action, + "mode": self.mode, + "rules": [rule.to_dict() for rule in self.rules], + } + + +@dataclass(frozen=True) +class DNSRule: + """One exact or leading-wildcard DNS query rule.""" + + pattern: str + action: str = "deny" + + def __post_init__(self) -> None: + object.__setattr__( + self, + "action", + _normalize_choice(self.action, "action", _NETWORK_ACTIONS), + ) + object.__setattr__( + self, "pattern", _normalize_domain_pattern(self.pattern, "DNS") + ) + + def to_dict(self) -> dict[str, str]: + """Return the JSON-compatible DNS rule.""" + + return {"action": self.action, "pattern": self.pattern} + + +@dataclass(frozen=True) +class DNSPolicy: + """DNS query policy evaluated by sandboxd's managed DNS proxy.""" + + default_action: str = "allow" + rules: Sequence[DNSRule] = () + + def __post_init__(self) -> None: + default = _normalize_choice( + self.default_action, "default_action", _NETWORK_ACTIONS + ) + if isinstance(self.rules, (str, bytes)): + raise TypeError("rules must be a sequence of DNSRule values") + rules = tuple(self.rules) + if any(not isinstance(rule, DNSRule) for rule in rules): + raise TypeError("rules must contain only DNSRule values") + object.__setattr__(self, "default_action", default) + object.__setattr__(self, "rules", rules) + + def to_dict(self) -> dict[str, Any]: + """Return the JSON-compatible DNS policy.""" + + return { + "defaultAction": self.default_action, + "rules": [rule.to_dict() for rule in self.rules], + } + + @dataclass(frozen=True) class NetworkPolicy: """Creation-time network policy for an AKernel sandbox. @@ -64,15 +322,15 @@ class NetworkPolicy: and explicit port forwarding. Use :meth:`deny_dns` to reject conventional DNS queries matching exact names or leading ``*.`` suffix patterns. - DNS patterns are ASCII names up to 253 characters, split into labels of - 1-63 lowercase letters, digits, underscores, or hyphens. Hyphens cannot - start or end a label. A wildcard is accepted only as the leading ``*.``; - use ASCII punycode for internationalized names. Input is normalized to - lowercase and trailing dots are removed. + ``traffic`` and ``dns`` expose the generic schema v2 model. Legacy fields + and schema v2 sections cannot be combined. Domains are normalized to + lowercase IDNA ASCII without a trailing dot. """ block_network: bool = False dns_blacklist: tuple[str, ...] = () + traffic: TrafficPolicy | None = None + dns: DNSPolicy | None = None def __post_init__(self) -> None: if not isinstance(self.block_network, bool): @@ -84,6 +342,14 @@ def __post_init__(self) -> None: ) if self.block_network and normalized: raise ValueError("block_network and dns_blacklist cannot be combined") + if (self.block_network or normalized) and ( + self.traffic is not None or self.dns is not None + ): + raise ValueError("legacy and schema v2 network policies cannot be combined") + if self.traffic is not None and not isinstance(self.traffic, TrafficPolicy): + raise TypeError("traffic must be a TrafficPolicy or None") + if self.dns is not None and not isinstance(self.dns, DNSPolicy): + raise TypeError("dns must be a DNSPolicy or None") object.__setattr__(self, "dns_blacklist", normalized) @classmethod @@ -100,11 +366,39 @@ def deny_dns(cls, *patterns: str) -> NetworkPolicy: raise ValueError("deny_dns requires at least one domain pattern") return cls(dns_blacklist=patterns) + @classmethod + def allowlist( + cls, + rules: Sequence[NetworkRule], + *, + default_action: str = "deny", + ingress_default_action: str = "allow", + mode: str = "stateful", + ) -> NetworkPolicy: + """Allow selected egress rules and apply a default action to the rest.""" + + normalized = tuple(rules) + if not normalized: + raise ValueError("allowlist requires at least one NetworkRule") + return cls( + traffic=TrafficPolicy( + ingress_default_action=ingress_default_action, + egress_default_action=default_action, + rules=normalized, + mode=mode, + ) + ) + @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 + return ( + not self.block_network + and not self.dns_blacklist + and self.traffic is None + and self.dns is None + ) def to_dict(self) -> dict[str, Any]: """Return the JSON-compatible public API representation.""" @@ -114,6 +408,12 @@ def to_dict(self) -> dict[str, Any]: value["blockNetwork"] = True if self.dns_blacklist: value["dnsBlacklist"] = list(self.dns_blacklist) + if self.traffic is not None or self.dns is not None: + value["schemaVersion"] = 2 + if self.traffic is not None: + value["traffic"] = self.traffic.to_dict() + if self.dns is not None: + value["dns"] = self.dns.to_dict() return value diff --git a/sdk/python/examples/network_policy.py b/sdk/python/examples/network_policy.py index 76c6c73..1c72b6c 100644 --- a/sdk/python/examples/network_policy.py +++ b/sdk/python/examples/network_policy.py @@ -12,11 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Exercise unrestricted, fully blocked, and DNS-denylisted networking.""" +"""Exercise unrestricted, blocked, DNS-filtered, and allowlisted networking.""" import shlex -from akernel_sdk import NetworkPolicy, Sandbox +from akernel_sdk import NetworkPolicy, NetworkRule, PortRange, Sandbox def tcp_connection(host: str, port: int) -> str: @@ -61,6 +61,31 @@ def main() -> None: assert allowed.exit_code == 0, allowed.stderr print("Allowed DNS and connection succeeded.") + allowlist = NetworkPolicy.allowlist( + [ + NetworkRule( + domain="*.github.com", + protocol="tcp", + port_range=PortRange(443), + priority=200, + ), + NetworkRule( + cidr="192.0.2.10", + protocol="tcp", + port_range=PortRange(8443), + ), + ] + ) + with Sandbox(network_policy=allowlist) as restricted: + allowed = restricted.commands.run( + tcp_connection("api.github.com", 443), timeout=30 + ) + assert allowed.exit_code == 0, allowed.stderr + + denied = restricted.commands.run(tcp_connection("example.com", 443), timeout=10) + assert denied.exit_code != 0 + print("Generic egress allowlist enforced domain and port rules.") + if __name__ == "__main__": main() diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 055e135..64bb8b9 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ "Topic :: System :: Distributed Computing", ] dependencies = [ + "idna>=3,<4", "openyuanrong-sandbox==0.10.1rc3", "websockets>=10.0", "dockerfile-parse>=2.0.1", @@ -35,15 +36,15 @@ dependencies = [ [project.optional-dependencies] openyuanrong-sdk = [ - "openyuanrong-sdk==0.9.9", + "openyuanrong-sdk==0.10.0", ] all = [ - "openyuanrong-sdk==0.9.9", + "openyuanrong-sdk==0.10.0", ] dev = [ "build>=1.2,<2", "mypy>=1.10,<2", - "openyuanrong-sdk==0.9.9", + "openyuanrong-sdk==0.10.0", "ruff>=0.11,<1", ] @@ -81,7 +82,7 @@ warn_unused_ignores = true exclude = ["akernel_sdk/_instance.py"] [[tool.mypy.overrides]] -module = ["yr", "yr.*"] +module = ["yr", "yr.*", "yr_sandbox", "yr_sandbox.*"] ignore_missing_imports = true [[tool.mypy.overrides]] diff --git a/sdk/python/tests/unit/test_backends.py b/sdk/python/tests/unit/test_backends.py index d00bb42..c27b74c 100644 --- a/sdk/python/tests/unit/test_backends.py +++ b/sdk/python/tests/unit/test_backends.py @@ -37,6 +37,8 @@ HttpReverseTunnel, Mount, NetworkPolicy, + NetworkRule, + PortRange, S3Config, ) @@ -362,6 +364,40 @@ def test_create_converts_network_policy_to_native_sdk_type(self): self.assertEqual(network.dns_blacklist, ("github.com", "*.github.com")) session.close() + def test_create_converts_acl_v2_to_native_sdk_types(self): + native = MagicMock() + native.id = "default-worker" + native.commands = MagicMock() + native.files = MagicMock() + policy = NetworkPolicy.allowlist( + [ + NetworkRule( + domain="api.github.com", + protocol="tcp", + port_range=PortRange(80, 443), + priority=110, + ) + ] + ) + with patch.object( + openyuanrong_sandbox.yr_sandbox, + "Sandbox", + return_value=native, + ) as sandbox_type: + session = self.backend.create(_spec(network_policy=policy)) + + network = sandbox_type.call_args.kwargs["network"] + self.assertEqual(network.to_dict(), policy.to_dict()) + self.assertIsInstance( + network.traffic.rules[0], + openyuanrong_sandbox.yr_sandbox.NetworkRule, + ) + self.assertIsInstance( + network.traffic.rules[0].port_range, + openyuanrong_sandbox.yr_sandbox.PortRange, + ) + session.close() + def test_terminate_forces_deletion_of_detached_native_sandbox(self): native = MagicMock() native.id = "default-worker" @@ -415,9 +451,7 @@ def test_detached_delete_failure_still_allows_local_cleanup(self): None, ] session = self.backend.create(_spec(detached=True)) - with self.assertRaisesRegex( - BackendOperationError, "remote delete failed" - ): + with self.assertRaisesRegex(BackendOperationError, "remote delete failed"): try: session.terminate() finally: diff --git a/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py b/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py index a766a68..013b219 100644 --- a/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py +++ b/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py @@ -16,7 +16,14 @@ import unittest from unittest.mock import MagicMock, patch -from akernel_sdk import HttpReverseTunnel, Mount, NetworkPolicy, S3Config +from akernel_sdk import ( + HttpReverseTunnel, + Mount, + NetworkPolicy, + NetworkRule, + PortRange, + S3Config, +) from akernel_sdk._backends import openyuanrong_sdk_impl as _impl @@ -108,8 +115,9 @@ def test_resource_limit_validation(self): with self.assertRaisesRegex(ValueError, "mem_limit"): self.build_options(memory=4096, mem_limit=2048) for value in (0, -1): - with self.subTest(schedule_timeout=value), self.assertRaisesRegex( - ValueError, "schedule_timeout" + with ( + self.subTest(schedule_timeout=value), + self.assertRaisesRegex(ValueError, "schedule_timeout"), ): self.build_options(schedule_timeout=value) @@ -139,6 +147,24 @@ def test_network_policy_uses_custom_extension_wire_format(self): {"dnsBlacklist": ["github.com", "*.github.com"]}, ) + def test_acl_v2_uses_custom_extension_wire_format(self): + policy = NetworkPolicy.allowlist( + [ + NetworkRule( + domain="api.github.com", + protocol="tcp", + port_range=PortRange(80, 443), + ) + ] + ) + + options = self.build_options(network_policy=policy) + + self.assertEqual( + json.loads(options.custom_extensions["network_policy"]), + policy.to_dict(), + ) + def test_extra_config_uses_custom_extension_wire_format(self): options = self.build_options( runtime="custom-runtime", diff --git a/sdk/python/tests/unit/test_sandbox.py b/sdk/python/tests/unit/test_sandbox.py index 6115869..922a94a 100644 --- a/sdk/python/tests/unit/test_sandbox.py +++ b/sdk/python/tests/unit/test_sandbox.py @@ -18,11 +18,16 @@ from unittest.mock import MagicMock, patch from akernel_sdk import ( + DNSPolicy, + DNSRule, DockerfileLaunch, HttpReverseTunnel, NetworkPolicy, + NetworkRule, + PortRange, S3Config, Sandbox, + TrafficPolicy, ) from akernel_sdk import sandbox as sandbox_module from akernel_sdk._dockercontext import LocalDockerContext @@ -224,13 +229,17 @@ def test_runtime_identifier_is_normalized_and_passed_to_backend(self): def test_runtime_identifier_validation(self): for value in (None, 1): - with self.subTest(value=value), self.assertRaisesRegex( - TypeError, "runtime must be a string" + with ( + self.subTest(value=value), + self.assertRaisesRegex(TypeError, "runtime must be a string"), ): Sandbox(runtime=value) for value in ("", " "): - with self.subTest(value=value), self.assertRaisesRegex( - ValueError, "runtime must be a non-empty string" + with ( + self.subTest(value=value), + self.assertRaisesRegex( + ValueError, "runtime must be a non-empty string" + ), ): Sandbox(runtime=value) self.backend.create.assert_not_called() @@ -294,6 +303,106 @@ def test_dns_blacklist_is_normalized_and_passed_to_backend(self): ) sandbox.kill() + def test_acl_v2_allowlist_is_normalized_and_passed_to_backend(self): + policy = NetworkPolicy.allowlist( + [ + NetworkRule( + cidr="10.20.30.40", + protocol="tcp", + port_range=22773, + priority=110, + ), + NetworkRule( + domain="BÜCHER.example.", + protocol="tcp", + port_range=PortRange(80, 443), + ), + NetworkRule(protocol="udp", port_range=53), + ] + ) + + sandbox = Sandbox(network_policy=policy) + + spec = self.backend.create.call_args.args[0] + self.assertIs(spec.network_policy, policy) + self.assertEqual( + policy.to_dict(), + { + "schemaVersion": 2, + "traffic": { + "ingressDefaultAction": "allow", + "egressDefaultAction": "deny", + "mode": "stateful", + "rules": [ + { + "action": "allow", + "direction": "egress", + "protocol": "tcp", + "priority": 110, + "peer": { + "cidr": "10.20.30.40/32", + "portRange": {"first": 22773, "last": 22773}, + }, + }, + { + "action": "allow", + "direction": "egress", + "protocol": "tcp", + "priority": 100, + "peer": { + "domain": "xn--bcher-kva.example", + "portRange": {"first": 80, "last": 443}, + }, + }, + { + "action": "allow", + "direction": "egress", + "protocol": "udp", + "priority": 100, + "peer": {"portRange": {"first": 53, "last": 53}}, + }, + ], + }, + }, + ) + sandbox.kill() + + def test_acl_v2_supports_low_level_traffic_and_dns_policies(self): + policy = NetworkPolicy( + traffic=TrafficPolicy( + ingress_default_action="deny", + egress_default_action="allow", + mode="stateless", + rules=( + NetworkRule( + action="deny", + direction="ingress", + protocol="tcp", + cidr="192.0.2.129/24", + sandbox_port_range=PortRange(8000, 8010), + priority=200, + ), + ), + ), + dns=DNSPolicy( + default_action="deny", + rules=(DNSRule("*.example.com", action="allow"),), + ), + ) + + self.assertEqual(policy.to_dict()["schemaVersion"], 2) + self.assertEqual( + policy.to_dict()["traffic"]["rules"][0]["peer"]["cidr"], + "192.0.2.0/24", + ) + self.assertEqual( + policy.to_dict()["dns"], + { + "defaultAction": "deny", + "rules": [{"action": "allow", "pattern": "*.example.com"}], + }, + ) + def test_empty_network_policy_is_treated_as_unrestricted(self): sandbox = Sandbox(network_policy=NetworkPolicy()) @@ -307,7 +416,21 @@ def test_invalid_network_policy_is_rejected_before_backend(self): lambda: NetworkPolicy(dns_blacklist="github.com"), lambda: NetworkPolicy.deny_dns(), lambda: NetworkPolicy.deny_dns("github.*"), + lambda: NetworkPolicy.deny_dns("github.com.."), lambda: NetworkPolicy(block_network=True, dns_blacklist=("github.com",)), + lambda: PortRange(0), + lambda: PortRange(100, 99), + lambda: NetworkRule(cidr="2001:db8::/32"), + lambda: NetworkRule(cidr="10.0.0.0/8", domain="example.com"), + lambda: NetworkRule(domain="*.example.com", direction="both"), + lambda: NetworkRule(protocol="any", port_range=443), + lambda: NetworkRule(priority=(1 << 32) - 1), + lambda: TrafficPolicy(rules=(NetworkRule(),) * 257), + lambda: NetworkPolicy( + block_network=True, + traffic=TrafficPolicy(), + ), + lambda: NetworkPolicy.allowlist(()), ) for factory in invalid_factories: with ( @@ -327,8 +450,9 @@ def test_common_resource_validation_happens_before_backend(self): with self.assertRaisesRegex(ValueError, "cpu_limit"): Sandbox(cpu=2000, cpu_limit=1000) for value in (0, -1, -2): - with self.subTest(schedule_timeout=value), self.assertRaisesRegex( - ValueError, "schedule_timeout" + with ( + self.subTest(schedule_timeout=value), + self.assertRaisesRegex(ValueError, "schedule_timeout"), ): Sandbox(schedule_timeout=value) self.backend.create.assert_not_called() diff --git a/sdk/python/tests/unit/test_types.py b/sdk/python/tests/unit/test_types.py index 06e9374..21c92da 100644 --- a/sdk/python/tests/unit/test_types.py +++ b/sdk/python/tests/unit/test_types.py @@ -77,6 +77,11 @@ def test_public_exports_are_minimal(self): "S3Config", "Mount", "NetworkPolicy", + "NetworkRule", + "PortRange", + "TrafficPolicy", + "DNSPolicy", + "DNSRule", "HttpReverseTunnel", "CommandResult", "CommandInfo", diff --git a/src/sandboxd b/src/sandboxd index 499e6d4..49fa7b1 160000 --- a/src/sandboxd +++ b/src/sandboxd @@ -1 +1 @@ -Subproject commit 499e6d499f0c5e705848179ccc42bcc6e193f6e2 +Subproject commit 49fa7b10996e28c18c5588e2fa1ee1e7d463e6a0 diff --git a/src/yuanrong b/src/yuanrong index 6004958..79e5ebf 160000 --- a/src/yuanrong +++ b/src/yuanrong @@ -1 +1 @@ -Subproject commit 6004958250e06578385c554d165062704e9d646e +Subproject commit 79e5ebf01c5df6af43c446eebd68ddb576259b1e From d1283ff1886e2ffb1153ed742c9d458541c9e0a5 Mon Sep 17 00:00:00 2001 From: Tianyu Zhou Date: Tue, 1 Sep 2026 02:15:18 +0800 Subject: [PATCH 2/5] feat(network): add dynamic sandbox ACL updates Expose whole-policy replacement on the public Sandbox API and delegate it to the native YuanRong backend. Normalize empty policies to an explicit clear and reject the actor backend instead of silently weakening the request. Advance the pinned YuanRong aggregate revision and update documentation, examples, and unit coverage for replacement, clearing, restart persistence, and backend compatibility. Signed-off-by: Tianyu Zhou --- AGENTS.md | 6 ++-- sdk/python/README.md | 18 +++++++++- sdk/python/akernel_sdk/_backends/base.py | 1 + .../_backends/openyuanrong_sandbox.py | 17 +++++++++ .../akernel_sdk/_backends/openyuanrong_sdk.py | 7 ++++ sdk/python/akernel_sdk/sandbox.py | 28 +++++++++++++-- sdk/python/examples/network_policy.py | 13 +++++++ sdk/python/tests/unit/test_backends.py | 35 +++++++++++++++++++ sdk/python/tests/unit/test_sandbox.py | 22 ++++++++++++ src/yuanrong | 2 +- 10 files changed, 141 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 497e310..1954c1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,9 @@ which owns availability and compatibility checks. The bundled deployment also advertises Kata Containers and Firecracker on KVM-capable nodes. The native Linux runc payload is build-time optional and must be explicitly included and enabled by an operator. -Creation-time network policies support unrestricted networking, blocking new -flows except the YuanRong control and published sandbox-port routes, or denying -exact and leading-wildcard DNS names. +Creation-time network policies and atomic runtime replacement support +unrestricted networking, blocking new flows except the YuanRong control and +published sandbox-port routes, or denying exact and leading-wildcard DNS names. Experimental whole-device NVIDIA GPU requests require runsc. Configurable writable-storage requests are supported by runsc and Firecracker. diff --git a/sdk/python/README.md b/sdk/python/README.md index 1c03c3a..228c8d6 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -212,6 +212,22 @@ policy = NetworkPolicy.allowlist( with Sandbox(network_policy=policy) as sandbox: print(sandbox.commands.run("curl https://api.example.com").stdout) ``` +Replace the complete policy of a running sandbox atomically with +`update_network_policy`. Passing `None` or an empty `NetworkPolicy()` +clears the policy and restores unrestricted networking: + +```python +with Sandbox() as sandbox: + sandbox.update_network_policy(NetworkPolicy.block()) + sandbox.update_network_policy( + NetworkPolicy.deny_dns("github.com", "*.github.com") + ) + sandbox.update_network_policy(None) +``` + +The desired policy survives sandboxd restarts, explicit reloads, and same-node +failover. Dynamic replacement is supported by the default +`openyuanrong-sandbox` backend; the actor-based backend rejects it explicitly. For independent ingress and egress defaults, deny rules, sandbox-side port ranges, DNS allowlists, or stateless matching, construct the schema v2 model @@ -239,7 +255,7 @@ The resulting enforcement is at IPv4 and transport layers. Another virtual host sharing an authorized address and port is not distinguishable; use an application proxy when hostname-level isolation is required. -Network policies are fixed when a sandbox is created. The legacy +Network policy replacement uses whole-policy semantics. The legacy `block_network` and `dns_blacklist` fields cannot be combined with schema v2 sections. DNS policies cover ordinary UDP and TCP DNS and return a refused response for denied queries; DNS-over-HTTPS and connections to a known IP are diff --git a/sdk/python/akernel_sdk/_backends/base.py b/sdk/python/akernel_sdk/_backends/base.py index 815aa9e..5715300 100644 --- a/sdk/python/akernel_sdk/_backends/base.py +++ b/sdk/python/akernel_sdk/_backends/base.py @@ -151,6 +151,7 @@ def is_running(self) -> bool: ... def get_info(self) -> SandboxInfo: ... def reload(self) -> bool: ... + def update_network_policy(self, policy: NetworkPolicy | None) -> None: ... def terminate(self) -> None: ... diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py index 018217a..8f577d8 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py @@ -342,6 +342,23 @@ def reload(self) -> bool: except Exception: return False + def update_network_policy(self, policy: NetworkPolicy | None) -> None: + if self._terminated or self._closed: + raise BackendOperationError( + "update network policy failed: sandbox is closed" + ) + update = getattr(self._sandbox, "update_network_policy", None) + if not callable(update): + raise UnsupportedBackendFeatureError( + "The installed openyuanrong-sandbox backend does not support " + "dynamic network policy updates. Upgrade the backend package." + ) + native_policy = None if policy is None else _native_network_policy(policy) + try: + update(native_policy) + except Exception as error: + raise _convert_error("update network policy", error) from error + def terminate(self) -> None: if self._terminated: return diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py index 28c8703..ce512bf 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py @@ -24,6 +24,7 @@ CommandInfo, CommandResult, EntryInfo, + NetworkPolicy, SandboxInfo, ) from . import openyuanrong_sdk_impl as _impl @@ -249,6 +250,12 @@ def reload(self) -> bool: "Use the default 'openyuanrong-sandbox' backend." ) + def update_network_policy(self, policy: NetworkPolicy | None) -> None: + raise UnsupportedBackendFeatureError( + "Backend 'openyuanrong-sdk' does not support dynamic network policy " + "updates. Use the default 'openyuanrong-sandbox' backend." + ) + def terminate(self) -> None: if self._terminated: return diff --git a/sdk/python/akernel_sdk/sandbox.py b/sdk/python/akernel_sdk/sandbox.py index ba552c2..9c58ffd 100644 --- a/sdk/python/akernel_sdk/sandbox.py +++ b/sdk/python/akernel_sdk/sandbox.py @@ -272,9 +272,7 @@ def __init__( raise ValueError("runtime must be a non-empty string") normalized_xpu = normalize_xpu(xpu) validate_storage_mb(storage_mb) - if network_policy is not None and not isinstance( - network_policy, NetworkPolicy - ): + if network_policy is not None and not isinstance(network_policy, NetworkPolicy): raise TypeError("network_policy must be a NetworkPolicy or None") _validate_integer("cpu", cpu, minimum=1) _validate_integer("memory", memory, minimum=1) @@ -469,6 +467,30 @@ def reload(self) -> bool: return False return self._session.reload() + def update_network_policy(self, policy: NetworkPolicy | None) -> None: + """Atomically replace the complete network policy of this sandbox. + + Passing None or an empty NetworkPolicy clears the existing policy and + restores unrestricted networking. The desired policy is retained + across sandboxd restarts, explicit reloads, and same-node failover. + + Args: + policy: The replacement policy, or None to clear it. + + Raises: + TypeError: If policy is not a NetworkPolicy or None. + RuntimeError: If the sandbox is already closed. + UnsupportedBackendFeatureError: If the selected backend cannot + update policies dynamically. + """ + + if policy is not None and not isinstance(policy, NetworkPolicy): + raise TypeError("policy must be a NetworkPolicy or None") + if self._closed or self._session is None: + raise RuntimeError("sandbox is closed") + normalized = None if policy is None or policy.is_empty else policy + self._session.update_network_policy(normalized) + def get_port_url(self, port: int, *, internal: bool = False) -> str: """Return the gateway URL for a declared sandbox port. diff --git a/sdk/python/examples/network_policy.py b/sdk/python/examples/network_policy.py index 1c72b6c..336bfff 100644 --- a/sdk/python/examples/network_policy.py +++ b/sdk/python/examples/network_policy.py @@ -85,6 +85,19 @@ def main() -> None: denied = restricted.commands.run(tcp_connection("example.com", 443), timeout=10) assert denied.exit_code != 0 print("Generic egress allowlist enforced domain and port rules.") + with Sandbox() as dynamic: + dynamic.update_network_policy(NetworkPolicy.block()) + denied = dynamic.commands.run(direct_connection(), timeout=10) + assert denied.exit_code != 0 + + dynamic.update_network_policy( + NetworkPolicy.deny_dns("github.com", "*.github.com") + ) + allowed = dynamic.commands.run(direct_connection(), timeout=10) + assert allowed.exit_code == 0, allowed.stderr + + dynamic.update_network_policy(None) + print("Dynamic policy replacement and clearing succeeded.") if __name__ == "__main__": diff --git a/sdk/python/tests/unit/test_backends.py b/sdk/python/tests/unit/test_backends.py index c27b74c..425a55d 100644 --- a/sdk/python/tests/unit/test_backends.py +++ b/sdk/python/tests/unit/test_backends.py @@ -591,6 +591,31 @@ def test_reload_requires_capable_native_sdk(self): ): session.reload() + def test_update_network_policy_converts_native_type_and_clears(self): + native = MagicMock() + native.id = "default-source" + native.commands = MagicMock() + native.files = MagicMock() + with patch.object( + openyuanrong_sandbox.yr_sandbox, + "Sandbox", + return_value=native, + ): + session = self.backend.create(_spec()) + + policy = NetworkPolicy.deny_dns("github.com") + session.update_network_policy(policy) + session.update_network_policy(None) + + native_policy = native.update_network_policy.call_args_list[0].args[0] + self.assertIsInstance( + native_policy, + openyuanrong_sandbox.yr_sandbox.NetworkPolicy, + ) + self.assertEqual(native_policy.to_dict(), policy.to_dict()) + self.assertIsNone(native.update_network_policy.call_args_list[1].args[0]) + + class OpenYuanRongSdkBackendTest(unittest.TestCase): def setUp(self): self.config = BackendConfig( @@ -736,5 +761,15 @@ def test_close_finalizes_actor_sdk(self): finalize.assert_called_once_with() + def test_dynamic_network_policy_is_explicitly_unsupported(self): + session = openyuanrong_sdk._Session(MagicMock(), "physical-id", _spec(), None) + + with self.assertRaisesRegex( + UnsupportedBackendFeatureError, + "does not support dynamic network policy", + ): + session.update_network_policy(NetworkPolicy.block()) + + if __name__ == "__main__": unittest.main() diff --git a/sdk/python/tests/unit/test_sandbox.py b/sdk/python/tests/unit/test_sandbox.py index 922a94a..5ba7508 100644 --- a/sdk/python/tests/unit/test_sandbox.py +++ b/sdk/python/tests/unit/test_sandbox.py @@ -698,6 +698,28 @@ def test_get_port_url(self): sandbox.get_port_url(9090) sandbox.kill() + def test_update_network_policy_normalizes_and_delegates(self): + sandbox = Sandbox() + policy = NetworkPolicy.deny_dns("github.com") + + sandbox.update_network_policy(policy) + sandbox.update_network_policy(NetworkPolicy()) + sandbox.update_network_policy(None) + + self.assertEqual( + self.session.update_network_policy.call_args_list, + [ + unittest.mock.call(policy), + unittest.mock.call(None), + unittest.mock.call(None), + ], + ) + with self.assertRaises(TypeError): + sandbox.update_network_policy({"blockNetwork": True}) + sandbox.kill() + with self.assertRaises(RuntimeError): + sandbox.update_network_policy(None) + if __name__ == "__main__": unittest.main() diff --git a/src/yuanrong b/src/yuanrong index 79e5ebf..c76fb0d 160000 --- a/src/yuanrong +++ b/src/yuanrong @@ -1 +1 @@ -Subproject commit 79e5ebf01c5df6af43c446eebd68ddb576259b1e +Subproject commit c76fb0d0619f8960d0a8138f994c6c979779a104 From bc9b1d9c44df2e4b0af503b85871b562a992fe38 Mon Sep 17 00:00:00 2001 From: Tianyu Zhou Date: Tue, 1 Sep 2026 04:02:44 +0800 Subject: [PATCH 3/5] chore(deps): update YuanRong ACL integration Advance the bundled YuanRong revision to include CAS-backed dynamic network policy persistence and shared-client PUT forwarding. This keeps the all-in-one image aligned with the component revisions used by the dynamic ACL end-to-end tests. Signed-off-by: Tianyu Zhou --- src/yuanrong | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/yuanrong b/src/yuanrong index c76fb0d..353e955 160000 --- a/src/yuanrong +++ b/src/yuanrong @@ -1 +1 @@ -Subproject commit c76fb0d0619f8960d0a8138f994c6c979779a104 +Subproject commit 353e955ff6aea6c175a453e1b38c895269758f62 From 45021eb0ad9ac84a4fbf7978e07c48f6d0d28d0f Mon Sep 17 00:00:00 2001 From: Tianyu Zhou Date: Tue, 1 Sep 2026 04:11:03 +0800 Subject: [PATCH 4/5] fix(examples): use reliable dynamic ACL probes Verify dynamic policy replacement with an HTTPS endpoint that the example already establishes as reachable instead of assuming public TCP DNS access. Also probe the previously denied domain after clearing the policy so the example validates both replacement and removal behavior. Signed-off-by: Tianyu Zhou --- sdk/python/examples/network_policy.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sdk/python/examples/network_policy.py b/sdk/python/examples/network_policy.py index 336bfff..dfa713f 100644 --- a/sdk/python/examples/network_policy.py +++ b/sdk/python/examples/network_policy.py @@ -93,10 +93,16 @@ def main() -> None: dynamic.update_network_policy( NetworkPolicy.deny_dns("github.com", "*.github.com") ) - allowed = dynamic.commands.run(direct_connection(), timeout=10) + allowed = dynamic.commands.run( + tcp_connection("example.com", 443), timeout=30 + ) assert allowed.exit_code == 0, allowed.stderr dynamic.update_network_policy(None) + cleared = dynamic.commands.run( + tcp_connection("github.com", 443), timeout=30 + ) + assert cleared.exit_code == 0, cleared.stderr print("Dynamic policy replacement and clearing succeeded.") From 35c36612c9a98972988ae4831a7ae0ec3dd05a0f Mon Sep 17 00:00:00 2001 From: Tianyu Zhou Date: Tue, 1 Sep 2026 23:09:45 +0800 Subject: [PATCH 5/5] chore(deps): update openYuanRong to 0.10.1rc4 Advance the control-plane, RRT, and default sandbox SDK dependency to the 0.10.1rc4 release, including the checksums for its published runtime and core wheel artifacts, so AKernel consumes the merged network ACL support. Keep the deprecated actor-based openyuanrong-sdk backend on the 0.9.9 version used by main and document that it is retained only for compatibility with existing applications. Signed-off-by: Tianyu Zhou --- AGENTS.md | 25 ++++++++++++++----------- README.md | 9 ++++++--- builder/node.Dockerfile | 6 +++--- builder/runtime.Dockerfile | 4 ++-- sdk/python/README.md | 9 ++++++--- sdk/python/pyproject.toml | 8 ++++---- 6 files changed, 35 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1954c1c..f656f42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,9 +124,10 @@ node components and produces the AKernel all-in-one image using the selected runtime image and its matching service configuration. The control-plane and RRT release version is independent of the optional -actor-based `openyuanrong_sdk` installed in the Python runtime profile. Keep -the latter on its explicitly pinned legacy version unless that backend is -being upgraded and tested as a separate compatibility change. +actor-based `openyuanrong_sdk` installed in the Python runtime profile. This +actor backend is deprecated and retained only for compatibility with existing +applications. Keep it on its explicitly pinned legacy version; do not advance +it with the default `openyuanrong-sandbox` backend or use it for new features. Initialize submodules with `git submodule update --init --recursive` before building. The all-in-one image builds the sandboxd binaries, including @@ -445,14 +446,16 @@ make sdk-check ``` The Python SDK installs `openyuanrong-sandbox` as its default execution -backend. The actor-based `openyuanrong-sdk` backend is available through the -`openyuanrong-sdk` extra. Installing that extra leaves both distributions -present, so `openyuanrong-sandbox` remains the automatic default unless -`AKERNEL_BACKEND=openyuanrong-sdk` is set before import. Backend selection -happens once during import and backend modules are loaded lazily on first use. -Keep public `Sandbox`, `Commands`, `Filesystem`, and value types independent -of both native packages; all native conversions belong under -`akernel_sdk._backends`. +backend. The actor-based `openyuanrong-sdk` backend is deprecated and retained +only for compatibility with existing applications through the +`openyuanrong-sdk` extra. Do not update its pinned legacy version alongside +the default backend or extend it with new capabilities. Installing that extra +leaves both distributions present, so `openyuanrong-sandbox` remains the +automatic default unless `AKERNEL_BACKEND=openyuanrong-sdk` is set before +import. Backend selection happens once during import and backend modules are +loaded lazily on first use. Keep public `Sandbox`, `Commands`, `Filesystem`, +and value types independent of both native packages; all native conversions +belong under `akernel_sdk._backends`. Dockerfile direct launch is a supported AKernel SDK capability through `DockerContext` and diff --git a/README.md b/README.md index d59b62d..2f54892 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,15 @@ python -m pip install akernel-sdk # Source python -m pip install ./sdk/python -# Also install the actor backend +# Also install the deprecated actor compatibility backend python -m pip install "akernel-sdk[openyuanrong-sdk]" ``` -When the actor extra is installed, both backend packages are present and -`openyuanrong-sandbox` remains the automatic default. Set +The actor-based `openyuanrong-sdk` backend is deprecated and retained only for +compatibility with existing applications. New applications should use the +default `openyuanrong-sandbox` backend. When the actor extra is installed, +both backend packages are present and `openyuanrong-sandbox` remains the +automatic default. Set `AKERNEL_BACKEND=openyuanrong-sdk` before importing `akernel_sdk` to select the actor backend: diff --git a/builder/node.Dockerfile b/builder/node.Dockerfile index 543d069..82171c0 100644 --- a/builder/node.Dockerfile +++ b/builder/node.Dockerfile @@ -10,12 +10,12 @@ ARG AKERNEL_ENABLE_RUNC=false ARG AKERNEL_ENABLE_FIRECRACKER=true ARG SANDBOXD_BUILD_IMAGE=golang:1.25.5-bookworm ARG DISTILL_FS_BUILD_IMAGE=rust:1.85.0-bookworm -ARG OPEN_YR_VERSION=0.10.1rc3 +ARG OPEN_YR_VERSION=0.10.1rc4 ARG OPEN_YR_CORE_WHEEL_URL= ARG OPEN_YR_CORE_WHEEL_SHA256= ARG OPEN_YR_RELEASE_BASE_URL=https://openyuanrong.obs.cn-southwest-2.myhuaweicloud.com/release -ARG OPEN_YR_CORE_AMD64_SHA256=d303a25587919ce64bae8a7e193cab4b0f33ef12213968fa96a1ba563df629d7 -ARG OPEN_YR_CORE_ARM64_SHA256=c611e4e2e4c08e696b60bf8ad394261b2a2bca5b18100b8e84351c2cbc289f1c +ARG OPEN_YR_CORE_AMD64_SHA256=65c1f27e7e700a253a2e907dea0273e85f1c76610e48c93544caa6bcc07ac3af +ARG OPEN_YR_CORE_ARM64_SHA256=29d25c3388c2913346035ee8df9b8159e702de7a8e6f783e3e218ab773896333 ARG GVISOR_DOWNLOAD_IMAGE=ubuntu:24.04 ARG GVISOR_RELEASE ARG GVISOR_AMD64_URL diff --git a/builder/runtime.Dockerfile b/builder/runtime.Dockerfile index 6fde3bc..d5edf8e 100644 --- a/builder/runtime.Dockerfile +++ b/builder/runtime.Dockerfile @@ -9,14 +9,14 @@ ARG PYTHON_311_VERSION=3.11.13 ARG PYTHON_312_VERSION=3.12.11 ARG PYTHON_313_VERSION=3.13.5 ARG PYTHON_314_VERSION=3.14.6 -ARG OPEN_YR_VERSION=0.10.1rc3 +ARG OPEN_YR_VERSION=0.10.1rc4 ARG OPEN_YR_LEGACY_SDK_VERSION=0.9.9 FROM ${AKERNEL_RUNTIME_BASE_IMAGE} AS rrt-download ARG OPEN_YR_VERSION ARG RRT_RUNTIME_URL=https://openyuanrong.obs.cn-southwest-2.myhuaweicloud.com/release/${OPEN_YR_VERSION}/linux/amd64/rrt-runtime-amd64 -ARG RRT_RUNTIME_SHA256=c22d3c95b38845763e9f27553a065a959458743bbf42358c609bc2f1f9fce789 +ARG RRT_RUNTIME_SHA256=7c2064531e91fba8b9bbe96ac9e706cd496a198551657ee5461a078a2f7ea9ea RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates curl && \ diff --git a/sdk/python/README.md b/sdk/python/README.md index 228c8d6..a828280 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -7,7 +7,8 @@ PTYs, port forwarding, and reverse tunnels. It supports two backends: - `openyuanrong-sandbox` (default), using a RESTful API and Rust runtime. -- `openyuanrong-sdk` (legacy), using YuanRong actors and a Python runtime. +- `openyuanrong-sdk` (deprecated compatibility backend), using YuanRong actors + and a Python runtime. ## Navigation @@ -62,8 +63,10 @@ Address behavior is deterministic: scheme uses HTTP/WS. Exec and file transfer continue to use `AKERNEL_SERVER_ADDRESS`. -The legacy actor backend is optional. Install and select it before importing -`akernel_sdk`: +The actor-based `openyuanrong-sdk` backend is deprecated and retained only for +compatibility with existing applications. New applications should use +`openyuanrong-sandbox`. If compatibility requires the actor backend, install +and select it before importing `akernel_sdk`: ```bash pip install "akernel-sdk[openyuanrong-sdk]" diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 64bb8b9..8f859a6 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -29,22 +29,22 @@ classifiers = [ ] dependencies = [ "idna>=3,<4", - "openyuanrong-sandbox==0.10.1rc3", + "openyuanrong-sandbox==0.10.1rc4", "websockets>=10.0", "dockerfile-parse>=2.0.1", ] [project.optional-dependencies] openyuanrong-sdk = [ - "openyuanrong-sdk==0.10.0", + "openyuanrong-sdk==0.9.9", ] all = [ - "openyuanrong-sdk==0.10.0", + "openyuanrong-sdk==0.9.9", ] dev = [ "build>=1.2,<2", "mypy>=1.10,<2", - "openyuanrong-sdk==0.10.0", + "openyuanrong-sdk==0.9.9", "ruff>=0.11,<1", ]