diff --git a/README.md b/README.md index a89004c..6e7f564 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,19 @@ from aio_lib_sandbox import Sandbox sandbox = await Sandbox.create( name="my-sandbox", type="cpu:default", + idle_timeout=900, max_lifetime=3600, ports=[3000, 8080], envs={"API_KEY": "your-api-key"}, ) ``` +#### Sandbox lifetime model + +A sandbox is always deleted when `max_lifetime` has elapsed. It will also be deleted after the `idle_timeout` has elapsed, if there has been no activity. + +To keep a sandbox alive, send at least one command or check the status every `idle_timeout` seconds. + ### Get Status ```python diff --git a/src/aio_lib_sandbox/sandbox.py b/src/aio_lib_sandbox/sandbox.py index 5b6880c..3f392a8 100644 --- a/src/aio_lib_sandbox/sandbox.py +++ b/src/aio_lib_sandbox/sandbox.py @@ -53,6 +53,7 @@ def __init__( status: str, cluster: str | None = None, region: str | None = None, + idle_timeout: int = 900, max_lifetime: int = 3600, namespace: str, api_host: str, @@ -67,6 +68,7 @@ def __init__( self.status = status self.cluster = cluster self.region = region + self.idle_timeout = idle_timeout self.max_lifetime = max_lifetime self.namespace = namespace @@ -93,6 +95,7 @@ async def create( auth: str | None = None, type: str = "cpu:default", size: str | dict[str, Any] | None = None, + idle_timeout: int = 900, max_lifetime: int = 3600, ports: list[int] | None = None, envs: dict[str, str] | None = None, @@ -119,7 +122,10 @@ async def create( auth: Runtime API key (overrides ``__OW_API_KEY``). type: Sandbox type (default: ``'cpu:default'``). size: Size tier name or spec dict. - max_lifetime: Maximum lifetime in seconds. + idle_timeout: Seconds of inactivity before the sandbox is terminated + (default: 900, max: 10800). The idle timer resets on every + WebSocket message or status-check request. + max_lifetime: Maximum lifetime in seconds (default: 3600, max: 10800). ports: TCP ports to expose via preview URLs (default: ``[]``). envs: Environment variables to inject into the sandbox. policy: Network policy (e.g. egress allowlist). @@ -137,6 +143,7 @@ async def create( "name": name, "size": normalize_size(size), "type": type, + "idleTimeout": idle_timeout, "maxLifetime": max_lifetime, } if cluster is not None: @@ -170,6 +177,7 @@ async def create( status=payload.get("status", ""), cluster=payload.get("cluster"), region=payload.get("region"), + idle_timeout=payload.get("idleTimeout", 900), max_lifetime=payload.get("maxLifetime", 3600), preview_urls=_parse_preview_urls(payload.get("previewUrls")), management_endpoint=payload.get("managementEndpoint"), @@ -191,17 +199,23 @@ async def get( api_host: str | None = None, namespace: str | None = None, auth: str | None = None, + management_endpoint: str | None = None, verify_ssl: bool = True, ) -> "Sandbox": """Fetch the current status of an existing sandbox. Credentials are read from the environment automatically. + Pass the management endpoint so the request is sent to the correct host; + falls back to ``api_host`` when omitted. + Args: sandbox_id: ID of the sandbox to look up. api_host: Runtime API host override. namespace: Runtime namespace override. auth: Runtime API key override. + management_endpoint: Per-sandbox management endpoint returned by + :meth:`create`. Falls back to ``api_host`` otherwise. verify_ssl: Whether to verify TLS certificates. Returns: @@ -209,7 +223,8 @@ async def get( This instance is **not** WebSocket-connected. """ creds = cls.resolve_credentials(api_host=api_host, namespace=namespace, auth=auth) - url = f"{creds['api_host']}/api/v1/namespaces/{creds['namespace']}/sandboxes/{sandbox_id}" + base = management_endpoint or creds["api_host"] + url = f"{base}/api/v1/namespaces/{creds['namespace']}/sandboxes/{sandbox_id}" payload = await api_request( "GET", url, @@ -224,7 +239,9 @@ async def get( status=payload.get("status", ""), cluster=payload.get("cluster"), region=payload.get("region"), + idle_timeout=payload.get("idleTimeout", 900), max_lifetime=payload.get("maxLifetime", 3600), + management_endpoint=payload.get("managementEndpoint") or management_endpoint, preview_urls=_parse_preview_urls(payload.get("previewUrls")), namespace=creds["namespace"], api_host=creds["api_host"], diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 8ed39d8..94c2f03 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -326,6 +326,83 @@ async def test_create_forwards_ports_and_parses_preview_urls(self): } assert sandbox.get_url(3000) == "https://sb-ports-3000.preview.example.net" + @pytest.mark.asyncio + async def test_create_sends_default_idle_timeout_and_max_lifetime(self): + payload = { + "sandboxId": "sb-defaults", + "wsEndpoint": "wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-defaults/exec", + "status": "ready", + "token": "tok-defaults", + "idleTimeout": 900, + "maxLifetime": 3600, + } + + with ( + patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)) as mock_req, + patch.object(Sandbox, "connect", new=AsyncMock()), + ): + await Sandbox.create( + name="defaults-sandbox", + api_host="https://runtime.example.net", + namespace="ns", + auth="uuid:key", + ) + + _, kwargs = mock_req.call_args + assert kwargs["body"]["idleTimeout"] == 900 + assert kwargs["body"]["maxLifetime"] == 3600 + + @pytest.mark.asyncio + async def test_create_forwards_explicit_idle_timeout(self): + payload = { + "sandboxId": "sb-idle", + "wsEndpoint": "wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-idle/exec", + "status": "ready", + "token": "tok-idle", + "idleTimeout": 1800, + "maxLifetime": 3600, + } + + with ( + patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)) as mock_req, + patch.object(Sandbox, "connect", new=AsyncMock()), + ): + await Sandbox.create( + name="idle-sandbox", + api_host="https://runtime.example.net", + namespace="ns", + auth="uuid:key", + idle_timeout=1800, + ) + + _, kwargs = mock_req.call_args + assert kwargs["body"]["idleTimeout"] == 1800 + + @pytest.mark.asyncio + async def test_create_stores_idle_timeout_from_response(self): + payload = { + "sandboxId": "sb-store", + "wsEndpoint": "wss://runtime.example.net/api/v1/namespaces/ns/sandboxes/sb-store/exec", + "status": "ready", + "token": "tok-store", + "idleTimeout": 1800, + "maxLifetime": 3600, + } + + with ( + patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)), + patch.object(Sandbox, "connect", new=AsyncMock()), + ): + sandbox = await Sandbox.create( + name="store-sandbox", + api_host="https://runtime.example.net", + namespace="ns", + auth="uuid:key", + idle_timeout=1800, + ) + + assert sandbox.idle_timeout == 1800 + # --------------------------------------------------------------------------- # WebSocket connection @@ -532,6 +609,43 @@ async def test_get_parses_preview_urls(self): assert sandbox.preview_urls == {3000: "https://sb-get-3000.preview.example.net"} assert sandbox.get_url(3000) == "https://sb-get-3000.preview.example.net" + @pytest.mark.asyncio + async def test_get_stores_idle_timeout_from_response(self): + payload = { + "sandboxId": "sb-get-idle", + "status": "running", + "idleTimeout": 1200, + "maxLifetime": 3600, + } + + with patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)): + sandbox = await Sandbox.get( + "sb-get-idle", + api_host="https://runtime.example.net", + namespace="ns", + auth="uuid:key", + ) + + assert sandbox.idle_timeout == 1200 + + @pytest.mark.asyncio + async def test_get_routes_through_management_endpoint(self): + payload = {"sandboxId": "sb-mgmt", "status": "running"} + + with patch("aio_lib_sandbox.sandbox.api_request", new=AsyncMock(return_value=payload)) as mock_req: + sandbox = await Sandbox.get( + "sb-mgmt", + api_host="https://runtime.example.net", + namespace="ns", + auth="uuid:key", + management_endpoint="https://sb-mgmt.mgmt.example.net", + ) + + args, _ = mock_req.call_args + # api_request is called positionally: (method, url, ...) + assert args[1] == "https://sb-mgmt.mgmt.example.net/api/v1/namespaces/ns/sandboxes/sb-mgmt" + assert sandbox.management_endpoint == "https://sb-mgmt.mgmt.example.net" + @pytest.mark.asyncio async def test_get_not_found_raises(self): with patch(