add sandbox runtime control endpoints

This commit is contained in:
Азамат Нураев 2026-04-28 21:53:26 +03:00
parent 0ca0bac9bf
commit 1b38bcfeab
17 changed files with 1408 additions and 119 deletions

View file

@ -27,16 +27,26 @@ from adapter.http.fastapi import app as app_module
from adapter.observability.noop import NoopMetrics, NoopTracer
from adapter.observability.runtime import ObservabilityRuntime
from adapter.sandbox.reconciliation import SandboxSessionReconciler
from domain.error import SandboxError, SandboxStartError
from domain.sandbox import SandboxSession, SandboxStatus
from domain.error import SandboxConflictError, SandboxError, SandboxStartError
from domain.sandbox import SandboxEndpoint, SandboxSession, SandboxStatus
from repository.sandbox_lock import ProcessLocalSandboxLifecycleLocker
from repository.sandbox_session import InMemorySandboxSessionRepository
from usecase.interface import Attrs
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand
from usecase.sandbox import (
CleanupExpiredSandboxes,
CreateSandbox,
CreateSandboxCommand,
DeleteSandbox,
DeleteSandboxCommand,
DeleteSandboxResult,
)
CHAT_ID = UUID('123e4567-e89b-12d3-a456-426614174000')
NON_CANONICAL_CHAT_ID = '123E4567E89B12D3A456426614174000'
SESSION_ID = UUID('00000000-0000-0000-0000-000000000011')
AGENT_ID = 'agent-alpha'
VOLUME_HOST_PATH = '/srv/sandbox/request-volume'
ENDPOINT = SandboxEndpoint(ip='172.20.0.8', port=8000)
class FakeLogger:
@ -82,6 +92,18 @@ class FakeCleanupExpiredSandboxes(CleanupExpiredSandboxes):
return []
class FakeDeleteSandboxUsecase(DeleteSandbox):
def __init__(self, result: DeleteSandboxResult | None = None) -> None:
self._result = result
self.commands: list[DeleteSandboxCommand] = []
def execute(self, command: DeleteSandboxCommand) -> DeleteSandboxResult:
self.commands.append(command)
if self._result is None:
return DeleteSandboxResult(chat_id=command.chat_id, result='not_found')
return self._result
class FakeDockerClient(DockerClient):
def __init__(self, base_url: str | None = None) -> None:
self.base_url = base_url
@ -197,10 +219,18 @@ class FakeLifecycleRuntime:
*,
session_id: UUID,
chat_id: UUID,
agent_id: str,
volume_host_path: str,
created_at: datetime,
expires_at: datetime,
) -> SandboxSession:
self.create_calls.append(CreateSandboxCommand(chat_id=chat_id))
self.create_calls.append(
CreateSandboxCommand(
chat_id=chat_id,
agent_id=agent_id,
volume_host_path=volume_host_path,
)
)
session = SandboxSession(
session_id=session_id,
chat_id=chat_id,
@ -208,6 +238,9 @@ class FakeLifecycleRuntime:
status=SandboxStatus.RUNNING,
created_at=created_at,
expires_at=expires_at,
agent_id=agent_id,
volume_host_path=volume_host_path,
endpoint=ENDPOINT,
)
self._sessions = [
existing for existing in self._sessions if existing.chat_id != chat_id
@ -218,6 +251,9 @@ class FakeLifecycleRuntime:
def stop(self, container_id: str) -> None:
self.stop_calls.append(container_id)
def delete(self, container_id: str) -> None:
self.stop_calls.append(container_id)
class FixedSandboxState:
def __init__(self, sessions: list[SandboxSession]) -> None:
@ -287,6 +323,8 @@ def build_config() -> AppConfig:
docker=DockerConfig(base_url='unix:///var/run/docker.sock'),
sandbox=SandboxConfig(
image='sandbox:latest',
network_name='sandbox',
agent_service_port=8000,
ttl_seconds=300,
cleanup_interval_seconds=60,
chats_root='/tmp/chats',
@ -295,6 +333,7 @@ def build_config() -> AppConfig:
chat_mount_path='/workspace/chat',
dependencies_mount_path='/workspace/dependencies',
lambda_tools_mount_path='/workspace/lambda-tools',
volume_mount_path='/workspace/volume',
),
security=SecurityConfig(
token_header='Authorization',
@ -310,6 +349,7 @@ def build_container(
cleanup_usecase: CleanupExpiredSandboxes,
logger: FakeLogger,
docker_client: FakeDockerClient,
delete_sandbox_usecase: DeleteSandbox | None = None,
sandbox_reconciler: SandboxSessionReconciler | None = None,
) -> AppContainer:
observability = ObservabilityRuntime(
@ -330,6 +370,7 @@ def build_container(
usecases = AppUsecases(
create_sandbox=create_sandbox_usecase,
cleanup_expired_sandboxes=cleanup_usecase,
delete_sandbox=delete_sandbox_usecase or FakeDeleteSandboxUsecase(),
)
return AppContainer(
config=config,
@ -419,6 +460,10 @@ async def get_json(app: FastAPI, path: str) -> tuple[int, dict[str, object]]:
return await request_json(app, 'GET', path)
async def delete_json(app: FastAPI, path: str) -> tuple[int, dict[str, object]]:
return await request_json(app, 'DELETE', path)
async def exercise_create_request(
app: FastAPI,
payload: dict[str, str],
@ -445,6 +490,19 @@ async def exercise_get_request(
await app.router.shutdown()
async def exercise_delete_request(
app: FastAPI,
path: str,
) -> tuple[int, dict[str, object]]:
await app.router.startup()
try:
status, response = await delete_json(app, path)
await asyncio.sleep(0)
return status, response
finally:
await app.router.shutdown()
def test_post_create_returns_session_with_canonical_chat_id(monkeypatch) -> None:
config = build_config()
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
@ -455,6 +513,9 @@ def test_post_create_returns_session_with_canonical_chat_id(monkeypatch) -> None
status=SandboxStatus.RUNNING,
created_at=expires_at - timedelta(minutes=5),
expires_at=expires_at,
agent_id=AGENT_ID,
volume_host_path=VOLUME_HOST_PATH,
endpoint=ENDPOINT,
)
logger = FakeLogger()
create_usecase = FakeCreateSandboxUsecase(session=session)
@ -475,19 +536,31 @@ def test_post_create_returns_session_with_canonical_chat_id(monkeypatch) -> None
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_create_request(app, {'chat_id': NON_CANONICAL_CHAT_ID})
exercise_create_request(
app,
{
'chat_id': NON_CANONICAL_CHAT_ID,
'agent_id': AGENT_ID,
'volume_host_path': VOLUME_HOST_PATH,
},
)
)
assert status_code == 200
assert response == {
'session_id': str(SESSION_ID),
'chat_id': str(CHAT_ID),
'agent_id': AGENT_ID,
'volume_host_path': VOLUME_HOST_PATH,
'container_id': 'container-123',
'endpoint': {'ip': '172.20.0.8', 'port': 8000},
'status': 'running',
'expires_at': '2026-04-02T12:05:00Z',
}
assert len(create_usecase.commands) == 1
assert create_usecase.commands[0].chat_id == CHAT_ID
assert create_usecase.commands[0].agent_id == AGENT_ID
assert create_usecase.commands[0].volume_host_path == VOLUME_HOST_PATH
assert cleanup_usecase.calls >= 1
assert any(
message == 'http_request'
@ -498,6 +571,55 @@ def test_post_create_returns_session_with_canonical_chat_id(monkeypatch) -> None
assert docker_client.close_calls == 1
def test_post_create_canonicalizes_volume_path_before_usecase(monkeypatch) -> None:
config = build_config()
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
session = SandboxSession(
session_id=SESSION_ID,
chat_id=CHAT_ID,
container_id='container-123',
status=SandboxStatus.RUNNING,
created_at=expires_at - timedelta(minutes=5),
expires_at=expires_at,
agent_id=AGENT_ID,
volume_host_path=VOLUME_HOST_PATH,
endpoint=ENDPOINT,
)
logger = FakeLogger()
create_usecase = FakeCreateSandboxUsecase(session=session)
cleanup_usecase = FakeCleanupExpiredSandboxes()
docker_client = FakeDockerClient()
container = build_container(
config,
create_usecase,
cleanup_usecase,
logger,
docker_client,
)
monkeypatch.setattr(app_module, 'build_container', lambda **kwargs: container)
monkeypatch.setattr(
app_module.FastAPIInstrumentor, 'instrument_app', lambda *args, **kwargs: None
)
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_create_request(
app,
{
'chat_id': str(CHAT_ID),
'agent_id': AGENT_ID,
'volume_host_path': '/srv/sandbox/a/../request-volume',
},
)
)
assert status_code == 200
assert response['volume_host_path'] == VOLUME_HOST_PATH
assert len(create_usecase.commands) == 1
assert create_usecase.commands[0].volume_host_path == VOLUME_HOST_PATH
assert docker_client.close_calls == 1
def test_post_create_rejects_non_uuid_chat_id(monkeypatch) -> None:
config = build_config()
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
@ -528,7 +650,14 @@ def test_post_create_rejects_non_uuid_chat_id(monkeypatch) -> None:
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_create_request(app, {'chat_id': 'x/../y'})
exercise_create_request(
app,
{
'chat_id': 'x/../y',
'agent_id': AGENT_ID,
'volume_host_path': VOLUME_HOST_PATH,
},
)
)
assert status_code == 422
@ -537,6 +666,94 @@ def test_post_create_rejects_non_uuid_chat_id(monkeypatch) -> None:
assert docker_client.close_calls == 1
@pytest.mark.parametrize(
'payload',
[
{'chat_id': str(CHAT_ID), 'volume_host_path': VOLUME_HOST_PATH},
{'chat_id': str(CHAT_ID), 'agent_id': AGENT_ID, 'volume_host_path': 'relative'},
],
)
def test_post_create_rejects_missing_or_invalid_runtime_params(
monkeypatch,
payload: dict[str, str],
) -> None:
config = build_config()
logger = FakeLogger()
create_usecase = FakeCreateSandboxUsecase(
session=SandboxSession(
session_id=SESSION_ID,
chat_id=CHAT_ID,
container_id='container-123',
status=SandboxStatus.RUNNING,
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=UTC),
expires_at=datetime(2026, 4, 2, 12, 5, tzinfo=UTC),
agent_id=AGENT_ID,
volume_host_path=VOLUME_HOST_PATH,
endpoint=ENDPOINT,
)
)
cleanup_usecase = FakeCleanupExpiredSandboxes()
docker_client = FakeDockerClient()
container = build_container(
config,
create_usecase,
cleanup_usecase,
logger,
docker_client,
)
monkeypatch.setattr(app_module, 'build_container', lambda **kwargs: container)
monkeypatch.setattr(
app_module.FastAPIInstrumentor, 'instrument_app', lambda *args, **kwargs: None
)
app = app_module.create_app(config=config)
status_code, response = asyncio.run(exercise_create_request(app, payload))
assert status_code == 422
assert 'detail' in response
assert create_usecase.commands == []
assert docker_client.close_calls == 1
def test_post_create_maps_conflict_to_conflict_response(monkeypatch) -> None:
config = build_config()
logger = FakeLogger()
create_usecase = FakeCreateSandboxUsecase(
error=SandboxConflictError(str(CHAT_ID))
)
cleanup_usecase = FakeCleanupExpiredSandboxes()
docker_client = FakeDockerClient()
container = build_container(
config,
create_usecase,
cleanup_usecase,
logger,
docker_client,
)
monkeypatch.setattr(app_module, 'build_container', lambda **kwargs: container)
monkeypatch.setattr(
app_module.FastAPIInstrumentor, 'instrument_app', lambda *args, **kwargs: None
)
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_create_request(
app,
{
'chat_id': str(CHAT_ID),
'agent_id': AGENT_ID,
'volume_host_path': VOLUME_HOST_PATH,
},
)
)
assert status_code == 409
assert response == {'detail': 'sandbox_conflict'}
assert docker_client.close_calls == 1
def test_post_create_maps_start_errors_to_service_unavailable(monkeypatch) -> None:
config = build_config()
logger = FakeLogger()
@ -558,7 +775,14 @@ def test_post_create_maps_start_errors_to_service_unavailable(monkeypatch) -> No
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_create_request(app, {'chat_id': str(CHAT_ID)})
exercise_create_request(
app,
{
'chat_id': str(CHAT_ID),
'agent_id': AGENT_ID,
'volume_host_path': VOLUME_HOST_PATH,
},
)
)
assert status_code == 503
@ -587,7 +811,14 @@ def test_post_create_maps_generic_sandbox_errors_to_internal_error(monkeypatch)
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_create_request(app, {'chat_id': str(CHAT_ID)})
exercise_create_request(
app,
{
'chat_id': str(CHAT_ID),
'agent_id': AGENT_ID,
'volume_host_path': VOLUME_HOST_PATH,
},
)
)
assert status_code == 500
@ -595,6 +826,89 @@ def test_post_create_maps_generic_sandbox_errors_to_internal_error(monkeypatch)
assert docker_client.close_calls == 1
def test_delete_sandbox_endpoint_returns_deleted(monkeypatch) -> None:
config = build_config()
logger = FakeLogger()
create_usecase = FakeCreateSandboxUsecase(error=AssertionError('unused'))
cleanup_usecase = FakeCleanupExpiredSandboxes()
delete_usecase = FakeDeleteSandboxUsecase(
DeleteSandboxResult(
chat_id=CHAT_ID,
result='deleted',
session_id=SESSION_ID,
container_id='container-123',
)
)
docker_client = FakeDockerClient()
container = build_container(
config,
create_usecase,
cleanup_usecase,
logger,
docker_client,
delete_sandbox_usecase=delete_usecase,
)
monkeypatch.setattr(app_module, 'build_container', lambda **kwargs: container)
monkeypatch.setattr(
app_module.FastAPIInstrumentor, 'instrument_app', lambda *args, **kwargs: None
)
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_delete_request(app, f'/api/v1/sandboxes/{CHAT_ID}')
)
assert status_code == 200
assert response == {
'chat_id': str(CHAT_ID),
'result': 'deleted',
'session_id': str(SESSION_ID),
'container_id': 'container-123',
}
assert delete_usecase.commands == [DeleteSandboxCommand(chat_id=CHAT_ID)]
assert docker_client.close_calls == 1
def test_delete_sandbox_endpoint_returns_not_found(monkeypatch) -> None:
config = build_config()
logger = FakeLogger()
create_usecase = FakeCreateSandboxUsecase(error=AssertionError('unused'))
cleanup_usecase = FakeCleanupExpiredSandboxes()
delete_usecase = FakeDeleteSandboxUsecase(
DeleteSandboxResult(chat_id=CHAT_ID, result='not_found')
)
docker_client = FakeDockerClient()
container = build_container(
config,
create_usecase,
cleanup_usecase,
logger,
docker_client,
delete_sandbox_usecase=delete_usecase,
)
monkeypatch.setattr(app_module, 'build_container', lambda **kwargs: container)
monkeypatch.setattr(
app_module.FastAPIInstrumentor, 'instrument_app', lambda *args, **kwargs: None
)
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_delete_request(app, f'/api/v1/sandboxes/{CHAT_ID}')
)
assert status_code == 200
assert response == {
'chat_id': str(CHAT_ID),
'result': 'not_found',
'session_id': None,
'container_id': None,
}
assert delete_usecase.commands == [DeleteSandboxCommand(chat_id=CHAT_ID)]
assert docker_client.close_calls == 1
def test_startup_reconciliation_reuses_existing_container_after_restart(
monkeypatch,
) -> None:
@ -607,6 +921,9 @@ def test_startup_reconciliation_reuses_existing_container_after_restart(
status=SandboxStatus.RUNNING,
created_at=created_at,
expires_at=created_at + timedelta(minutes=5),
agent_id=AGENT_ID,
volume_host_path=VOLUME_HOST_PATH,
endpoint=ENDPOINT,
)
logger = FakeLogger()
docker_client = FakeDockerClient()
@ -618,6 +935,7 @@ def test_startup_reconciliation_reuses_existing_container_after_restart(
tracer=NoopTracer(),
)
repositories = AppRepositories(sandbox_session=repository)
locker = ProcessLocalSandboxLifecycleLocker()
reconciler = SandboxSessionReconciler(
state_source=runtime,
registry=repository,
@ -628,7 +946,7 @@ def test_startup_reconciliation_reuses_existing_container_after_restart(
usecases = AppUsecases(
create_sandbox=CreateSandbox(
repository=repository,
locker=ProcessLocalSandboxLifecycleLocker(),
locker=locker,
runtime=runtime,
clock=FakeClock(created_at),
logger=logger,
@ -638,13 +956,21 @@ def test_startup_reconciliation_reuses_existing_container_after_restart(
),
cleanup_expired_sandboxes=CleanupExpiredSandboxes(
repository=repository,
locker=ProcessLocalSandboxLifecycleLocker(),
locker=locker,
runtime=runtime,
clock=FakeClock(created_at),
logger=logger,
metrics=NoopMetrics(),
tracer=NoopTracer(),
),
delete_sandbox=DeleteSandbox(
repository=repository,
locker=locker,
runtime=runtime,
logger=logger,
metrics=NoopMetrics(),
tracer=NoopTracer(),
),
)
container = AppContainer(
config=config,
@ -662,14 +988,24 @@ def test_startup_reconciliation_reuses_existing_container_after_restart(
app = app_module.create_app(config=config)
status_code, response = asyncio.run(
exercise_create_request(app, {'chat_id': str(CHAT_ID)})
exercise_create_request(
app,
{
'chat_id': str(CHAT_ID),
'agent_id': AGENT_ID,
'volume_host_path': VOLUME_HOST_PATH,
},
)
)
assert status_code == 200
assert response == {
'session_id': str(SESSION_ID),
'chat_id': str(CHAT_ID),
'agent_id': AGENT_ID,
'volume_host_path': VOLUME_HOST_PATH,
'container_id': 'container-123',
'endpoint': {'ip': '172.20.0.8', 'port': 8000},
'status': 'running',
'expires_at': '2026-04-02T12:05:00Z',
}