diff --git a/README.md b/README.md index c9b0738c..d099f933 100644 --- a/README.md +++ b/README.md @@ -115,15 +115,21 @@ client = Hyperbrowser(api_key="test-key") sandbox = client.sandboxes.create( CreateSandboxParams( image_name="node", + cpu=4, + memory_mib=4096, + disk_mib=8192, exposed_ports=[SandboxExposeParams(port=3000, auth=True)], ) ) print(sandbox.exposed_ports[0].browser_url) +print(sandbox.cpu, sandbox.memory_mib, sandbox.disk_mib) sandbox.stop() client.close() ``` +`cpu`, `memory_mib`, and `disk_mib` are only supported for image launches. + ### List sandboxes with filters ```python @@ -164,7 +170,11 @@ from hyperbrowser import Hyperbrowser from hyperbrowser.models import CreateSandboxParams, SandboxExposeParams client = Hyperbrowser(api_key="test-key") -sandbox = client.sandboxes.create(CreateSandboxParams(image_name="node")) +sandbox = client.sandboxes.create( + CreateSandboxParams( + image_name="node", cpu=2, memory_mib=2048, disk_mib=8192 + ) +) result = sandbox.expose(SandboxExposeParams(port=8080, auth=True)) print(result.url, result.browser_url) diff --git a/hyperbrowser/client/managers/async_manager/sandbox.py b/hyperbrowser/client/managers/async_manager/sandbox.py index 61111378..1e74edce 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -114,6 +114,18 @@ def token_expires_at(self): def session_url(self) -> str: return self._detail.session_url + @property + def cpu(self): + return self._detail.cpu + + @property + def memory_mib(self): + return self._detail.memory_mib + + @property + def disk_mib(self): + return self._detail.disk_mib + @property def exposed_ports(self): return self._detail.exposed_ports diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index 7e05cba5..1d4cb916 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -114,6 +114,18 @@ def token_expires_at(self): def session_url(self) -> str: return self._detail.session_url + @property + def cpu(self): + return self._detail.cpu + + @property + def memory_mib(self): + return self._detail.memory_mib + + @property + def disk_mib(self): + return self._detail.disk_mib + @property def exposed_ports(self): return self._detail.exposed_ports diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index ccda2ce5..bd82a925 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -116,6 +116,9 @@ class Sandbox(SandboxBaseModel): session_url: str = Field(alias="sessionUrl") duration: int proxy_bytes_used: Optional[int] = Field(default=None, alias="proxyBytesUsed") + cpu: Optional[int] = Field(default=None, alias="vcpus") + memory_mib: Optional[int] = Field(default=None, alias="memMiB") + disk_mib: Optional[int] = Field(default=None, alias="diskSizeMiB") runtime: SandboxRuntimeTarget exposed_ports: List[SandboxExposeResult] = Field( default_factory=list, @@ -128,6 +131,9 @@ class Sandbox(SandboxBaseModel): "data_consumed", "proxy_data_consumed", "proxy_bytes_used", + "cpu", + "memory_mib", + "disk_mib", mode="before", ) @classmethod @@ -177,6 +183,11 @@ class CreateSandboxParams(SandboxBaseModel): timeout_minutes: Optional[int] = Field( default=None, serialization_alias="timeoutMinutes" ) + cpu: Optional[int] = Field(default=None, ge=1, serialization_alias="vcpus") + memory_mib: Optional[int] = Field(default=None, ge=1, serialization_alias="memMiB") + disk_mib: Optional[int] = Field( + default=None, ge=1, serialization_alias="diskSizeMiB" + ) @model_validator(mode="after") def validate_launch_source(self): @@ -191,6 +202,12 @@ def validate_launch_source(self): raise ValueError( "Provide exactly one start source: snapshot_name or image_name" ) + if self.snapshot_name and any( + value is not None for value in (self.cpu, self.memory_mib, self.disk_mib) + ): + raise ValueError( + "cpu, memory_mib, and disk_mib are only supported for image launches" + ) return self diff --git a/pyproject.toml b/pyproject.toml index fda9a079..477721d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hyperbrowser" -version = "0.89.1" +version = "0.89.2" description = "Python SDK for hyperbrowser" authors = ["Nikhil Shahi "] license = "MIT" diff --git a/tests/sandbox/e2e/test_resource_config.py b/tests/sandbox/e2e/test_resource_config.py new file mode 100644 index 00000000..1e426b51 --- /dev/null +++ b/tests/sandbox/e2e/test_resource_config.py @@ -0,0 +1,122 @@ +import pytest + +from hyperbrowser.models import CreateSandboxParams + +from tests.helpers.config import DEFAULT_IMAGE_NAME, create_async_client, create_client +from tests.helpers.sandbox import ( + stop_sandbox_if_running, + stop_sandbox_if_running_async, + wait_for_runtime_ready, + wait_for_runtime_ready_async, +) + +client = create_client() + +REQUESTED_CPU = 8 +REQUESTED_MEMORY_MIB = 8192 +REQUESTED_DISK_MIB = 10240 +MEMORY_MIN_VISIBLE_MIB = REQUESTED_MEMORY_MIB - 512 +DISK_MIN_VISIBLE_MIB = REQUESTED_DISK_MIB - 512 + + +def _exec_integer(sandbox, command: str) -> int: + result = sandbox.exec(command) + assert result.exit_code == 0 + return int(result.stdout.strip()) + + +async def _exec_integer_async(sandbox, command: str) -> int: + result = await sandbox.exec(command) + assert result.exit_code == 0 + return int(result.stdout.strip()) + + +def test_sandbox_resource_config_e2e(): + sandbox = None + + try: + sandbox = client.sandboxes.create( + CreateSandboxParams( + image_name=DEFAULT_IMAGE_NAME, + cpu=REQUESTED_CPU, + memory_mib=REQUESTED_MEMORY_MIB, + disk_mib=REQUESTED_DISK_MIB, + ) + ) + + assert sandbox.cpu == REQUESTED_CPU + assert sandbox.memory_mib == REQUESTED_MEMORY_MIB + assert sandbox.disk_mib == REQUESTED_DISK_MIB + + detail = sandbox.info() + assert detail.cpu == REQUESTED_CPU + assert detail.memory_mib == REQUESTED_MEMORY_MIB + assert detail.disk_mib == REQUESTED_DISK_MIB + + reloaded = client.sandboxes.get(sandbox.id) + assert reloaded.cpu == REQUESTED_CPU + assert reloaded.memory_mib == REQUESTED_MEMORY_MIB + assert reloaded.disk_mib == REQUESTED_DISK_MIB + + wait_for_runtime_ready(sandbox) + + cpu_count = _exec_integer(sandbox, "nproc") + memory_mib = _exec_integer( + sandbox, + "awk '/MemTotal/ {printf \"%.0f\\n\", $2/1024}' /proc/meminfo", + ) + disk_mib = _exec_integer(sandbox, "df -m / | awk 'NR==2 {print $2}'") + + assert cpu_count == REQUESTED_CPU + assert MEMORY_MIN_VISIBLE_MIB <= memory_mib <= REQUESTED_MEMORY_MIB + assert DISK_MIN_VISIBLE_MIB <= disk_mib <= REQUESTED_DISK_MIB + finally: + stop_sandbox_if_running(sandbox) + + +@pytest.mark.anyio +async def test_async_sandbox_resource_config_e2e(): + client = create_async_client() + sandbox = None + + try: + sandbox = await client.sandboxes.create( + CreateSandboxParams( + image_name=DEFAULT_IMAGE_NAME, + cpu=REQUESTED_CPU, + memory_mib=REQUESTED_MEMORY_MIB, + disk_mib=REQUESTED_DISK_MIB, + ) + ) + + assert sandbox.cpu == REQUESTED_CPU + assert sandbox.memory_mib == REQUESTED_MEMORY_MIB + assert sandbox.disk_mib == REQUESTED_DISK_MIB + + detail = await sandbox.info() + assert detail.cpu == REQUESTED_CPU + assert detail.memory_mib == REQUESTED_MEMORY_MIB + assert detail.disk_mib == REQUESTED_DISK_MIB + + reloaded = await client.sandboxes.get(sandbox.id) + assert reloaded.cpu == REQUESTED_CPU + assert reloaded.memory_mib == REQUESTED_MEMORY_MIB + assert reloaded.disk_mib == REQUESTED_DISK_MIB + + await wait_for_runtime_ready_async(sandbox) + + cpu_count = await _exec_integer_async(sandbox, "nproc") + memory_mib = await _exec_integer_async( + sandbox, + "awk '/MemTotal/ {printf \"%.0f\\n\", $2/1024}' /proc/meminfo", + ) + disk_mib = await _exec_integer_async( + sandbox, "df -m / | awk 'NR==2 {print $2}'" + ) + + assert cpu_count == REQUESTED_CPU + assert MEMORY_MIN_VISIBLE_MIB <= memory_mib <= REQUESTED_MEMORY_MIB + assert DISK_MIN_VISIBLE_MIB <= disk_mib <= REQUESTED_DISK_MIB + finally: + await stop_sandbox_if_running_async(sandbox) + await client.close() diff --git a/tests/test_create_sandbox_params.py b/tests/test_create_sandbox_params.py index 7b7cf39d..0416f4bd 100644 --- a/tests/test_create_sandbox_params.py +++ b/tests/test_create_sandbox_params.py @@ -21,11 +21,17 @@ def test_create_sandbox_params_accepts_image_source(): def test_create_sandbox_params_serializes_exposed_ports(): params = CreateSandboxParams( image_name="node", + cpu=4, + memory_mib=4096, + disk_mib=8192, exposed_ports=[SandboxExposeParams(port=3000, auth=True)], ) assert params.model_dump(by_alias=True, exclude_none=True) == { "imageName": "node", + "vcpus": 4, + "memMiB": 4096, + "diskSizeMiB": 8192, "exposedPorts": [{"port": 3000, "auth": True}], } @@ -72,6 +78,14 @@ def test_create_sandbox_params_requires_snapshot_name_for_snapshot_id(): CreateSandboxParams(snapshot_id="snap-id") +def test_create_sandbox_params_rejects_resource_config_for_snapshot_source(): + with pytest.raises( + ValidationError, + match="cpu, memory_mib, and disk_mib are only supported for image launches", + ): + CreateSandboxParams(snapshot_name="snap", cpu=2, memory_mib=2048, disk_mib=8192) + + def test_sandbox_exec_params_serialize_process_timeout_sec_as_snake_case(): params = SandboxExecParams( command="echo hi", diff --git a/tests/test_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index 033f8fd9..781be106 100644 --- a/tests/test_sandbox_wire_contract.py +++ b/tests/test_sandbox_wire_contract.py @@ -63,6 +63,9 @@ "sessionUrl": "https://example.com/session", "duration": 10, "proxyBytesUsed": 3, + "vcpus": 2, + "memMiB": 2048, + "diskSizeMiB": 8192, "runtime": { "transport": "regional_proxy", "host": "runtime.example.com", @@ -112,6 +115,9 @@ "sessionUrl": "https://example.com/session", "duration": 10, "proxyBytesUsed": 3, + "vcpus": 2, + "memMiB": 2048, + "diskSizeMiB": 8192, "runtime": { "transport": "regional_proxy", "host": "runtime.example.com", @@ -486,12 +492,18 @@ def test_sandbox_request_models_serialize_expected_wire_keys(): assert CreateSandboxParams( image_name="node", image_id="img-id", + cpu=4, + memory_mib=4096, + disk_mib=8192, enable_recording=True, exposed_ports=[SandboxExposeParams(port=3000, auth=True)], timeout_minutes=15, ).model_dump(by_alias=True, exclude_none=True) == { "imageName": "node", "imageId": "img-id", + "vcpus": 4, + "memMiB": 4096, + "diskSizeMiB": 8192, "enableRecording": True, "exposedPorts": [{"port": 3000, "auth": True}], "timeoutMinutes": 15, @@ -611,6 +623,9 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys(): CreateSandboxParams( image_name="node", image_id="img-id", + cpu=4, + memory_mib=4096, + disk_mib=8192, enable_recording=True, exposed_ports=[SandboxExposeParams(port=3000, auth=True)], timeout_minutes=15, @@ -658,6 +673,9 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys(): assert create_call["json"] == { "imageName": "node", "imageId": "img-id", + "vcpus": 4, + "memMiB": 4096, + "diskSizeMiB": 8192, "enableRecording": True, "exposedPorts": [{"port": 3000, "auth": True}], "timeoutMinutes": 15, @@ -665,6 +683,9 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys(): assert snapshot_call["json"] == { "snapshotName": "snap", } + assert sandbox.cpu == 2 + assert sandbox.memory_mib == 2048 + assert sandbox.disk_mib == 8192 assert sandbox.exposed_ports[0].browser_url is not None assert expose_call["json"] == {"port": 3000, "auth": True} assert exposed.browser_url is not None @@ -824,6 +845,9 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys(): CreateSandboxParams( image_name="node", image_id="img-id", + cpu=4, + memory_mib=4096, + disk_mib=8192, enable_recording=True, exposed_ports=[SandboxExposeParams(port=3000, auth=True)], timeout_minutes=15, @@ -871,6 +895,9 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys(): assert create_call["json"] == { "imageName": "node", "imageId": "img-id", + "vcpus": 4, + "memMiB": 4096, + "diskSizeMiB": 8192, "enableRecording": True, "exposedPorts": [{"port": 3000, "auth": True}], "timeoutMinutes": 15, @@ -878,6 +905,9 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys(): assert snapshot_call["json"] == { "snapshotName": "snap", } + assert sandbox.cpu == 2 + assert sandbox.memory_mib == 2048 + assert sandbox.disk_mib == 8192 assert sandbox.exposed_ports[0].browser_url is not None assert expose_call["json"] == {"port": 3000, "auth": True} assert exposed.browser_url is not None @@ -1010,7 +1040,9 @@ def test_sync_terminal_attach_includes_cursor(monkeypatch): captured = {} class DummyTarget: - url = "wss://runtime.example.com/sandbox/pty/pty_1/ws?sessionId=sbx_123&cursor=7" + url = ( + "wss://runtime.example.com/sandbox/pty/pty_1/ws?sessionId=sbx_123&cursor=7" + ) host_header = None connect_host = None connect_port = None @@ -1023,7 +1055,9 @@ def fake_connect(url, additional_headers=None, open_timeout=None, **kwargs): captured["url"] = url return object() - monkeypatch.setattr(sync_terminal_module, "to_websocket_transport_target", fake_target) + monkeypatch.setattr( + sync_terminal_module, "to_websocket_transport_target", fake_target + ) monkeypatch.setattr(sync_terminal_module, "sync_ws_connect", fake_connect) handle = sync_terminal_module.SandboxTerminalHandle( @@ -1051,7 +1085,9 @@ async def test_async_terminal_attach_includes_cursor(monkeypatch): captured = {} class DummyTarget: - url = "wss://runtime.example.com/sandbox/pty/pty_1/ws?sessionId=sbx_123&cursor=7" + url = ( + "wss://runtime.example.com/sandbox/pty/pty_1/ws?sessionId=sbx_123&cursor=7" + ) host_header = None connect_host = None connect_port = None @@ -1064,7 +1100,9 @@ async def fake_connect(url, additional_headers=None, open_timeout=None, **kwargs captured["url"] = url return object() - monkeypatch.setattr(async_terminal_module, "to_websocket_transport_target", fake_target) + monkeypatch.setattr( + async_terminal_module, "to_websocket_transport_target", fake_target + ) monkeypatch.setattr(async_terminal_module, "async_ws_connect", fake_connect) async def get_connection_info():