diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdd7ee9..5cc7701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,14 +41,36 @@ jobs: - name: Install SDK dependencies run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY python -m pip install -e './sdk/python[all]' - name: Run unit tests run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY make sdk-test + sdk-quality: + name: Python SDK lint and type checks + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: sdk/python/pyproject.toml + + - name: Install SDK development dependencies + run: | + python -m pip install -e './sdk/python[dev]' + + - name: Run SDK quality checks + run: | + make sdk-check + deployment-script-syntax: name: Deployment script syntax runs-on: ubuntu-latest @@ -65,7 +87,6 @@ jobs: - name: Check deployment script syntax run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY make deploy-script-check standalone-e2e: @@ -81,7 +102,6 @@ jobs: - name: Initialize build submodules run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY git submodule update --init src/sandboxd src/distill-fs - name: Set up Python @@ -93,12 +113,10 @@ jobs: - name: Install SDK dependencies run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY python -m pip install -e './sdk/python[all]' - name: Prepare host kernel modules run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY sudo modprobe loop sudo modprobe erofs sudo modprobe br_netfilter @@ -106,7 +124,6 @@ jobs: - name: Build all-in-one image run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY AKERNEL_ENABLE_KATA=false \ make build \ IMAGE_REPOSITORY=akernel-ci/all-in-one \ @@ -115,14 +132,12 @@ jobs: - name: Start standalone AKernel run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY IMAGE="akernel-ci/all-in-one:${GITHUB_SHA}" \ AKERNEL_NAT_BACKEND=iptables \ ./deploy/standalone/start.sh - name: Run SDK end-to-end examples run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY gateway_ip="$(docker inspect \ --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \ akernel-traefik)" @@ -151,7 +166,6 @@ jobs: - name: Show standalone diagnostics if: failure() run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY docker ps -a for container in akernel-node akernel-traefik; do if docker container inspect "${container}" >/dev/null 2>&1; then @@ -168,7 +182,6 @@ jobs: - name: Stop standalone AKernel if: always() run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY if [[ -x ./deploy/standalone/stop.sh ]]; then ./deploy/standalone/stop.sh else diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index 31f7bfc..2693da5 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -35,8 +35,6 @@ jobs: env: RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY - if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Release tag must use the stable vX.Y.Z format: ${RELEASE_TAG}" >&2 exit 1 @@ -68,18 +66,15 @@ jobs: - name: Install build dependencies run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY python -m pip install --upgrade pip python -m pip install -e './sdk/python[dev]' 'twine>=5,<7' - name: Check SDK run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY make sdk-check - name: Build distributions run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY rm -rf sdk/python/dist python -m build --outdir sdk/python/dist sdk/python test "$(find sdk/python/dist -maxdepth 1 -name '*.whl' | wc -l)" -eq 1 @@ -88,7 +83,6 @@ jobs: - name: Test installed wheel run: | - unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY python -m venv /tmp/akernel-wheel-test /tmp/akernel-wheel-test/bin/python -m pip install \ --no-deps sdk/python/dist/*.whl diff --git a/AGENTS.md b/AGENTS.md index c778f55..8d240ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,11 +9,12 @@ project guidance. AKernel provides cluster-backed remote sandbox environments for agents and developer workflows. The current public user-facing surface is the Python `akernel-sdk`, including the `akernel_sdk.Sandbox` API and the `ak` CLI. -The default sandbox runtime is gVisor runsc; callers may select Kata -Containers when the cluster has a KVM-capable node. 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. +The default sandbox runtime is gVisor runsc. Runtime identifiers are forwarded +to the selected backend, which owns availability and compatibility checks; the +bundled deployment also advertises Kata Containers on KVM-capable nodes. +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 and configurable writable-storage requests currently require runsc. diff --git a/Makefile b/Makefile index 88300cf..0953c33 100644 --- a/Makefile +++ b/Makefile @@ -145,7 +145,8 @@ sdk-test: .PHONY: sdk-check sdk-check: sdk-test - @cd sdk/python; \ + @set -e; \ + cd sdk/python; \ python3 -m ruff check akernel_sdk tests; \ python3 -m mypy akernel_sdk diff --git a/sdk/python/README.md b/sdk/python/README.md index 3e0d969..56b981d 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -115,8 +115,9 @@ with Sandbox(xpu="gpu:l20:1") as sandbox: The `type:model:count` value is case-insensitive and canonicalized to lower case. The model is required and matched exactly; wildcard models are not -supported. GPU sandboxes currently require the gVisor `runsc` runtime and a -node configured for gVisor nvproxy. +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: @@ -125,9 +126,10 @@ with Sandbox(storage_mb=20 * 1024) as sandbox: print(sandbox.commands.run("df -h /").stdout) ``` -An explicit `storage_mb` quota currently requires `runsc` and uses sandboxd's -disk-backed XFS filestore. When it is omitted, sandboxd retains its configured -default 10 GiB memory-backed writable overlay. See +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 [`examples/gpu_sandbox.py`](./examples/gpu_sandbox.py) and [`examples/storage_sandbox.py`](./examples/storage_sandbox.py). @@ -195,7 +197,10 @@ configuration, as described in the ## Sandbox runtimes -AKernel uses the gVisor `runsc` runtime when `runtime` is omitted. Callers may also select `runsc` explicitly or request Kata Containers: +AKernel uses the gVisor `runsc` runtime when `runtime` is omitted. Runtime +identifiers are forwarded to the selected backend instead of being restricted +by an SDK-owned registry. The bundled deployment advertises `runsc` and Kata +Containers: ```python default_sandbox = Sandbox() @@ -203,7 +208,10 @@ runsc_sandbox = Sandbox(runtime="runsc") kata_sandbox = Sandbox(runtime="kata") ``` -Kata requires at least one cluster node whose sandboxd instance successfully initialized the Kata runtime with a usable `/dev/kvm` device. Nodes without KVM remain available for runsc workloads and do not advertise Kata; when no eligible Kata node exists, the scheduler returns a no-resource error. +Kata requires at least one cluster node whose sandboxd instance successfully +initialized the Kata runtime with a usable `/dev/kvm` device. Nodes without +KVM remain available for runsc workloads and do not advertise Kata. A runtime +that is unavailable in the cluster fails scheduling or backend validation. See [`examples/sandbox_runtime.py`](./examples/sandbox_runtime.py) for a runnable example. diff --git a/sdk/python/akernel_sdk/_backends/base.py b/sdk/python/akernel_sdk/_backends/base.py index 910cee2..1fae783 100644 --- a/sdk/python/akernel_sdk/_backends/base.py +++ b/sdk/python/akernel_sdk/_backends/base.py @@ -19,7 +19,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field from enum import Enum, auto -from typing import Literal, Protocol +from typing import Protocol from .._addresses import Endpoint from ..types import ( @@ -37,7 +37,6 @@ class Capability(Enum): """Features whose availability differs between backends.""" - KATA_RUNTIME = auto() S3_ROOTFS = auto() NODE_PLACEMENT = auto() CUSTOM_REVERSE_TUNNEL_PORTS = auto() @@ -59,7 +58,7 @@ class SandboxSpec: image: str | None rootfs: S3Config | None - runtime: Literal["runsc", "kata"] + runtime: str cpu: int memory: int cpu_limit: int diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py index 4e28777..715bcf9 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sandbox.py @@ -278,7 +278,6 @@ class OpenYuanRongSandboxBackend: namespace = _NAMESPACE capabilities = frozenset( { - Capability.KATA_RUNTIME, Capability.S3_ROOTFS, Capability.NODE_PLACEMENT, } diff --git a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py index 9706b46..87503e2 100644 --- a/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py +++ b/sdk/python/akernel_sdk/_backends/openyuanrong_sdk_impl.py @@ -171,10 +171,6 @@ def build_options( raise ValueError("mem_limit must be 0 or greater than or equal to memory") normalized_xpu = normalize_xpu(xpu) validate_storage_mb(storage_mb) - if normalized_xpu is not None and runtime != "runsc": - raise ValueError("xpu is currently supported only by runsc") - if storage_mb is not None and runtime != "runsc": - raise ValueError("storage_mb is currently supported only by runsc") options = yr.InvokeOptions() # A Sandbox is driven by one sequential SDK client. Disabling ordered RPC diff --git a/sdk/python/akernel_sdk/sandbox.py b/sdk/python/akernel_sdk/sandbox.py index 90f95e0..02d426d 100644 --- a/sdk/python/akernel_sdk/sandbox.py +++ b/sdk/python/akernel_sdk/sandbox.py @@ -22,7 +22,6 @@ import urllib.request from collections.abc import Mapping, Sequence from types import MappingProxyType -from typing import Literal, cast from ._addresses import Endpoint, api_endpoint_from_env, gateway_endpoint_from_env from ._backends.base import BackendSession, SandboxSpec @@ -33,7 +32,6 @@ from .pty import Pty from .types import HttpReverseTunnel, Mount, NetworkPolicy, S3Config, SandboxInfo -_SUPPORTED_RUNTIMES = ("runsc", "kata") _traefik_internal_ip_cache: str | None = None logger = logging.getLogger(__name__) @@ -108,8 +106,8 @@ def _get_traefik_internal_ip(gateway: Endpoint) -> tuple[str, int]: class Sandbox: """A remote AKernel sandbox. - The public API is backend-neutral. AKernel supports the gVisor ``runsc`` - runtime and Kata Containers on KVM-capable nodes. + The public API is backend-neutral. The selected backend and cluster + determine which sandbox runtime identifiers are available. """ def __init__( @@ -141,7 +139,8 @@ def __init__( Args: image: OCI image used as the sandbox root filesystem. rootfs: S3-compatible EROFS root filesystem configuration. - runtime: Sandbox runtime name: ``runsc`` or ``kata``. + runtime: Sandbox runtime identifier. Defaults to ``runsc``; + availability is determined by the backend and cluster. cpu: Requested CPU in millicores. memory: Requested memory in MiB. cpu_limit: CPU limit in millicores, or zero to follow ``cpu``. @@ -158,10 +157,11 @@ def __init__( node_id: Require placement on a specific AKernel node. xpu: Experimental whole-device accelerator request in ``type:model:count`` format. Currently only exact-model NVIDIA - GPU requests with the ``runsc`` runtime are supported. + 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 - currently require the ``runsc`` runtime. + are validated against the selected runtime by the backend. network_policy: Optional creation-time network policy. Omitting it leaves sandbox networking unrestricted. @@ -177,21 +177,17 @@ def __init__( raise TypeError("rootfs must be an S3Config") if image is not None and rootfs is not None: raise ValueError("image and rootfs are mutually exclusive") - if runtime not in _SUPPORTED_RUNTIMES: - raise ValueError( - f"unsupported runtime {runtime!r}; " - f"supported runtimes: {', '.join(_SUPPORTED_RUNTIMES)}" - ) + if not isinstance(runtime, str): + raise TypeError("runtime must be a string") + runtime = runtime.strip() + if not runtime: + 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 ): raise TypeError("network_policy must be a NetworkPolicy or None") - if normalized_xpu is not None and runtime != "runsc": - raise ValueError("xpu is currently supported only by runsc") - if storage_mb is not None and runtime != "runsc": - raise ValueError("storage_mb is currently supported only by runsc") _validate_integer("cpu", cpu, minimum=1) _validate_integer("memory", memory, minimum=1) _validate_integer("cpu_limit", cpu_limit, minimum=0) @@ -255,7 +251,7 @@ def __init__( spec = SandboxSpec( image=image, rootfs=rootfs, - runtime=cast(Literal["runsc", "kata"], runtime), + runtime=runtime, cpu=cpu, memory=memory, cpu_limit=cpu_limit, diff --git a/sdk/python/benchmarks/sandbox_pressure.py b/sdk/python/benchmarks/sandbox_pressure.py index 57296f6..5f6e334 100644 --- a/sdk/python/benchmarks/sandbox_pressure.py +++ b/sdk/python/benchmarks/sandbox_pressure.py @@ -419,7 +419,6 @@ def _percentiles(label, samples_s): ) parser.add_argument( "--runtime", - choices=("runsc", "kata"), default=DEFAULT_RUNTIME, help="sandbox runtime (default: AKERNEL_PRESSURE_RUNTIME or runsc)", ) diff --git a/sdk/python/tests/unit/test_addresses.py b/sdk/python/tests/unit/test_addresses.py index 96671ed..938f2b0 100644 --- a/sdk/python/tests/unit/test_addresses.py +++ b/sdk/python/tests/unit/test_addresses.py @@ -51,7 +51,9 @@ def test_explicit_server_port_uses_plain_http_gateway(self): gateway_expected = ("http", "10.0.0.1", 8888, False) self.assertEqual(endpoint_tuple(api_endpoint_from_env()), expected) self.assertEqual(endpoint_tuple(exec_endpoint_from_env()), expected) - self.assertEqual(endpoint_tuple(gateway_endpoint_from_env()), gateway_expected) + self.assertEqual( + endpoint_tuple(gateway_endpoint_from_env()), gateway_expected + ) def test_gateway_override_defaults_to_plain_http(self): with patch.dict( diff --git a/sdk/python/tests/unit/test_backends.py b/sdk/python/tests/unit/test_backends.py index 98e8623..430ab03 100644 --- a/sdk/python/tests/unit/test_backends.py +++ b/sdk/python/tests/unit/test_backends.py @@ -147,20 +147,20 @@ def test_connection_config_maps_to_yr_environment(self): self.assertEqual(os.environ["YR_GATEWAY_TLS"], "0") self.assertEqual(os.environ["YR_TOKEN"], "secret") - def test_kata_without_explicit_rootfs_passes_runtime_config_override(self): + def test_runtime_identifier_without_explicit_rootfs_is_forwarded(self): native = MagicMock() - native.id = "default-kata" + native.id = "default-gvisor-next" with patch.object( openyuanrong_sandbox.yr_sandbox, "Sandbox", return_value=native, ) as sandbox_type: - self.backend.create(_spec(runtime="kata")) + self.backend.create(_spec(runtime="gvisor-next")) # YuanRong applies this runtime as a configuration override to the # deployed default rootfs; the adapter does not build a filesystem # overlay. - self.assertEqual(sandbox_type.call_args.kwargs["runtime"], "kata") + self.assertEqual(sandbox_type.call_args.kwargs["runtime"], "gvisor-next") self.assertIsNone(sandbox_type.call_args.kwargs["rootfs"]) def test_runsc_without_explicit_rootfs_passes_runtime_config_override(self): diff --git a/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py b/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py index 109e577..1dd3a29 100644 --- a/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py +++ b/sdk/python/tests/unit/test_openyuanrong_sdk_impl.py @@ -112,8 +112,10 @@ def test_resource_limit_validation(self): ): self.build_options(schedule_timeout=value) - def test_xpu_and_storage_translation(self): - options = self.build_options(xpu="GPU:L20:2", storage_mb=256) + def test_xpu_and_storage_translation_is_runtime_agnostic(self): + options = self.build_options( + runtime="gvisor-next", xpu="GPU:L20:2", storage_mb=256 + ) self.assertEqual( options.custom_resources, { @@ -121,12 +123,10 @@ def test_xpu_and_storage_translation(self): "storage": float(256 * 1024 * 1024), }, ) - - def test_xpu_and_storage_require_runsc(self): - with self.assertRaisesRegex(ValueError, "xpu.*runsc"): - self.build_options(runtime="kata", xpu="gpu:l20:1") - with self.assertRaisesRegex(ValueError, "storage_mb.*runsc"): - self.build_options(runtime="kata", storage_mb=256) + self.assertEqual( + json.loads(options.custom_extensions["rootfs"]), + {"runtime": "gvisor-next"}, + ) def test_network_policy_uses_custom_extension_wire_format(self): options = self.build_options( diff --git a/sdk/python/tests/unit/test_sandbox.py b/sdk/python/tests/unit/test_sandbox.py index 9dfcf23..3aec282 100644 --- a/sdk/python/tests/unit/test_sandbox.py +++ b/sdk/python/tests/unit/test_sandbox.py @@ -136,17 +136,30 @@ def test_rootfs_requires_s3_config(self): ) self.backend.create.assert_not_called() - def test_supported_runtimes(self): - sandbox = Sandbox(runtime="kata") + def test_runtime_identifier_is_normalized_and_passed_to_backend(self): + sandbox = Sandbox(runtime=" gvisor-next ") + spec = self.backend.create.call_args.args[0] + self.assertEqual(spec.runtime, "gvisor-next") sandbox.kill() - with self.assertRaisesRegex(ValueError, "unsupported runtime"): - Sandbox(runtime="unknown") + def test_runtime_identifier_validation(self): + for value in (None, 1): + 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" + ): + Sandbox(runtime=value) + self.backend.create.assert_not_called() - def test_xpu_request_is_normalized_and_passed_to_backend(self): - sandbox = Sandbox(xpu=" GPU:L20:02 ") + def test_xpu_request_is_normalized_and_delegated_to_backend(self): + sandbox = Sandbox(runtime="gpu-runtime", xpu=" GPU:L20:02 ") self.assertEqual(sandbox.get_info().xpu, "gpu:l20:2") spec = self.backend.create.call_args.args[0] + self.assertEqual(spec.runtime, "gpu-runtime") self.assertEqual(spec.xpu, "gpu:l20:2") sandbox.kill() @@ -163,14 +176,13 @@ def test_xpu_request_validation(self): for value, error_type in invalid: with self.subTest(value=value), self.assertRaises(error_type): Sandbox(xpu=value) - with self.assertRaisesRegex(ValueError, "xpu.*runsc"): - Sandbox(runtime="kata", xpu="gpu:l20:1") self.backend.create.assert_not_called() - def test_storage_request_is_passed_to_backend(self): - sandbox = Sandbox(storage_mb=256) + 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) spec = self.backend.create.call_args.args[0] + self.assertEqual(spec.runtime, "storage-runtime") self.assertEqual(spec.storage_mb, 256) sandbox.kill() @@ -178,8 +190,6 @@ 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) - with self.assertRaisesRegex(ValueError, "storage_mb.*runsc"): - Sandbox(runtime="kata", storage_mb=256) self.backend.create.assert_not_called() def test_block_network_policy_is_passed_to_backend(self):