|
37 | 37 | ExecutionResult, |
38 | 38 | HealthCheck, |
39 | 39 | SessionInfo, |
| 40 | + SessionNotFoundError, |
40 | 41 | StreamChunk, |
41 | 42 | StreamEvent, |
42 | 43 | StreamResult, |
@@ -376,19 +377,63 @@ def _upload_tar_to_pod(self, pod_name: str, tar_archive: bytes) -> None: |
376 | 377 | f"stderr: {tar_stderr.decode('utf-8', errors='replace')}" |
377 | 378 | ) |
378 | 379 |
|
379 | | - def _kill_python_process(self, pod_name: str) -> None: |
380 | | - """Kill the Python process running in the pod.""" |
| 380 | + def _kill_processes_in_pod(self, pod_name: str, process_name: str) -> None: |
| 381 | + """Best-effort SIGKILL of all processes named ``process_name`` in the pod.""" |
381 | 382 | try: |
382 | 383 | self._stream_pod_exec( |
383 | 384 | pod_name, |
384 | | - command=["pkill", "-9", "python"], |
| 385 | + command=["pkill", "-9", process_name], |
385 | 386 | stderr=False, |
386 | 387 | stdin=False, |
387 | 388 | stdout=False, |
388 | 389 | tty=False, |
389 | 390 | ) |
390 | 391 | except Exception: |
391 | | - logger.warning("Failed to kill Python process in pod %s", pod_name, exc_info=True) |
| 392 | + logger.warning( |
| 393 | + "Failed to kill %s process in pod %s", process_name, pod_name, exc_info=True |
| 394 | + ) |
| 395 | + |
| 396 | + def _kill_python_process(self, pod_name: str) -> None: |
| 397 | + """Kill the Python process running in the pod.""" |
| 398 | + self._kill_processes_in_pod(pod_name, "python") |
| 399 | + |
| 400 | + def _drain_exec_stream( |
| 401 | + self, |
| 402 | + exec_resp: ws_client.WSClient, |
| 403 | + timeout_ms: int, |
| 404 | + ) -> tuple[bytes, bytes, int | None, bool]: |
| 405 | + """Read stdout/stderr from an exec stream until completion or timeout. |
| 406 | +
|
| 407 | + Returns ``(stdout_bytes, stderr_bytes, exit_code, timed_out)``. |
| 408 | + """ |
| 409 | + stdout_data = b"" |
| 410 | + stderr_data = b"" |
| 411 | + exit_code: int | None = None |
| 412 | + timed_out = False |
| 413 | + |
| 414 | + end_time = time.time() + timeout_ms / 1000.0 |
| 415 | + |
| 416 | + while exec_resp.is_open(): |
| 417 | + remaining = end_time - time.time() |
| 418 | + if remaining <= 0: |
| 419 | + timed_out = True |
| 420 | + break |
| 421 | + |
| 422 | + exec_resp.update(timeout=min(remaining, 1)) |
| 423 | + |
| 424 | + if exec_resp.peek_stdout(): |
| 425 | + stdout_data += exec_resp.read_stdout().encode("utf-8") |
| 426 | + |
| 427 | + if exec_resp.peek_stderr(): |
| 428 | + stderr_data += exec_resp.read_stderr().encode("utf-8") |
| 429 | + |
| 430 | + error = exec_resp.read_channel(ws_client.ERROR_CHANNEL) |
| 431 | + if error: |
| 432 | + exit_code = _parse_exit_code(error) |
| 433 | + break |
| 434 | + |
| 435 | + exec_resp.close() |
| 436 | + return stdout_data, stderr_data, exit_code, timed_out |
392 | 437 |
|
393 | 438 | @contextmanager |
394 | 439 | def _run_in_pod( |
@@ -702,6 +747,56 @@ def reap_expired_sessions(self) -> int: |
702 | 747 | logger.info("Reaped expired session pod %s", metadata.name) |
703 | 748 | return reaped |
704 | 749 |
|
| 750 | + def execute_bash_in_session( |
| 751 | + self, |
| 752 | + session_id: str, |
| 753 | + *, |
| 754 | + cmd: str, |
| 755 | + timeout_ms: int, |
| 756 | + max_output_bytes: int, |
| 757 | + ) -> ExecutionResult: |
| 758 | + """Run a bash command inside an existing session pod. |
| 759 | +
|
| 760 | + Network restrictions established at pod creation (the iptables init |
| 761 | + container) remain in force — exec inherits the pod's network namespace. |
| 762 | + """ |
| 763 | + if not session_id.startswith(SESSION_NAME_PREFIX): |
| 764 | + raise SessionNotFoundError(session_id) |
| 765 | + |
| 766 | + try: |
| 767 | + self.v1.read_namespaced_pod(session_id, self.namespace) |
| 768 | + except ApiException as e: |
| 769 | + if e.status == 404: |
| 770 | + raise SessionNotFoundError(session_id) from e |
| 771 | + raise |
| 772 | + |
| 773 | + start = time.perf_counter() |
| 774 | + exec_resp = self._stream_pod_exec( |
| 775 | + session_id, |
| 776 | + command=["bash", "-c", cmd], |
| 777 | + stderr=True, |
| 778 | + stdin=False, |
| 779 | + stdout=True, |
| 780 | + tty=False, |
| 781 | + ) |
| 782 | + |
| 783 | + stdout_data, stderr_data, exit_code, timed_out = self._drain_exec_stream( |
| 784 | + exec_resp, timeout_ms |
| 785 | + ) |
| 786 | + |
| 787 | + if timed_out: |
| 788 | + self._kill_processes_in_pod(session_id, "bash") |
| 789 | + |
| 790 | + duration_ms = int((time.perf_counter() - start) * 1000) |
| 791 | + return ExecutionResult( |
| 792 | + stdout=self.truncate_output(stdout_data, max_output_bytes), |
| 793 | + stderr=self.truncate_output(stderr_data, max_output_bytes), |
| 794 | + exit_code=None if timed_out else exit_code, |
| 795 | + timed_out=timed_out, |
| 796 | + duration_ms=duration_ms, |
| 797 | + files=tuple(), |
| 798 | + ) |
| 799 | + |
705 | 800 | def execute_python( |
706 | 801 | self, |
707 | 802 | *, |
@@ -731,34 +826,9 @@ def execute_python( |
731 | 826 | logger.debug("Writing stdin to Python process") |
732 | 827 | ctx.exec_resp.write_stdin(stdin) |
733 | 828 |
|
734 | | - stdout_data = b"" |
735 | | - stderr_data = b"" |
736 | | - exit_code: int | None = None |
737 | | - timed_out = False |
738 | | - |
739 | | - timeout_sec = timeout_ms / 1000.0 |
740 | | - end_time = time.time() + timeout_sec |
741 | | - |
742 | | - while ctx.exec_resp.is_open(): |
743 | | - remaining = end_time - time.time() |
744 | | - if remaining <= 0: |
745 | | - timed_out = True |
746 | | - break |
747 | | - |
748 | | - ctx.exec_resp.update(timeout=min(remaining, 1)) |
749 | | - |
750 | | - if ctx.exec_resp.peek_stdout(): |
751 | | - stdout_data += ctx.exec_resp.read_stdout().encode("utf-8") |
752 | | - |
753 | | - if ctx.exec_resp.peek_stderr(): |
754 | | - stderr_data += ctx.exec_resp.read_stderr().encode("utf-8") |
755 | | - |
756 | | - error = ctx.exec_resp.read_channel(ws_client.ERROR_CHANNEL) |
757 | | - if error: |
758 | | - exit_code = _parse_exit_code(error) |
759 | | - break |
760 | | - |
761 | | - ctx.exec_resp.close() |
| 829 | + stdout_data, stderr_data, exit_code, timed_out = self._drain_exec_stream( |
| 830 | + ctx.exec_resp, timeout_ms |
| 831 | + ) |
762 | 832 |
|
763 | 833 | if timed_out: |
764 | 834 | self._kill_python_process(ctx.pod_name) |
|
0 commit comments