3333SANDBOX_API_VERSION = "v1alpha1"
3434SANDBOX_PLURAL_NAME = "sandboxes"
3535
36- logging .basicConfig (level = logging .INFO , format = '%(asctime)s - %(levelname)s - %(message)s' , stream = sys .stdout )
36+ logging .basicConfig (level = logging .INFO ,
37+ format = '%(asctime)s - %(levelname)s - %(message)s' , stream = sys .stdout )
38+
3739
3840@dataclass
3941class ExecutionResult :
@@ -42,15 +44,17 @@ class ExecutionResult:
4244 stderr : str
4345 exit_code : int
4446
47+
4548class SandboxClient :
4649 """
4750 The main client for creating and interacting with a stateful Sandbox (now named SandboxClient).
4851 This class is a context manager, designed to be used with a `with` statement.
4952 """
53+
5054 def __init__ (
51- self ,
52- template_name : str ,
53- namespace : str = "default" ,
55+ self ,
56+ template_name : str ,
57+ namespace : str = "default" ,
5458 server_port : int = 8888 ,
5559 sandbox_ready_timeout : int = 180 ,
5660 port_forward_ready_timeout : int = 30
@@ -62,6 +66,7 @@ def __init__(
6266 self .port_forward_ready_timeout = port_forward_ready_timeout
6367 self .claim_name : str | None = None
6468 self .sandbox_name : str | None = None
69+ self .pod_name : str | None = None
6570 self .base_url = f"http://127.0.0.1:{ self .server_port } "
6671 self .port_forward_process : subprocess .Popen | None = None
6772
@@ -97,11 +102,39 @@ def _create_claim(self):
97102
98103 def _wait_for_sandbox_ready (self ):
99104 """
100- Waits for the Sandbox custom resource to have a 'Ready' status condition.
105+ Waits for the SandboxClaim to be populated with the Sandbox name, and then
106+ waits for the Sandbox custom resource to have a 'Ready' status condition.
101107 This indicates that the underlying pod is running and has passed its checks.
102108 """
103109 if not self .claim_name :
104- raise RuntimeError ("Cannot wait for sandbox, claim has not been created." )
110+ raise RuntimeError (
111+ "Cannot wait for sandbox, claim has not been created." )
112+
113+ # Watch for the SandboxClaim to be updated with the sandbox name
114+ w_claim = watch .Watch ()
115+ for event in w_claim .stream (
116+ self .custom_objects_api .list_namespaced_custom_object ,
117+ namespace = self .namespace ,
118+ group = CLAIM_API_GROUP ,
119+ version = CLAIM_API_VERSION ,
120+ plural = CLAIM_PLURAL_NAME ,
121+ field_selector = f"metadata.name={ self .claim_name } " ,
122+ timeout_seconds = self .sandbox_ready_timeout
123+ ):
124+ if event ["type" ] in ["ADDED" , "MODIFIED" ]:
125+ claim_obj = event ["object" ]
126+ status = claim_obj .get ("status" , {})
127+ sandbox_status = status .get ("sandbox" , {})
128+ if sandbox_status and sandbox_status .get ("Name" ):
129+ self .sandbox_name = sandbox_status .get ("Name" )
130+ w_claim .stop ()
131+ break
132+ else :
133+ self .__exit__ (None , None , None ) # Attempt cleanup
134+ raise TimeoutError (
135+ f"SandboxClaim did not become ready within { self .sandbox_ready_timeout } seconds." )
136+
137+ # Watch for the Sandbox to become ready
105138 w = watch .Watch ()
106139 logging .info ("Watching for Sandbox to become ready..." )
107140 for event in w .stream (
@@ -110,40 +143,52 @@ def _wait_for_sandbox_ready(self):
110143 group = SANDBOX_API_GROUP ,
111144 version = SANDBOX_API_VERSION ,
112145 plural = SANDBOX_PLURAL_NAME ,
113- field_selector = f"metadata.name={ self .claim_name } " ,
146+ field_selector = f"metadata.name={ self .sandbox_name } " ,
114147 timeout_seconds = self .sandbox_ready_timeout
115148 ):
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
149+ if event ["type" ] in ["ADDED" , "MODIFIED" ]:
150+ sandbox_object = event ['object' ]
151+ status = sandbox_object .get ('status' , {})
152+ conditions = status .get ('conditions' , [])
153+ is_ready = False
154+ for cond in conditions :
155+ if cond .get ('type' ) == 'Ready' and cond .get ('status' ) == 'True' :
156+ is_ready = True
157+ break
124158
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
159+ if is_ready :
160+ annotations = sandbox_object .get (
161+ 'metadata' , {}).get ('annotations' , {})
162+ pod_name_annotation = "agents.x-k8s.io/pod-name"
163+ if pod_name_annotation in annotations :
164+ self .pod_name = annotations [pod_name_annotation ]
165+ logging .info (
166+ f"Found pod name from annotation: { self .pod_name } " )
167+ else :
168+ self .pod_name = self .sandbox_name
169+ w .stop ()
170+ logging .info (f"Sandbox { self .sandbox_name } is ready." )
171+ break
130172
131- if not self .sandbox_name :
173+ if not self .pod_name :
132174 self .__exit__ (None , None , None )
133- raise TimeoutError (f"Sandbox did not become ready within { self .sandbox_ready_timeout } seconds." )
175+ raise TimeoutError (
176+ f"Sandbox did not become ready or pod name could not be determined within { self .sandbox_ready_timeout } seconds." )
134177
135178 def _start_and_wait_for_port_forward (self ):
136179 """
137180 Starts the 'kubectl port-forward' subprocess and waits for the local port
138181 to be open and listening, ensuring the tunnel is ready for traffic.
139182 """
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 } ..." )
183+ if not self .pod_name :
184+ raise RuntimeError (
185+ "Cannot start port-forwarding, sandbox pod name is not known." )
186+ logging .info (
187+ f"Starting port-forwarding for sandbox { self .sandbox_name } with sandbox pod { self .pod_name } ..." )
143188 self .port_forward_process = subprocess .Popen (
144189 [
145190 "kubectl" , "port-forward" ,
146- f"pod/{ self .sandbox_name } " ,
191+ f"pod/{ self .pod_name } " ,
147192 f"{ self .server_port } :{ self .server_port } " ,
148193 "-n" , self .namespace
149194 ],
@@ -165,14 +210,16 @@ def _start_and_wait_for_port_forward(self):
165210
166211 try :
167212 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 } ." )
213+ logging .info (
214+ f"Port-forwarding is ready on port { self .server_port } ." )
169215 return
170216 except (socket .timeout , ConnectionRefusedError ):
171217 time .sleep (0.2 ) # Wait before retrying
172218
173219 # If the loop finishes, it timed out
174220 self .__exit__ (None , None , None )
175- raise TimeoutError (f"Port-forwarding did not become ready within { self .port_forward_ready_timeout } seconds." )
221+ raise TimeoutError (
222+ f"Port-forwarding did not become ready within { self .port_forward_ready_timeout } seconds." )
176223
177224 def __enter__ (self ) -> 'SandboxClient' :
178225 """Creates the SandboxClaim resource and waits for the Sandbox to become ready."""
@@ -200,7 +247,8 @@ def __exit__(self, exc_type, exc_val, exc_tb):
200247 )
201248 except client .ApiException as e :
202249 if e .status != 404 :
203- logging .error (f"Error deleting sandbox claim: { e } " , exc_info = True )
250+ logging .error (
251+ f"Error deleting sandbox claim: { e } " , exc_info = True )
204252
205253 def _request (self , method : str , endpoint : str , ** kwargs ) -> requests .Response :
206254 """
@@ -210,22 +258,24 @@ def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
210258 if not self .is_ready ():
211259 raise RuntimeError ("Sandbox is not ready. Cannot send requests." )
212260
213- url = f"http://127.0.0.1: { self .server_port } /{ endpoint } "
261+ url = f"{ self .base_url } /{ endpoint } "
214262 try :
215263 response = requests .request (method , url , ** kwargs )
216264 response .raise_for_status () # Raise an exception for bad status codes (4xx or 5xx)
217265 return response
218266 except requests .exceptions .RequestException as e :
219267 logging .error (f"Request to sandbox failed: { e } " )
220- raise RuntimeError (f"Failed to communicate with the sandbox at { url } ." ) from e
268+ raise RuntimeError (
269+ f"Failed to communicate with the sandbox at { url } ." ) from e
221270
222271 def run (self , command : str , timeout : int = 60 ) -> ExecutionResult :
223272 """
224273 Executes a shell command inside the running sandbox.
225274 """
226275 payload = {"command" : command }
227- response = self ._request ("POST" , "execute" , json = payload , timeout = timeout )
228-
276+ response = self ._request (
277+ "POST" , "execute" , json = payload , timeout = timeout )
278+
229279 response_data = response .json ()
230280 return ExecutionResult (
231281 stdout = response_data ['stdout' ],
@@ -253,4 +303,4 @@ def read(self, path: str) -> bytes:
253303 The base path for the download is the root of the sandbox's filesystem.
254304 """
255305 response = self ._request ("GET" , f"download/{ path } " )
256- return response .content
306+ return response .content
0 commit comments