Skip to content

Commit 2b1df2c

Browse files
committed
feat(e2b): support listing sandboxes by metadata
1 parent eb35043 commit 2b1df2c

12 files changed

Lines changed: 533 additions & 93 deletions

File tree

rock/admin/core/sandbox_table.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
import datetime
66
from typing import TYPE_CHECKING, Any
77

8-
from sqlalchemy import select
8+
from sqlalchemy import and_, select
99

10+
from rock.actions.sandbox.response import State
1011
from rock.admin.core.db_provider import DatabaseProvider, retry_on_disconnect
1112
from rock.admin.core.schema import SandboxRecord
1213
from rock.admin.metrics.decorator import monitor_metastore_operation
@@ -190,6 +191,27 @@ def _list_by_in_sync(
190191
result = session.execute(stmt)
191192
return [_merge_status_blob(r.to_dict()) for r in result.scalars().all()]
192193

194+
@retry_on_disconnect
195+
@monitor_metastore_operation
196+
async def list_by_metadata(self, metadata: dict[str, str]) -> list[dict]:
197+
"""Return running sandboxes whose persisted metadata contains all requested pairs."""
198+
return await self._db.run(self._list_by_metadata_sync, metadata)
199+
200+
def _list_by_metadata_sync(self, metadata: dict[str, str]) -> list[dict]:
201+
if not metadata:
202+
raise ValueError("metadata filter must not be empty")
203+
204+
stmt = (
205+
select(SandboxRecord)
206+
.where(SandboxRecord.state == State.RUNNING.value)
207+
.where(_metadata_filter_expression(metadata, dialect_name=self._db.engine.dialect.name))
208+
.order_by(SandboxRecord.sandbox_id.asc())
209+
)
210+
211+
with self._db.session_factory() as session:
212+
result = session.execute(stmt)
213+
return [_merge_status_blob(record.to_dict()) for record in result.scalars().all()]
214+
193215
@retry_on_disconnect
194216
@monitor_metastore_operation
195217
async def list_expired_by(
@@ -235,6 +257,12 @@ def _pick_columns(data: dict[str, Any]) -> dict[str, Any]:
235257
return result
236258

237259

260+
def _metadata_filter_expression(metadata: dict[str, str], dialect_name: str):
261+
if dialect_name == "postgresql":
262+
return SandboxRecord.labels.op("@>")(metadata)
263+
return and_(*(SandboxRecord.labels[key].as_string() == value for key, value in metadata.items()))
264+
265+
238266
def _parse_aware_datetime(value: Any) -> datetime.datetime | None:
239267
if value is None:
240268
return None
Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
from fastapi import APIRouter, Request
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Query, Request
24
from fastapi.exceptions import RequestValidationError
35
from fastapi.responses import JSONResponse
46
from fastapi.routing import APIRoute
57

6-
from rock.admin.proto.response import E2BSandboxDetail
8+
from rock.admin.proto.response import E2BListedSandbox, E2BSandboxDetail
9+
from rock.admin.service.e2b_proxy_service import E2BProxyService
710
from rock.logger import init_logger
8-
from rock.sandbox.service.sandbox_proxy_service import SandboxProxyService
911
from rock.sdk.common.exceptions import BadRequestRockError, E2BSandboxNotFoundError
1012

1113
logger = init_logger(__name__)
@@ -25,18 +27,21 @@ async def handler(request: Request):
2527
return _error_response(400, message)
2628
except E2BSandboxNotFoundError as error:
2729
return _error_response(404, str(error))
30+
except BadRequestRockError as error:
31+
logger.warning("E2B proxy request rejected: %s", error)
32+
return _error_response(400, str(error))
2833
except Exception:
29-
logger.exception("E2B get sandbox failed")
34+
logger.exception("E2B proxy request failed")
3035
return _error_response(500, "Internal server error")
3136

3237
return handler
3338

3439

3540
e2b_proxy_router = APIRouter(route_class=E2BProxyAPIRoute)
36-
e2b_proxy_service: SandboxProxyService
41+
e2b_proxy_service: E2BProxyService
3742

3843

39-
def set_e2b_proxy_service(service: SandboxProxyService) -> None:
44+
def set_e2b_proxy_service(service: E2BProxyService) -> None:
4045
global e2b_proxy_service
4146
e2b_proxy_service = service
4247

@@ -45,16 +50,22 @@ def _error_response(status_code: int, message: str) -> JSONResponse:
4550
return JSONResponse(status_code=status_code, content={"code": status_code, "message": message})
4651

4752

53+
@e2b_proxy_router.get(
54+
"/v2/sandboxes",
55+
response_model=list[E2BListedSandbox],
56+
response_model_by_alias=True,
57+
response_model_exclude_none=True,
58+
)
59+
async def list_sandboxes(
60+
metadata: Annotated[str, Query(min_length=1)],
61+
) -> list[E2BListedSandbox]:
62+
return await e2b_proxy_service.list_sandboxes(metadata)
63+
64+
4865
@e2b_proxy_router.get(
4966
"/sandboxes/{sandboxID}",
5067
response_model=E2BSandboxDetail,
5168
response_model_by_alias=True,
5269
)
5370
async def get_sandbox(sandboxID: str) -> E2BSandboxDetail:
54-
try:
55-
sandbox_status = await e2b_proxy_service.get_status(sandboxID, include_all_states=True)
56-
except BadRequestRockError as error:
57-
if str(error) == f"Sandbox {sandboxID} not found":
58-
raise E2BSandboxNotFoundError(str(error)) from None
59-
raise
60-
return E2BSandboxDetail.from_sandbox_status(sandboxID, sandbox_status)
71+
return await e2b_proxy_service.get_sandbox(sandboxID)

rock/admin/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from rock.admin.scheduler.tasks.sandbox_log_archive_task import (
4646
set_sandbox_table_provider as set_archive_sandbox_table_provider,
4747
)
48+
from rock.admin.service.e2b_proxy_service import E2BProxyService
4849
from rock.admin.service.ops_service import OpsService
4950
from rock.common.exception import request_validation_exception_handler
5051
from rock.config import DatabaseConfig, RockConfig, SchedulerConfig
@@ -263,7 +264,7 @@ async def lifespan(app: FastAPI):
263264

264265
else:
265266
sandbox_manager = create_sandbox_proxy_service(rock_config=rock_config, meta_store=meta_store)
266-
set_e2b_proxy_service(sandbox_manager)
267+
set_e2b_proxy_service(E2BProxyService(sandbox_service=sandbox_manager, meta_store=meta_store))
267268
set_sandbox_proxy_service(sandbox_manager)
268269
proxy_service_ref = sandbox_manager
269270

rock/admin/proto/response.py

Lines changed: 8 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import datetime
2-
import math
3-
from ipaddress import ip_address
41
from typing import Literal
52

63
from pydantic import BaseModel, ConfigDict, Field
@@ -9,10 +6,7 @@
96
from rock.actions.sandbox.response import State, StateTransitionRecord
107
from rock.actions.sandbox.sandbox_info import SandboxInfo
118
from rock.admin.proto.request import TaskSetSpec
12-
from rock.common.constants import E2B_CLIENT_ID, E2B_ENVD_VERSION, E2B_SANDBOX_IP_METADATA_KEY, E2B_STATE_BY_ROCK_STATE
139
from rock.sandbox.utils.timeout import SandboxTimeoutHelper
14-
from rock.sdk.common.exceptions import E2BSandboxNotFoundError
15-
from rock.utils.format import parse_size_to_bytes
1610

1711

1812
class E2BCreateSandboxResponse(BaseModel):
@@ -22,6 +16,14 @@ class E2BCreateSandboxResponse(BaseModel):
2216
template_id: str = Field(alias="templateID")
2317

2418

19+
class E2BListedSandbox(BaseModel):
20+
model_config = ConfigDict(populate_by_name=True)
21+
22+
sandbox_id: str = Field(alias="sandboxID")
23+
metadata: dict[str, str]
24+
state: Literal["running", "paused"]
25+
26+
2527
class SandboxStartResponse(SandboxResponse):
2628
sandbox_id: str | None = None
2729
host_name: str | None = None
@@ -115,68 +117,6 @@ class E2BSandboxDetail(BaseModel):
115117
started_at: str = Field(alias="startedAt")
116118
end_at: str = Field(alias="endAt")
117119

118-
@staticmethod
119-
def _state(sandbox_id: str, state: State | str | None) -> Literal["running", "paused"]:
120-
try:
121-
rock_state = state if isinstance(state, State) else State(state)
122-
return E2B_STATE_BY_ROCK_STATE[rock_state.value]
123-
except (KeyError, TypeError, ValueError):
124-
raise E2BSandboxNotFoundError(f"Sandbox {sandbox_id} not found") from None
125-
126-
@staticmethod
127-
def _iso8601_timestamp(sandbox_id: str, field: str, value: object) -> str:
128-
if not isinstance(value, str):
129-
raise ValueError(f"Sandbox {sandbox_id} {field} is invalid")
130-
try:
131-
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
132-
except ValueError:
133-
raise ValueError(f"Sandbox {sandbox_id} {field} is invalid") from None
134-
if parsed.tzinfo is None:
135-
raise ValueError(f"Sandbox {sandbox_id} {field} must include a timezone")
136-
return parsed.isoformat(timespec="seconds")
137-
138-
@classmethod
139-
def from_sandbox_status(
140-
cls,
141-
sandbox_id: str,
142-
sandbox_status: SandboxStatusResponse,
143-
) -> "E2BSandboxDetail":
144-
state = cls._state(sandbox_id, sandbox_status.state)
145-
end_at = (
146-
sandbox_status.auto_stop_time
147-
if state == "running"
148-
else sandbox_status.auto_delete_time or sandbox_status.archive_time
149-
)
150-
151-
metadata = sandbox_status.metadata
152-
if not isinstance(metadata, dict) or not all(
153-
isinstance(key, str) and isinstance(value, str) for key, value in metadata.items()
154-
):
155-
raise ValueError(f"Sandbox {sandbox_id} metadata is invalid")
156-
157-
host_ip = sandbox_status.host_ip
158-
if not isinstance(host_ip, str) or not host_ip.strip():
159-
raise ValueError(f"Sandbox {sandbox_id} IP is missing")
160-
ip_address(host_ip)
161-
162-
return cls(
163-
sandboxID=sandbox_id,
164-
metadata={**metadata, E2B_SANDBOX_IP_METADATA_KEY: host_ip},
165-
state=state,
166-
clientID=E2B_CLIENT_ID,
167-
templateID=str(sandbox_status.image),
168-
envdVersion=E2B_ENVD_VERSION,
169-
cpuCount=max(1, math.ceil(float(sandbox_status.cpus))),
170-
memoryMB=parse_size_to_bytes(str(sandbox_status.memory)) // (1024**2),
171-
diskSizeMB=parse_size_to_bytes(str(sandbox_status.disk)) // (1024**2),
172-
startedAt=cls._iso8601_timestamp(
173-
sandbox_id,
174-
"start time",
175-
sandbox_status.start_time or sandbox_status.create_time,
176-
),
177-
endAt=cls._iso8601_timestamp(sandbox_id, "end time", end_at),
178-
)
179-
180120

181121
class SandboxListStatusResponse(SandboxStatusResponse):
182122
rock_authorization_encrypted: str | None = None

0 commit comments

Comments
 (0)