Skip to content

Commit 83317b2

Browse files
authored
Initial implementation of Agentic Sandbox Python Client, tester, distribution and packaging files. (#134)
Added build info in README Added reviewed changes - Implemented logger, readiness probe in the sandbox template, removed unnecessary sleep calls, and updated README for installation instructions.
1 parent aad77b0 commit 83317b2

File tree

6 files changed

+485
-0
lines changed

6 files changed

+485
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
build/
2+
venv/
3+
4+
__pycache__/
5+
*.pyc
6+
.env
7+
.DS_Store
8+
.idea/
9+
.vscode/
10+
logs/
11+
*.log
12+
13+
agentic_sandbox.egg-info/
14+
dist/
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Agentic Sandbox Client Python
2+
3+
This Python client provides a simple, high-level interface for creating and interacting with sandboxes managed by the Agent Sandbox controller. It's designed to be used as a context manager, ensuring that sandbox resources are properly created and cleaned up.
4+
5+
## Usage
6+
7+
### Prerequisites
8+
9+
- A running Kubernetes cluster (e.g., `kind`).
10+
- The Agent Sandbox controller must be deployed with the extensions feature enabled.
11+
- A `SandboxTemplate` resource must be created in the cluster.
12+
- The `kubectl` command-line tool must be installed and configured to connect to your cluster.
13+
14+
### Installation
15+
16+
1. **Create a virtual environment:**
17+
```bash
18+
python3 -m venv .venv
19+
```
20+
2. **Activate the virtual environment:**
21+
```bash
22+
source .venv/bin/activate
23+
```
24+
3. **Option 1: Install from source via git:**
25+
```bash
26+
pip install "git+https://github.com/kubernetes-sigs/agent-sandbox.git#subdirectory=clients/python/agentic-sandbox-client"
27+
```
28+
4. **Option 2: Install from source in editable mode:**
29+
```bash
30+
git clone https://github.com/kubernetes-sigs/agent-sandbox.git
31+
cd agent-sandbox/clients/agentic-sandbox-client-python
32+
cd ~/path_to_venv
33+
pip install -e .
34+
```
35+
36+
### Example:
37+
38+
```python
39+
from agentic_sandbox import SandboxClient
40+
41+
with SandboxClient(template_name="sandbox-python-template", namespace="default") as sandbox:
42+
result = sandbox.run("echo 'Hello, World!'")
43+
print(result.stdout)
44+
```
45+
46+
## How It Works
47+
48+
The `SandboxClient` client automates the entire lifecycle of a temporary sandbox environment:
49+
50+
1. **Initialization (`with SandboxClient(...)`):** The client is initialized with the name of the `SandboxTemplate` you want to use and the namespace where the resources should be created:
51+
52+
- **`template_name` (str):** The name of the `SandboxTemplate` resource to use for creating the sandbox.
53+
- **`namespace` (str, optional):** The Kubernetes namespace to create the `SandboxClaim` in. Defaults to "default".
54+
- When you create a `SandboxClient` instance within a `with` block, it initiates the process of creating a sandbox.
55+
2. **Claim Creation:** It creates a `SandboxClaim` Kubernetes resource. This claim tells the agent-sandbox controller to provision a new sandbox using a predefined `SandboxTemplate`.
56+
3. **Waiting for Readiness:** The client watches the Kubernetes API for the corresponding `Sandbox` resource to be created and become "Ready". This indicates that the pod is running and the server inside is active.
57+
4. **Port Forwarding:** Once the sandbox pod is ready, the client automatically starts a `kubectl port-forward` process in the background. This creates a secure tunnel from your local machine to the sandbox pod, allowing you to communicate with the server running inside.
58+
5. **Interaction:** The `SandboxClient` object provides three main methods to interact with the running sandbox:
59+
* `run(command)`: Executes a shell command inside the sandbox.
60+
* `write(path, content)`: Uploads a file to the sandbox.
61+
* `read(path)`: Downloads a file from the sandbox.
62+
6. **Cleanup (`__exit__`):** When the `with` block is exited (either normally or due to an error), the client automatically cleans up all resources. It terminates the `kubectl port-forward` process and deletes the `SandboxClaim`, which in turn causes the controller to delete the `Sandbox` pod.
63+
64+
65+
## How to Test the Client
66+
67+
A test script, `test_client.py`, is included to verify the client's functionality.
68+
You should see output indicating that the tests for command execution and file operations have passed.
69+
70+
## Packaging and Installation
71+
72+
This client is configured as a standard Python package using `pyproject.toml`.
73+
74+
### Prerequisites
75+
76+
- Python 3.7+
77+
- `pip`
78+
- `build` (install with `pip install build`)
79+
80+
### Building the Package
81+
82+
To build the package from the source, navigate to the `agentic-sandbox-client` directory and run the following command:
83+
84+
```bash
85+
python -m build
86+
```
87+
88+
This will create a `dist` directory containing the packaged distributables: a `.whl` (wheel) file and a `.tar.gz` (source archive).
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2025 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from .sandbox_client import SandboxClient
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
# Copyright 2025 The Kubernetes Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import os
16+
import time
17+
import sys
18+
import subprocess
19+
import socket
20+
import logging
21+
from dataclasses import dataclass
22+
23+
import requests
24+
from kubernetes import client, config, watch
25+
26+
# Constants for SandboxClaim
27+
CLAIM_API_GROUP = "extensions.agents.x-k8s.io"
28+
CLAIM_API_VERSION = "v1alpha1"
29+
CLAIM_PLURAL_NAME = "sandboxclaims"
30+
31+
# Constants for Sandbox
32+
SANDBOX_API_GROUP = "agents.x-k8s.io"
33+
SANDBOX_API_VERSION = "v1alpha1"
34+
SANDBOX_PLURAL_NAME = "sandboxes"
35+
36+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', stream=sys.stdout)
37+
38+
@dataclass
39+
class ExecutionResult:
40+
"""A structured object for holding the result of a command execution."""
41+
stdout: str
42+
stderr: str
43+
exit_code: int
44+
45+
class SandboxClient:
46+
"""
47+
The main client for creating and interacting with a stateful Sandbox (now named SandboxClient).
48+
This class is a context manager, designed to be used with a `with` statement.
49+
"""
50+
def __init__(
51+
self,
52+
template_name: str,
53+
namespace: str = "default",
54+
server_port: int = 8888,
55+
sandbox_ready_timeout: int = 180,
56+
port_forward_ready_timeout: int = 30
57+
):
58+
self.template_name = template_name
59+
self.namespace = namespace
60+
self.server_port = server_port
61+
self.sandbox_ready_timeout = sandbox_ready_timeout
62+
self.port_forward_ready_timeout = port_forward_ready_timeout
63+
self.claim_name: str | None = None
64+
self.sandbox_name: str | None = None
65+
self.base_url = f"http://127.0.0.1:{self.server_port}"
66+
self.port_forward_process: subprocess.Popen | None = None
67+
68+
try:
69+
config.load_incluster_config()
70+
except config.ConfigException:
71+
config.load_kube_config()
72+
73+
self.custom_objects_api = client.CustomObjectsApi()
74+
75+
def is_ready(self) -> bool:
76+
"""Returns True if the sandbox is created and ready for communication."""
77+
return self.port_forward_process is not None
78+
79+
def _create_claim(self):
80+
"""Creates the SandboxClaim custom resource in the Kubernetes cluster."""
81+
self.claim_name = f"sandbox-claim-{os.urandom(4).hex()}"
82+
manifest = {
83+
"apiVersion": f"{CLAIM_API_GROUP}/{CLAIM_API_VERSION}",
84+
"kind": "SandboxClaim",
85+
"metadata": {"name": self.claim_name},
86+
"spec": {"sandboxTemplateRef": {"name": self.template_name}}
87+
}
88+
89+
logging.info(f"Creating SandboxClaim: {self.claim_name}...")
90+
self.custom_objects_api.create_namespaced_custom_object(
91+
group=CLAIM_API_GROUP,
92+
version=CLAIM_API_VERSION,
93+
namespace=self.namespace,
94+
plural=CLAIM_PLURAL_NAME,
95+
body=manifest
96+
)
97+
98+
def _wait_for_sandbox_ready(self):
99+
"""
100+
Waits for the Sandbox custom resource to have a 'Ready' status condition.
101+
This indicates that the underlying pod is running and has passed its checks.
102+
"""
103+
if not self.claim_name:
104+
raise RuntimeError("Cannot wait for sandbox, claim has not been created.")
105+
w = watch.Watch()
106+
logging.info("Watching for Sandbox to become ready...")
107+
for event in w.stream(
108+
func=self.custom_objects_api.list_namespaced_custom_object,
109+
namespace=self.namespace,
110+
group=SANDBOX_API_GROUP,
111+
version=SANDBOX_API_VERSION,
112+
plural=SANDBOX_PLURAL_NAME,
113+
field_selector=f"metadata.name={self.claim_name}",
114+
timeout_seconds=self.sandbox_ready_timeout
115+
):
116+
sandbox_object = event['object']
117+
status = sandbox_object.get('status', {})
118+
conditions = status.get('conditions', [])
119+
is_ready = False
120+
for cond in conditions:
121+
if cond.get('type') == 'Ready' and cond.get('status') == 'True':
122+
is_ready = True
123+
break
124+
125+
if is_ready:
126+
self.sandbox_name = sandbox_object['metadata']['name']
127+
w.stop()
128+
logging.info(f"Sandbox {self.sandbox_name} is ready.")
129+
break
130+
131+
if not self.sandbox_name:
132+
self.__exit__(None, None, None)
133+
raise TimeoutError(f"Sandbox did not become ready within {self.sandbox_ready_timeout} seconds.")
134+
135+
def _start_and_wait_for_port_forward(self):
136+
"""
137+
Starts the 'kubectl port-forward' subprocess and waits for the local port
138+
to be open and listening, ensuring the tunnel is ready for traffic.
139+
"""
140+
if not self.sandbox_name:
141+
raise RuntimeError("Cannot start port-forwarding, sandbox name is not known.")
142+
logging.info(f"Starting port-forwarding for sandbox {self.sandbox_name}...")
143+
self.port_forward_process = subprocess.Popen(
144+
[
145+
"kubectl", "port-forward",
146+
f"pod/{self.sandbox_name}",
147+
f"{self.server_port}:{self.server_port}",
148+
"-n", self.namespace
149+
],
150+
stdout=subprocess.PIPE,
151+
stderr=subprocess.PIPE
152+
)
153+
154+
logging.info("Waiting for port-forwarding to be ready...")
155+
start_time = time.monotonic()
156+
while time.monotonic() - start_time < self.port_forward_ready_timeout:
157+
# Check if the process has exited prematurely
158+
if self.port_forward_process.poll() is not None:
159+
stdout, stderr = self.port_forward_process.communicate()
160+
raise RuntimeError(
161+
"Port-forward process exited unexpectedly.\n"
162+
f"Stdout: {stdout.decode(errors='ignore')}\n"
163+
f"Stderr: {stderr.decode(errors='ignore')}"
164+
)
165+
166+
try:
167+
with socket.create_connection(("127.0.0.1", self.server_port), timeout=0.1):
168+
logging.info(f"Port-forwarding is ready on port {self.server_port}.")
169+
return
170+
except (socket.timeout, ConnectionRefusedError):
171+
time.sleep(0.2) # Wait before retrying
172+
173+
# If the loop finishes, it timed out
174+
self.__exit__(None, None, None)
175+
raise TimeoutError(f"Port-forwarding did not become ready within {self.port_forward_ready_timeout} seconds.")
176+
177+
def __enter__(self) -> 'SandboxClient':
178+
"""Creates the SandboxClaim resource and waits for the Sandbox to become ready."""
179+
self._create_claim()
180+
self._wait_for_sandbox_ready()
181+
self._start_and_wait_for_port_forward()
182+
return self
183+
184+
def __exit__(self, exc_type, exc_val, exc_tb):
185+
"""Deletes the SandboxClaim resource and stops port-forwarding."""
186+
if self.port_forward_process:
187+
logging.info("Stopping port-forwarding...")
188+
self.port_forward_process.terminate()
189+
self.port_forward_process.wait()
190+
191+
if self.claim_name:
192+
logging.info(f"Deleting SandboxClaim: {self.claim_name}")
193+
try:
194+
self.custom_objects_api.delete_namespaced_custom_object(
195+
group=CLAIM_API_GROUP,
196+
version=CLAIM_API_VERSION,
197+
namespace=self.namespace,
198+
plural=CLAIM_PLURAL_NAME,
199+
name=self.claim_name
200+
)
201+
except client.ApiException as e:
202+
if e.status != 404:
203+
logging.error(f"Error deleting sandbox claim: {e}", exc_info=True)
204+
205+
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
206+
"""
207+
A helper method to make requests to the sandbox's server.
208+
Raises an exception if the sandbox is not ready or if the request fails.
209+
"""
210+
if not self.is_ready():
211+
raise RuntimeError("Sandbox is not ready. Cannot send requests.")
212+
213+
url = f"http://127.0.0.1:{self.server_port}/{endpoint}"
214+
try:
215+
response = requests.request(method, url, **kwargs)
216+
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
217+
return response
218+
except requests.exceptions.RequestException as e:
219+
logging.error(f"Request to sandbox failed: {e}")
220+
raise RuntimeError(f"Failed to communicate with the sandbox at {url}.") from e
221+
222+
def run(self, command: str, timeout: int = 60) -> ExecutionResult:
223+
"""
224+
Executes a shell command inside the running sandbox.
225+
"""
226+
payload = {"command": command}
227+
response = self._request("POST", "execute", json=payload, timeout=timeout)
228+
229+
response_data = response.json()
230+
return ExecutionResult(
231+
stdout=response_data['stdout'],
232+
stderr=response_data['stderr'],
233+
exit_code=response_data['exit_code']
234+
)
235+
236+
def write(self, path: str, content: bytes | str):
237+
"""
238+
Uploads content to a file inside the sandbox.
239+
The basename of the provided path is used as the filename in the sandbox.
240+
"""
241+
if isinstance(content, str):
242+
content = content.encode('utf-8')
243+
244+
filename = os.path.basename(path)
245+
files_payload = {'file': (filename, content)}
246+
247+
self._request("POST", "upload", files=files_payload)
248+
logging.info(f"File '{filename}' uploaded successfully.")
249+
250+
def read(self, path: str) -> bytes:
251+
"""
252+
Downloads a file from the sandbox.
253+
The base path for the download is the root of the sandbox's filesystem.
254+
"""
255+
response = self._request("GET", f"download/{path}")
256+
return response.content

0 commit comments

Comments
 (0)