diff --git a/AGENTS.md b/AGENTS.md index b6e11c3..0f8ab71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ 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. Experimental whole-device NVIDIA GPU requests require runsc. Configurable -writable-storage requests are supported by runsc and Firecracker. +writable-storage requests and hard limits are supported by runsc and +Firecracker. Use AKernel when a task needs an isolated remote environment with command execution, file operations, interactive PTYs, port forwarding, or reverse @@ -196,7 +197,8 @@ a configured runtime as an advertised runtime. Firecracker supports commands, files, PTYs, network policies, published ports, reverse tunnels, read-only EROFS image roots and mounts, explicit `storage_mb` -quotas, and recovery across sandboxd restarts. Its root and filesystem image +requests and `storage_limit_mb` hard limits, and recovery across sandboxd +restarts. Its root and filesystem image mounts must be local or image-provider-backed regular EROFS files. It rejects OCI/Nydus directory roots, directory mounts, writable live host binds, NVIDIA GPUs, and nested KVM rather than weakening their semantics. @@ -213,9 +215,9 @@ direct configuration, build an image with `AKERNEL_ENABLE_RUNC=true`, then use `AKERNEL_ENABLE_RUNC=true` for standalone, `node.config.sandboxd.enableRunc=true` for Helm, or `enable_runc=true` for Terraform. Runc uses the host kernel and therefore has a different isolation -boundary from runsc. It does not support experimental GPU or explicit -`storage_mb` requests. Its optional `enableKVM` extra configuration requires a -usable `/dev/kvm` device. +boundary from runsc. It does not support experimental GPU, explicit +`storage_mb` requests, or `storage_limit_mb` limits. Its optional `enableKVM` +extra configuration requires a 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 @@ -390,9 +392,10 @@ explicit override for multi-homed environments. The standalone sandboxd filestore is a loop-mounted ext4 image under the bind-mounted `deploy/standalone/data/` directory. Explicit `storage_mb` -quotas for runsc and Firecracker use this local-disk filestore. Without an -explicit quota, runsc retains its configured memory-backed overlay while -Firecracker creates its configured sparse ext4 default. +requests and `storage_limit_mb` hard limits for runsc and Firecracker use this +local-disk filestore. Without explicit storage values, runsc retains its +configured memory-backed overlay while Firecracker creates its configured +sparse ext4 default. Terraform-managed Alibaba Cloud node pools instead attach a dedicated 300 GiB ESSD by default, have ACK format it as XFS, and mount it at `/home/akernel`. @@ -509,9 +512,10 @@ python sdk/python/tests/integration/test_sandbox.py -v python sdk/python/benchmarks/sandbox_pressure.py --runtime firecracker python sdk/python/benchmarks/sandbox_pressure.py \ - --runtime firecracker --storage-mb 256 + --runtime firecracker --storage-mb 256 --storage-limit-mb 512 python sdk/python/benchmarks/sandbox_pressure.py \ - --xpu gpu:a10:1 --storage-mb 256 --processes 1 --threads 1 + --xpu gpu:a10:1 --storage-mb 256 --storage-limit-mb 512 \ + --processes 1 --threads 1 ``` ## Maintenance Rules diff --git a/README.md b/README.md index b5754c3..4ebffcb 100644 --- a/README.md +++ b/README.md @@ -169,8 +169,9 @@ with Sandbox(xpu="gpu:l20:1") as sandbox: ``` GPU sandboxes require a compatible NVIDIA node and the gVisor `runsc` -runtime. `storage_mb` is measured in MiB and is supported by `runsc` and -Firecracker. +runtime. Writable storage controls are measured in MiB: `storage_mb` requests +scheduler capacity, while `storage_limit_mb` sets the rootfs writable-layer +hard limit. Both are supported by `runsc` and Firecracker. See the complete [basic usage example](./sdk/python/examples/basic_usage.py), the [sandbox runtime example](./sdk/python/examples/sandbox_runtime.py), and the other [SDK examples](./sdk/python/examples/) for more operations. diff --git a/sdk/python/README.md b/sdk/python/README.md index d687fe1..23077bc 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -104,6 +104,7 @@ Sandbox( *, xpu: str | None = None, storage_mb: int | None = None, + storage_limit_mb: int | None = None, network_policy: NetworkPolicy | None = None, dockerfile: DockerfileLaunch | None = None, extra_config: Mapping[str, object] | None = None, @@ -125,17 +126,29 @@ supported. The bundled backend currently requires the gVisor `runsc` runtime and a node configured for gVisor nvproxy. Runtime compatibility is validated by the backend rather than the SDK. -Set the writable root filesystem quota in MiB: +Set the writable root filesystem scheduling request and hard limit in MiB: ```python -with Sandbox(storage_mb=20 * 1024) as sandbox: +with Sandbox(storage_mb=10 * 1024, storage_limit_mb=20 * 1024) as sandbox: print(sandbox.commands.run("df -h /").stdout) ``` -The bundled backend currently requires `runsc` for an explicit `storage_mb` -quota and uses sandboxd's disk-backed XFS filestore. Runtime compatibility is -validated by the backend. When `storage_mb` is omitted, sandboxd retains its -configured default 10 GiB memory-backed writable overlay. See +`storage_mb` is the amount reserved by the scheduler. `storage_limit_mb` is +the writable root filesystem's hard limit. Both default to `None`: + +| `storage_mb` | `storage_limit_mb` | Behavior | +|---|---|---| +| `None` | `None` | No explicit storage reservation; use the runtime's configured writable-layer limit. | +| request | `None` | Reserve the request; use the same value as the hard limit. | +| `None` | limit | Reserve the limit and use it as the hard limit. | +| request | limit | Reserve the request and enforce the limit; the limit must be at least the request. | + +Explicit storage values are supported by `runsc` and Firecracker. Runtime +compatibility is validated by the backend. With neither value set, the bundled +deployment keeps its configured 10 GiB writable-layer limit: runsc uses its +memory-backed overlay and Firecracker uses a sparse ext4 overlay image. +`SandboxInfo` reports the requested values; it does not resolve an omitted +value to the runtime default. See [`examples/gpu_sandbox.py`](./examples/gpu_sandbox.py) and [`examples/storage_sandbox.py`](./examples/storage_sandbox.py). @@ -238,8 +251,8 @@ with Sandbox( `enableKVM` is owned by the runc backend and requires a usable `/dev/kvm` on the selected node. Runc supports OCI/EROFS root filesystems, read-only mounts, networking, command execution, and the default writable overlay. Experimental -GPU requests remain runsc-only; explicit `storage_mb` quotas are supported by -runsc and Firecracker. See the +GPU requests remain runsc-only; explicit `storage_mb` requests and +`storage_limit_mb` limits are supported by runsc and Firecracker. See the [sandbox runtime comparison](../../src/sandboxd/doc/runtime.md) for the runtime capability boundaries. @@ -590,7 +603,7 @@ not part of the default test suite. | `CommandResult` | `stdout`, `stderr`, `exit_code` | | `CommandInfo` | `pid`, `command`, `running` | | `EntryInfo` | `name`, `path`, `type`, `size`, `permissions`, `modified_time` | -| `SandboxInfo` | `id`, `state`, `cpu`, `memory`, `image`, `xpu`, `storage_mb` | +| `SandboxInfo` | `id`, `state`, `cpu`, `memory`, `image`, `xpu`, `storage_mb`, `storage_limit_mb` | | `NodeInfo` | `id`, `status`, `capacity`, `allocatable`, `labels` | | `S3Config` | `endpoint`, `bucket`, `object`, optional credentials | | `Mount` | `target`, one source, and `type` | diff --git a/sdk/python/akernel_sdk/_backends/base.py b/sdk/python/akernel_sdk/_backends/base.py index 815aa9e..ff3f934 100644 --- a/sdk/python/akernel_sdk/_backends/base.py +++ b/sdk/python/akernel_sdk/_backends/base.py @@ -76,6 +76,7 @@ class SandboxSpec: node_id: str | None xpu: str | None storage_mb: int | None + storage_limit_mb: int | None network_policy: NetworkPolicy | None extra_config: Mapping[str, object] diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py index 32c1a27..f93866a 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py @@ -267,6 +267,7 @@ def get_info(self) -> SandboxInfo: image=value.image, xpu=self._spec.xpu, storage_mb=self._spec.storage_mb, + storage_limit_mb=self._spec.storage_limit_mb, ) def reload(self) -> bool: @@ -415,6 +416,9 @@ def create(self, spec: SandboxSpec) -> BackendSession: node_id=spec.node_id, xpu=spec.xpu, storage_mb=spec.storage_mb, + storage_limit_mb=( + spec.storage_limit_mb if spec.storage_limit_mb is not None else 0 + ), network=network, extra_config=dict(spec.extra_config), create_timeout=create_timeout, diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py index 28c8703..87d9f3b 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk.py @@ -241,6 +241,7 @@ def get_info(self) -> SandboxInfo: image=self._spec.image, xpu=self._spec.xpu, storage_mb=self._spec.storage_mb, + storage_limit_mb=self._spec.storage_limit_mb, ) def reload(self) -> bool: @@ -306,6 +307,7 @@ def create(self, spec: SandboxSpec) -> BackendSession: node_id=spec.node_id, xpu=spec.xpu, storage_mb=spec.storage_mb, + storage_limit_mb=spec.storage_limit_mb, network_policy=spec.network_policy, extra_config=spec.extra_config, ) diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py index a7cbd8a..e1a49a9 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py @@ -37,7 +37,7 @@ from .._sandbox_resources import ( normalize_xpu, storage_bytes, - validate_storage_mb, + validate_storage, xpu_custom_resource, ) from ..types import ( @@ -155,6 +155,7 @@ def build_options( node_id: str | None, xpu: str | None, storage_mb: int | None, + storage_limit_mb: int | None, network_policy: NetworkPolicy | None, extra_config: Mapping[str, object], ) -> Any: @@ -171,7 +172,7 @@ def build_options( if mem_limit and mem_limit < memory: raise ValueError("mem_limit must be 0 or greater than or equal to memory") normalized_xpu = normalize_xpu(xpu) - validate_storage_mb(storage_mb) + validate_storage(storage_mb, storage_limit_mb) options = yr.InvokeOptions() # A Sandbox is driven by one sequential SDK client. Disabling ordered RPC @@ -201,8 +202,13 @@ def build_options( if normalized_xpu is not None: resource_name, count = xpu_custom_resource(normalized_xpu) options.custom_resources[resource_name] = count - if storage_mb is not None: - options.custom_resources["storage"] = storage_bytes(storage_mb) + storage_request_mb = storage_mb if storage_mb is not None else storage_limit_mb + if storage_request_mb is not None: + options.custom_resources["storage"] = storage_bytes(storage_request_mb) + if storage_limit_mb is not None: + options.custom_extensions["STORAGE_LIMIT"] = str( + int(storage_bytes(storage_limit_mb)) + ) if network_policy is not None: options.custom_extensions["network_policy"] = json.dumps( network_policy.to_dict() diff --git a/sdk/python/akernel_sdk/_dockerfile.py b/sdk/python/akernel_sdk/_dockerfile.py index 6404f1b..e6217f7 100644 --- a/sdk/python/akernel_sdk/_dockerfile.py +++ b/sdk/python/akernel_sdk/_dockerfile.py @@ -162,7 +162,7 @@ def _resolve_entrypoint( _IGNORED_INSTRUCTIONS: dict[str, str] = { - "VOLUME": "not supported; use storage_mb or mounts for persistence", + "VOLUME": "not supported; use storage_mb/storage_limit_mb or mounts", "LABEL": "not supported", "HEALTHCHECK": "not supported", "SHELL": "not supported", diff --git a/sdk/python/akernel_sdk/_sandbox_resources.py b/sdk/python/akernel_sdk/_sandbox_resources.py index 3682065..5fa3c86 100644 --- a/sdk/python/akernel_sdk/_sandbox_resources.py +++ b/sdk/python/akernel_sdk/_sandbox_resources.py @@ -59,21 +59,43 @@ def xpu_custom_resource(value: str) -> tuple[str, float]: return f"{xpu_type.upper()}/{re.escape(model)}/count", float(count_text) -def validate_storage_mb(value: int | None) -> None: - """Validate a writable-layer quota accepted by YuanRong's scalar wire type.""" +def _validate_storage_value(name: str, value: int | None) -> None: + """Validate one MiB storage value accepted by YuanRong's scalar wire type.""" if value is None: return if isinstance(value, bool) or not isinstance(value, int): - raise TypeError("storage_mb must be an integer") + raise TypeError(f"{name} must be an integer") if value <= 0: - raise ValueError("storage_mb must be greater than 0") + raise ValueError(f"{name} must be greater than 0") if value > MAX_STORAGE_MB: - raise ValueError(f"storage_mb must not exceed {MAX_STORAGE_MB}") + raise ValueError(f"{name} must not exceed {MAX_STORAGE_MB}") + + +def validate_storage_mb(value: int | None) -> None: + """Validate a writable-layer scheduling request in MiB.""" + + _validate_storage_value("storage_mb", value) + + +def validate_storage( + storage_mb: int | None, + storage_limit_mb: int | None, +) -> None: + """Validate writable-layer request and hard-limit values in MiB.""" + + validate_storage_mb(storage_mb) + _validate_storage_value("storage_limit_mb", storage_limit_mb) + if ( + storage_mb is not None + and storage_limit_mb is not None + and storage_limit_mb < storage_mb + ): + raise ValueError("storage_limit_mb must be greater than or equal to storage_mb") def storage_bytes(value: int) -> float: - """Convert a validated MiB quota to YuanRong's byte-valued scalar.""" + """Convert a validated positive MiB value to YuanRong's byte scalar.""" validate_storage_mb(value) return float(value * _MIB) diff --git a/sdk/python/akernel_sdk/sandbox.py b/sdk/python/akernel_sdk/sandbox.py index ba552c2..99aed80 100644 --- a/sdk/python/akernel_sdk/sandbox.py +++ b/sdk/python/akernel_sdk/sandbox.py @@ -28,7 +28,7 @@ from ._backends.base import BackendSession, SandboxSpec from ._backends.registry import load_backend from ._dockerfile_launch import DockerfileLaunch -from ._sandbox_resources import normalize_xpu, validate_storage_mb +from ._sandbox_resources import normalize_xpu, validate_storage from .commands import CommandHandle, Commands from .filesystem import Filesystem from .pty import Pty @@ -195,6 +195,7 @@ def __init__( failover: bool = False, xpu: str | None = None, storage_mb: int | None = None, + storage_limit_mb: int | None = None, network_policy: NetworkPolicy | None = None, dockerfile: DockerfileLaunch | None = None, extra_config: Mapping[str, object] | None = None, @@ -227,9 +228,13 @@ def __init__( ``type:model:count`` format. Currently only exact-model NVIDIA GPU requests are supported. The backend validates runtime compatibility. - storage_mb: Experimental writable root filesystem quota in MiB. - When omitted, the configured default is used. Explicit quotas - are validated against the selected runtime by the backend. + storage_mb: Writable root filesystem scheduling request in MiB. + When no separate limit is given, this is also the writable + layer's hard limit. ``None`` makes no explicit storage request. + storage_limit_mb: Writable root filesystem hard limit in MiB. + It must be greater than or equal to ``storage_mb`` when both + are set. ``None`` follows ``storage_mb`` or the runtime's + configured default. network_policy: Optional creation-time network policy. Omitting it leaves sandbox networking unrestricted. dockerfile: Supported Dockerfile direct-launch configuration. @@ -271,7 +276,7 @@ def __init__( if not runtime: raise ValueError("runtime must be a non-empty string") normalized_xpu = normalize_xpu(xpu) - validate_storage_mb(storage_mb) + validate_storage(storage_mb, storage_limit_mb) if network_policy is not None and not isinstance( network_policy, NetworkPolicy ): @@ -347,6 +352,7 @@ def __init__( self._memory = memory self._xpu = normalized_xpu self._storage_mb = storage_mb + self._storage_limit_mb = storage_limit_mb self._id = "" spec = SandboxSpec( @@ -370,6 +376,7 @@ def __init__( node_id=node_id, xpu=normalized_xpu, storage_mb=storage_mb, + storage_limit_mb=storage_limit_mb, network_policy=( None if network_policy is None or network_policy.is_empty @@ -519,6 +526,7 @@ def get_info(self) -> SandboxInfo: image=self._image, xpu=self._xpu, storage_mb=self._storage_mb, + storage_limit_mb=self._storage_limit_mb, ) info = self._session.get_info() return SandboxInfo( @@ -533,6 +541,11 @@ def get_info(self) -> SandboxInfo: if info.storage_mb is not None else self._storage_mb ), + storage_limit_mb=( + info.storage_limit_mb + if info.storage_limit_mb is not None + else self._storage_limit_mb + ), ) def kill(self) -> None: diff --git a/sdk/python/akernel_sdk/types.py b/sdk/python/akernel_sdk/types.py index 67a43cd..e5d1972 100644 --- a/sdk/python/akernel_sdk/types.py +++ b/sdk/python/akernel_sdk/types.py @@ -158,6 +158,7 @@ class SandboxInfo: image: str | None xpu: str | None = None storage_mb: int | None = None + storage_limit_mb: int | None = None @dataclass(frozen=True) diff --git a/sdk/python/benchmarks/sandbox_pressure.py b/sdk/python/benchmarks/sandbox_pressure.py index 5f6e334..71e0ca0 100644 --- a/sdk/python/benchmarks/sandbox_pressure.py +++ b/sdk/python/benchmarks/sandbox_pressure.py @@ -94,6 +94,7 @@ def _build_sandbox_kwargs( idle_timeout, xpu, storage_mb, + storage_limit_mb, upstream, reverse_port, listen_port, @@ -110,6 +111,8 @@ def _build_sandbox_kwargs( kwargs["xpu"] = xpu if storage_mb is not None: kwargs["storage_mb"] = storage_mb + if storage_limit_mb is not None: + kwargs["storage_limit_mb"] = storage_limit_mb if image: kwargs["image"] = image if upstream: @@ -197,6 +200,7 @@ def worker_process( idle_timeout, xpu, storage_mb, + storage_limit_mb, upstream, reverse_port, listen_port, @@ -223,6 +227,7 @@ def worker_process( idle_timeout, xpu, storage_mb, + storage_limit_mb, upstream, reverse_port, listen_port, @@ -298,7 +303,8 @@ def main(args): f" cpu req/limit : {args.cpu}m / {args.cpu_limit}m\n" f" mem req/limit : {args.memory}MiB / {args.mem_limit}MiB\n" f" xpu request : {args.xpu or ''}\n" - f" storage quota : {args.storage_mb or ''} MiB\n" + f" storage req : {args.storage_mb or ''} MiB\n" + f" storage limit : {args.storage_limit_mb or ''} MiB\n" f" tunnel mode : {bool(args.upstream)}" + ( f" (upstream={args.upstream}, reverse_port={args.reverse_port}, " @@ -325,6 +331,7 @@ def main(args): args.idle_timeout, args.xpu, args.storage_mb, + args.storage_limit_mb, args.upstream, args.reverse_port, args.listen_port, @@ -448,7 +455,13 @@ def _percentiles(label, samples_s): "--storage-mb", type=int, default=None, - help="optional writable root filesystem quota in MiB", + help="optional writable root filesystem scheduling request in MiB", + ) + parser.add_argument( + "--storage-limit-mb", + type=int, + default=None, + help="optional writable root filesystem hard limit in MiB", ) parser.add_argument( "--tunnel", diff --git a/sdk/python/examples/storage_sandbox.py b/sdk/python/examples/storage_sandbox.py index a8e4f4c..a5e16fa 100644 --- a/sdk/python/examples/storage_sandbox.py +++ b/sdk/python/examples/storage_sandbox.py @@ -12,15 +12,21 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Verify an experimental gVisor writable root filesystem quota.""" +"""Verify a writable root filesystem request and hard limit.""" from akernel_sdk import Sandbox -STORAGE_MB = 256 +STORAGE_MB = 128 +STORAGE_LIMIT_MB = 256 def main() -> None: - with Sandbox(storage_mb=STORAGE_MB, cpu=1000, memory=2048) as sandbox: + with Sandbox( + storage_mb=STORAGE_MB, + storage_limit_mb=STORAGE_LIMIT_MB, + cpu=1000, + memory=2048, + ) as sandbox: small_write = sandbox.commands.run( "dd if=/dev/zero of=/root/quota-ok bs=1M count=32 conv=fsync" ) @@ -33,7 +39,10 @@ def main() -> None: assert "No space left on device" in oversized_write.stderr, ( oversized_write.stderr ) - print(f"Writable rootfs quota enforced at {STORAGE_MB} MiB") + print( + "Writable rootfs request/limit enforced at " + f"{STORAGE_MB}/{STORAGE_LIMIT_MB} MiB" + ) if __name__ == "__main__": diff --git a/sdk/python/tests/unit/test_backends.py b/sdk/python/tests/unit/test_backends.py index d00bb42..5c148ab 100644 --- a/sdk/python/tests/unit/test_backends.py +++ b/sdk/python/tests/unit/test_backends.py @@ -63,6 +63,7 @@ def _spec(**overrides): "node_id": None, "xpu": None, "storage_mb": None, + "storage_limit_mb": None, "network_policy": None, "extra_config": MappingProxyType({}), } @@ -256,6 +257,42 @@ def test_explicit_kata_image_is_forwarded_to_native_sdk(self): self.assertEqual(sandbox_type.call_args.kwargs["runtime"], "kata") self.assertEqual(sandbox_type.call_args.kwargs["image"], "ubuntu:24.04") + def test_storage_request_and_limit_are_forwarded_to_native_sdk(self): + native = MagicMock() + native.id = "default-storage" + native.get_info.return_value = SimpleNamespace( + id="default-storage", + state="running", + cpu=1000, + memory=4096, + image=None, + ) + with patch.object( + openyuanrong_sandbox.yr_sandbox, + "Sandbox", + return_value=native, + ) as sandbox_type: + session = self.backend.create(_spec(storage_mb=128, storage_limit_mb=256)) + + kwargs = sandbox_type.call_args.kwargs + self.assertEqual(kwargs["storage_mb"], 128) + self.assertEqual(kwargs["storage_limit_mb"], 256) + info = session.get_info() + self.assertEqual(info.storage_mb, 128) + self.assertEqual(info.storage_limit_mb, 256) + + def test_unspecified_storage_limit_uses_native_default_sentinel(self): + native = MagicMock() + native.id = "default-storage" + with patch.object( + openyuanrong_sandbox.yr_sandbox, + "Sandbox", + return_value=native, + ) as sandbox_type: + self.backend.create(_spec()) + + self.assertEqual(sandbox_type.call_args.kwargs["storage_limit_mb"], 0) + def test_create_converts_inputs_and_preserves_akernel_outputs(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 a766a68..cf63784 100644 --- a/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py +++ b/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py @@ -41,6 +41,7 @@ def build_options(self, **overrides): "node_id": None, "xpu": None, "storage_mb": None, + "storage_limit_mb": None, "network_policy": None, "extra_config": {}, } @@ -115,20 +116,48 @@ def test_resource_limit_validation(self): def test_xpu_and_storage_translation_is_runtime_agnostic(self): options = self.build_options( - runtime="gvisor-next", xpu="GPU:L20:2", storage_mb=256 + runtime="gvisor-next", + xpu="GPU:L20:2", + storage_mb=128, + storage_limit_mb=256, ) self.assertEqual( options.custom_resources, { "GPU/l20/count": 2.0, - "storage": float(256 * 1024 * 1024), + "storage": float(128 * 1024 * 1024), }, ) + self.assertEqual( + options.custom_extensions["STORAGE_LIMIT"], + str(256 * 1024 * 1024), + ) self.assertEqual( json.loads(options.custom_extensions["rootfs"]), {"runtime": "gvisor-next"}, ) + def test_storage_limit_alone_reserves_its_hard_limit(self): + options = self.build_options(storage_limit_mb=256) + + self.assertEqual( + options.custom_resources["storage"], + float(256 * 1024 * 1024), + ) + self.assertEqual( + options.custom_extensions["STORAGE_LIMIT"], + str(256 * 1024 * 1024), + ) + + def test_storage_request_without_limit_omits_limit_extension(self): + options = self.build_options(storage_mb=128) + + self.assertEqual( + options.custom_resources["storage"], + float(128 * 1024 * 1024), + ) + self.assertNotIn("STORAGE_LIMIT", options.custom_extensions) + def test_network_policy_uses_custom_extension_wire_format(self): options = self.build_options( network_policy=NetworkPolicy.deny_dns("github.com", "*.github.com") diff --git a/sdk/python/tests/unit/test_sandbox.py b/sdk/python/tests/unit/test_sandbox.py index 6115869..7c12e36 100644 --- a/sdk/python/tests/unit/test_sandbox.py +++ b/sdk/python/tests/unit/test_sandbox.py @@ -64,6 +64,7 @@ def test_default_constructor_and_info(self): self.assertEqual(sandbox.get_info().cpu, 2000) self.assertIsNone(sandbox.get_info().xpu) self.assertIsNone(sandbox.get_info().storage_mb) + self.assertIsNone(sandbox.get_info().storage_limit_mb) self.assertIsNone(sandbox.startup_command) spec = self.backend.create.call_args.args[0] @@ -72,6 +73,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.storage_limit_mb) self.assertFalse(spec.failover) self.assertIsNone(spec.network_policy) self.assertEqual(dict(spec.extra_config), {}) @@ -258,18 +260,31 @@ def test_xpu_request_validation(self): Sandbox(xpu=value) self.backend.create.assert_not_called() - def test_storage_request_is_delegated_to_backend(self): - sandbox = Sandbox(runtime="storage-runtime", storage_mb=256) - self.assertEqual(sandbox.get_info().storage_mb, 256) + def test_storage_request_and_limit_are_delegated_to_backend(self): + sandbox = Sandbox( + runtime="storage-runtime", + storage_mb=128, + storage_limit_mb=256, + ) + info = sandbox.get_info() + self.assertEqual(info.storage_mb, 128) + self.assertEqual(info.storage_limit_mb, 256) spec = self.backend.create.call_args.args[0] self.assertEqual(spec.runtime, "storage-runtime") - self.assertEqual(spec.storage_mb, 256) + self.assertEqual(spec.storage_mb, 128) + self.assertEqual(spec.storage_limit_mb, 256) sandbox.kill() - def test_storage_request_validation(self): - for value in (True, 0, -1, 1.5): - with self.subTest(value=value), self.assertRaises((TypeError, ValueError)): - Sandbox(storage_mb=value) + def test_storage_request_and_limit_validation(self): + for name in ("storage_mb", "storage_limit_mb"): + for value in (True, 0, -1, 1.5): + with ( + self.subTest(name=name, value=value), + self.assertRaises((TypeError, ValueError)), + ): + Sandbox(**{name: value}) + with self.assertRaisesRegex(ValueError, "greater than or equal"): + Sandbox(storage_mb=256, storage_limit_mb=128) self.backend.create.assert_not_called() def test_block_network_policy_is_passed_to_backend(self): diff --git a/sdk/python/tests/unit/test_sandbox_resources.py b/sdk/python/tests/unit/test_sandbox_resources.py index 98642a4..9559870 100644 --- a/sdk/python/tests/unit/test_sandbox_resources.py +++ b/sdk/python/tests/unit/test_sandbox_resources.py @@ -18,6 +18,7 @@ MAX_STORAGE_MB, normalize_xpu, storage_bytes, + validate_storage, validate_storage_mb, xpu_custom_resource, ) @@ -37,6 +38,14 @@ def test_storage_wire_value_and_upper_bound(self): with self.assertRaises(ValueError): validate_storage_mb(MAX_STORAGE_MB + 1) + def test_storage_limit_must_cover_request(self): + validate_storage(None, None) + validate_storage(None, 256) + validate_storage(128, None) + validate_storage(128, 256) + with self.assertRaisesRegex(ValueError, "greater than or equal"): + validate_storage(256, 128) + if __name__ == "__main__": unittest.main()