From cffbea442ebe88d7a2b03aeb01e7d5ffd48574a2 Mon Sep 17 00:00:00 2001 From: Caroline Zhu Date: Fri, 10 Jul 2026 11:45:34 -0700 Subject: [PATCH] Add set-up for packaging existing Python runsc bindings into a wheel. PiperOrigin-RevId: 945818696 --- sandboxexec/sandbox/BUILD | 10 +++-- sandboxexec/sandbox/python/gvisor/__init__.py | 18 +++++++++ .../sandbox/{ => python/gvisor}/sandbox.py | 39 +++++++++---------- sandboxexec/sandbox/python/pyproject.toml | 18 +++++++++ .../{ => python/tests}/sandbox_test.py | 18 ++++----- 5 files changed, 71 insertions(+), 32 deletions(-) create mode 100644 sandboxexec/sandbox/python/gvisor/__init__.py rename sandboxexec/sandbox/{ => python/gvisor}/sandbox.py (89%) create mode 100644 sandboxexec/sandbox/python/pyproject.toml rename sandboxexec/sandbox/{ => python/tests}/sandbox_test.py (93%) diff --git a/sandboxexec/sandbox/BUILD b/sandboxexec/sandbox/BUILD index adc566f923..bcaca1d1c0 100644 --- a/sandboxexec/sandbox/BUILD +++ b/sandboxexec/sandbox/BUILD @@ -34,15 +34,19 @@ go_test( py_library( name = "sandbox_py", - srcs = ["sandbox.py"], + srcs = [ + "python/gvisor/__init__.py", + "python/gvisor/sandbox.py", + ], + imports = ["python"], visibility = ["//:__subpackages__"], ) py_test( name = "sandbox_py_test", - srcs = ["sandbox_test.py"], + srcs = ["python/tests/sandbox_test.py"], data = ["//runsc"], - main = "sandbox_test.py", + main = "python/tests/sandbox_test.py", tags = ["nogotsan"], deps = [ ":sandbox_py", diff --git a/sandboxexec/sandbox/python/gvisor/__init__.py b/sandboxexec/sandbox/python/gvisor/__init__.py new file mode 100644 index 0000000000..3b2dc24099 --- /dev/null +++ b/sandboxexec/sandbox/python/gvisor/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2026 The gVisor Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""gVisor sandboxexec sandbox package.""" + +from .sandbox import Error +from .sandbox import Sandbox diff --git a/sandboxexec/sandbox/sandbox.py b/sandboxexec/sandbox/python/gvisor/sandbox.py similarity index 89% rename from sandboxexec/sandbox/sandbox.py rename to sandboxexec/sandbox/python/gvisor/sandbox.py index 0a66258ba6..d240a8404b 100644 --- a/sandboxexec/sandbox/sandbox.py +++ b/sandboxexec/sandbox/python/gvisor/sandbox.py @@ -27,7 +27,7 @@ from typing import Optional, Tuple -class SandboxError(Exception): +class Error(Exception): """Base exception for Sandbox operations.""" @@ -50,7 +50,7 @@ def __init__( enable_networking: Whether networking is enabled inside the sandbox. Raises: - SandboxError: If sandbox creation fails. + Error: If sandbox creation fails. """ self._enable_networking = enable_networking self._runtime_dir = "" @@ -65,7 +65,7 @@ def __init__( try: self._runtime_dir = tempfile.mkdtemp(prefix="gvisor-sandbox-") except OSError as e: - raise SandboxError(f"failed to create runtime directory: {e}") from e + raise Error(f"failed to create runtime directory: {e}") from e self._owns_runtime_dir = True else: self._runtime_dir = runtime_dir @@ -75,23 +75,22 @@ def __init__( try: if os.geteuid() != 0 and self._enable_networking: - raise SandboxError("enabling networking requires running as root") + raise Error("enabling networking requires running as root") self._state_dir = os.path.join(self._runtime_dir, "state") try: os.makedirs(self._state_dir, mode=0o700, exist_ok=True) except OSError as e: - raise SandboxError( - f"failed to create sandbox state directory: {e}" - ) from e + raise Error(f"failed to create sandbox state directory: {e}") from e - # Verify permissions (mode might be different if directory already existed). + # Verify permissions (mode might be different if directory already + # existed). try: stat_info = os.stat(self._state_dir) if (stat_info.st_mode & 0o777) != 0o700: os.chmod(self._state_dir, 0o700) except OSError as e: - raise SandboxError( + raise Error( "sandbox state directory has incorrect permissions and failed to" f" chmod: {e}" ) from e @@ -121,7 +120,7 @@ def __init__( timeout=30, ) except subprocess.TimeoutExpired as e: - raise SandboxError("sandbox creation timed out (runsc run hung)") from e + raise Error("sandbox creation timed out (runsc run hung)") from e except subprocess.CalledProcessError as e: stderr_content = "" if os.path.exists(stderr_path): @@ -130,9 +129,9 @@ def __init__( stderr_content = f.read() except OSError: pass - raise SandboxError( - f"failed to create sandbox via subprocess: exit code {e.returncode}," - f" stderr: {stderr_content}" + raise Error( + "failed to create sandbox via subprocess: exit code" + f" {e.returncode}, stderr: {stderr_content}" ) from e except Exception: self.close() @@ -163,7 +162,7 @@ def _find_runsc(self) -> str: path = shutil.which("runsc") if path is not None: return path - raise SandboxError("runsc binary is not found") + raise Error("runsc binary is not found") def _create_bundle(self) -> str: """Creates the OCI bundle directory and config.json. @@ -172,14 +171,14 @@ def _create_bundle(self) -> str: The path to the created bundle directory. Raises: - SandboxError: If bundle creation fails. + Error: If bundle creation fails. """ bundle_dir = os.path.join(self._runtime_dir, self._id) rootfs_dir = os.path.join(bundle_dir, "rootfs") try: os.makedirs(rootfs_dir, mode=0o755, exist_ok=True) except OSError as e: - raise SandboxError(f"failed to create bundle directories: {e}") from e + raise Error(f"failed to create bundle directories: {e}") from e namespaces = [ {"type": "pid"}, @@ -242,7 +241,7 @@ def _create_bundle(self) -> str: with open(config_path, "w") as f: json.dump(spec, f, indent=2) except OSError as e: - raise SandboxError(f"failed to create config.json: {e}") from e + raise Error(f"failed to create config.json: {e}") from e return bundle_dir @@ -260,7 +259,7 @@ def exec( A tuple of (stdout, stderr) strings. Raises: - SandboxError: If the command execution fails or times out. + Error: If the command execution fails or times out. """ runsc_args = ["--root", self._state_dir, "exec", self._id, cmd] + list(args) try: @@ -273,9 +272,9 @@ def exec( ) return result.stdout, result.stderr except subprocess.TimeoutExpired as e: - raise SandboxError(f"exec timed out after {timeout} seconds") from e + raise Error(f"exec timed out after {timeout} seconds") from e except subprocess.CalledProcessError as e: - raise SandboxError(f"exec failed: {e.stderr}") from e + raise Error(f"exec failed: {e.stderr}") from e def close(self): """Kills the sandbox processes and cleans up directories.""" diff --git a/sandboxexec/sandbox/python/pyproject.toml b/sandboxexec/sandbox/python/pyproject.toml new file mode 100644 index 0000000000..d511bd35ca --- /dev/null +++ b/sandboxexec/sandbox/python/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=61.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "gvisor" +version = "0.1.0" +description = "Python bindings for gVisor." +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: POSIX :: Linux", +] +dependencies = [] + +[tool.setuptools] +packages = ["gvisor"] diff --git a/sandboxexec/sandbox/sandbox_test.py b/sandboxexec/sandbox/python/tests/sandbox_test.py similarity index 93% rename from sandboxexec/sandbox/sandbox_test.py rename to sandboxexec/sandbox/python/tests/sandbox_test.py index 5e9aab4e09..342faa1ab2 100644 --- a/sandboxexec/sandbox/sandbox_test.py +++ b/sandboxexec/sandbox/python/tests/sandbox_test.py @@ -21,7 +21,7 @@ import tempfile import unittest -from sandboxexec.sandbox import sandbox +from gvisor import sandbox def find_runsc() -> str: @@ -67,7 +67,7 @@ def test_exec_dmesg(self): def test_exec_timeout(self): enable_networking = os.geteuid() == 0 with sandbox.Sandbox(enable_networking=enable_networking) as sb: - with self.assertRaises(sandbox.SandboxError) as ctx: + with self.assertRaises(sandbox.Error) as ctx: sb.exec("sleep", "10", timeout=1) self.assertIn("exec timed out", str(ctx.exception)) @@ -80,15 +80,14 @@ def test_exec_with_args(self): def test_exec_invalid_command_or_args(self): enable_networking = os.geteuid() == 0 with sandbox.Sandbox(enable_networking=enable_networking) as sb: - with self.assertRaises(sandbox.SandboxError) as ctx: + with self.assertRaises(sandbox.Error) as ctx: sb.exec("nonexistent_command_xyz123") self.assertIn("exec failed", str(ctx.exception)) - with self.assertRaises(sandbox.SandboxError) as ctx: + with self.assertRaises(sandbox.Error) as ctx: sb.exec("ls", "--invalid-flag-xyz123") self.assertIn("exec failed", str(ctx.exception)) - def test_sandbox_options(self): enable_networking = os.geteuid() == 0 with tempfile.TemporaryDirectory() as runtime_dir: @@ -106,17 +105,18 @@ def test_non_root_networking_error(self): self.skipTest("this test must be run as non-root") before_tmp = set(os.listdir(tempfile.gettempdir())) - with self.assertRaises(sandbox.SandboxError) as ctx: + with self.assertRaises(sandbox.Error) as ctx: sandbox.Sandbox(enable_networking=True) after_tmp = set(os.listdir(tempfile.gettempdir())) self.assertIn( "enabling networking requires running as root", str(ctx.exception) ) - leaked = [f for f in after_tmp - before_tmp if f.startswith("gvisor-sandbox-")] + leaked = [ + f for f in after_tmp - before_tmp if f.startswith("gvisor-sandbox-") + ] self.assertEqual(leaked, []) - def test_create_bundle(self): # Test the internal _create_bundle method to verify config.json for enable_networking in [False, True]: @@ -133,7 +133,7 @@ def test_create_bundle(self): sandbox_id=sandbox_id, enable_networking=enable_networking, ) - except sandbox.SandboxError as e: + except sandbox.Error as e: self.fail(f"Failed to create sandbox: {e}") try: