diff --git a/examples/sandbox_proxy_demo.py b/examples/sandbox_proxy_demo.py new file mode 100644 index 000000000..84cc05a1c --- /dev/null +++ b/examples/sandbox_proxy_demo.py @@ -0,0 +1,80 @@ +import asyncio +from contextlib import asynccontextmanager + +from rock.actions import CreateBashSessionRequest +from rock.sdk.sandbox.client import Sandbox +from rock.sdk.sandbox.config import SandboxConfig + +# ============================================================================= +# Local Read/Write Separation Architecture Demo +# ============================================================================= +# +# This script demonstrates how to use the proxy + admin read/write separation +# architecture locally: +# +# - Admin server (port 8080): Handles write operations (start/stop sandbox) +# - Proxy server (port 8081): Handles read/runtime operations (create session, +# execute commands, etc.) +# +# Prerequisites: +# 1. Start Redis: +# docker run -d --name redis-local -p 6379:6379 redis:latest +# 2. Start admin server: +# rock admin start --env local-proxy --role admin --port 8080 +# 3. Start proxy server: +# rock admin start --env local-proxy --role proxy --port 8081 +# +# NOTE: For production environments, it is recommended to add a gateway layer +# (e.g. Nginx, Kong, or a custom API gateway) to route requests +# transparently, so that clients don't need to manage URL switching +# between admin and proxy manually. +# ============================================================================= + + +@asynccontextmanager +async def managed_sandbox( + image: str = "python:3.11", + memory: str = "2g", + cpus: float = 0.5, +): + """Context manager that handles sandbox lifecycle with read/write separation. + + Lifecycle: + 1. start() -> routed to admin server (write operation) + 2. yield -> url switched to proxy (read/runtime operations) + 3. stop() -> url restored to admin (write operation) + + Args: + image: Docker image to use for the sandbox. + memory: Memory limit for the sandbox container. + cpus: CPU limit for the sandbox container. + """ + base_url = "http://127.0.0.1:8080" + proxy_url = "http://127.0.0.1:8081/apis/envs/sandbox/v1" + admin_url = f"{base_url}/apis/envs/sandbox/v1" + + config = SandboxConfig(base_url=base_url, image=image, memory=memory, cpus=cpus) + sandbox = Sandbox(config) + + try: + # Write operation: create sandbox via admin server + await sandbox.start() + # Switch to proxy server for runtime operations + sandbox.url = proxy_url + yield sandbox + finally: + # Switch back to admin server for cleanup + sandbox.url = admin_url + await sandbox.stop() + + +async def run_sandbox(): + async with managed_sandbox() as sandbox: + await sandbox.create_session(CreateBashSessionRequest(session="bash-1")) + + result = await sandbox.arun(cmd="echo Hello ROCK", session="bash-1") + print(f"\n{'*' * 50}\n{result.output}\n{'*' * 50}\n") + + +if __name__ == "__main__": + asyncio.run(run_sandbox()) diff --git a/rock-conf/rock-local-proxy.yml b/rock-conf/rock-local-proxy.yml new file mode 100644 index 000000000..56ee8e6d8 --- /dev/null +++ b/rock-conf/rock-local-proxy.yml @@ -0,0 +1,15 @@ +ray: + runtime_env: + working_dir: ./ + pip: ./requirements_sandbox_actor.txt + namespace: "rock-sandbox-local" + +warmup: + images: + - "python:3.11" + +# to start admin proxy in local, redis is required +# quickest way to start redis local: "docker run -d --name redis-dev -p 6379:6379 redis:latest" +redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/rock/cli/command/admin.py b/rock/cli/command/admin.py index c14df7e05..cad41d320 100644 --- a/rock/cli/command/admin.py +++ b/rock/cli/command/admin.py @@ -29,9 +29,11 @@ async def arun(self, args: argparse.Namespace): async def _admin_start(self, args: argparse.Namespace): """Start admin service""" - env = getattr(args, "env", None) + env = getattr(args, "env", "local") + role = getattr(args, "role", "admin") + port = getattr(args, "port", 8080) - subprocess.Popen(["admin", "--env", env]) + subprocess.Popen(["admin", "--env", env, "--role", role, "--port", str(port)]) async def _admin_stop(self, args: argparse.Namespace): """Stop admin service""" @@ -112,6 +114,10 @@ async def add_parser_to(subparsers: argparse._SubParsersAction): # admin start admin_start_parser = admin_subparsers.add_parser("start", help="Start admin service") admin_start_parser.add_argument("--env", default="local", help="admin service env") + admin_start_parser.add_argument( + "--role", default="admin", choices=["admin", "proxy"], help="admin service role (admin or proxy)" + ) + admin_start_parser.add_argument("--port", type=int, default=8080, help="admin service port") # admin stop admin_subparsers.add_parser("stop", help="Stop admin service") diff --git a/rock/sdk/sandbox/client.py b/rock/sdk/sandbox/client.py index 3278b46e9..f19c9b0fc 100644 --- a/rock/sdk/sandbox/client.py +++ b/rock/sdk/sandbox/client.py @@ -120,6 +120,14 @@ def host_ip(self) -> str: def cluster(self) -> str: return self._cluster + @property + def url(self) -> str: + return self._url + + @url.setter + def url(self, value: str): + self._url = value + def _build_headers(self) -> dict[str, str]: """Build basic request headers.""" headers = {