ref #9: [feat] add tests
This commit is contained in:
parent
3a7973accd
commit
fb974fff1e
5 changed files with 899 additions and 2 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -14,3 +14,5 @@ wheels/
|
||||||
!docs
|
!docs
|
||||||
!AGENTS.md
|
!AGENTS.md
|
||||||
!tasks.md
|
!tasks.md
|
||||||
|
|
||||||
|
opencode.json
|
||||||
|
|
|
||||||
71
tasks.md
71
tasks.md
|
|
@ -98,7 +98,7 @@
|
||||||
### M07. Тесты для create, reuse, TTL и mount policy
|
### M07. Тесты для create, reuse, TTL и mount policy
|
||||||
|
|
||||||
- Субагент: `test-engineer`
|
- Субагент: `test-engineer`
|
||||||
- Статус: pending
|
- Статус: completed
|
||||||
- Зависимости: `M03`, `M04`, `M05`, `M06`
|
- Зависимости: `M03`, `M04`, `M05`, `M06`
|
||||||
- Commit required: no
|
- Commit required: no
|
||||||
- Scope: покрыть тестами ключевое поведение MVP без запуска реального production Docker stack
|
- Scope: покрыть тестами ключевое поведение MVP без запуска реального production Docker stack
|
||||||
|
|
@ -108,9 +108,76 @@
|
||||||
### M08. Архитектурный и boundary review по MVP sandbox
|
### M08. Архитектурный и boundary review по MVP sandbox
|
||||||
|
|
||||||
- Субагент: `code-reviewer`
|
- Субагент: `code-reviewer`
|
||||||
- Статус: pending
|
- Статус: completed
|
||||||
- Зависимости: `M07`
|
- Зависимости: `M07`
|
||||||
- Commit required: no
|
- Commit required: no
|
||||||
- Scope: проверить соблюдение clean architecture, dependency direction и соответствие MVP-ограничениям
|
- Scope: проверить соблюдение clean architecture, dependency direction и соответствие MVP-ограничениям
|
||||||
- Файлы: весь измененный код
|
- Файлы: весь измененный код
|
||||||
- Критерии приемки: Docker остается только во внешнем adapter; FastAPI не протекает в `domain/` и `usecase/`; TTL и mount policy читаются как явные, тестируемые правила; замечания сформулированы как точечные правки или подтверждение готовности
|
- Критерии приемки: Docker остается только во внешнем adapter; FastAPI не протекает в `domain/` и `usecase/`; TTL и mount policy читаются как явные, тестируемые правила; замечания сформулированы как точечные правки или подтверждение готовности
|
||||||
|
|
||||||
|
## Follow-up после M08 review
|
||||||
|
|
||||||
|
### M09. Сериализация lifecycle sandbox по `chat_id`
|
||||||
|
|
||||||
|
- Субагент: `feature-developer`
|
||||||
|
- Статус: pending
|
||||||
|
- Зависимости: `M08`
|
||||||
|
- Commit required: no
|
||||||
|
- Scope: убрать гонки между параллельными `create` и cleanup для одного `chat_id`
|
||||||
|
- Файлы: `usecase/interface.py`, `usecase/sandbox.py`, `repository/sandbox_lock.py` или другой outer-layer lock implementation, `adapter/di/container.py`
|
||||||
|
- Решение: ввести явный usecase-port для process-local lock по `chat_id`; outer-layer реализация держит per-chat lock registry; `CreateSandbox` и `CleanupExpiredSandboxes` выполняют мутации сессии под этим lock
|
||||||
|
- Критерии приемки: для одного `chat_id` не поднимаются два sandbox при concurrent create; create-vs-cleanup не оставляет orphan container; locking не протекает в HTTP и Docker adapter как бизнес-логика
|
||||||
|
|
||||||
|
### M10. Устойчивый cleanup и вынос blocking cleanup из event loop
|
||||||
|
|
||||||
|
- Субагент: `feature-developer`
|
||||||
|
- Статус: pending
|
||||||
|
- Зависимости: `M09`
|
||||||
|
- Commit required: no
|
||||||
|
- Scope: сделать cleanup устойчивым к частичным ошибкам и не блокировать FastAPI event loop синхронным Docker stop
|
||||||
|
- Файлы: `usecase/sandbox.py`, `adapter/http/fastapi/app.py`
|
||||||
|
- Решение: `CleanupExpiredSandboxes` обрабатывает stop/delete по каждой сессии отдельно и продолжает batch; HTTP cleanup loop выносит blocking cleanup work в thread через adapter-layer orchestration
|
||||||
|
- Критерии приемки: ошибка на одной expired session не мешает чистить остальные; background cleanup loop не умирает после ошибки; blocking cleanup больше не выполняется прямо в event loop
|
||||||
|
|
||||||
|
### M11. Удаление не-MVP user surface из приложения
|
||||||
|
|
||||||
|
- Субагент: `feature-developer`
|
||||||
|
- Статус: pending
|
||||||
|
- Зависимости: `M08`
|
||||||
|
- Commit required: no
|
||||||
|
- Scope: убрать из runtime app неотносящиеся к MVP user endpoint и seed user wiring
|
||||||
|
- Файлы: `adapter/http/fastapi/routers/v1/router.py`, `adapter/http/fastapi/dependencies.py`, `adapter/http/fastapi/schemas.py`, `adapter/di/container.py`
|
||||||
|
- Решение: оставить в MVP только `health` и sandbox API; примерный user code может остаться в репозитории как template, но не должен быть подключен в runtime app
|
||||||
|
- Критерии приемки: `GET /api/v1/users/{user_id}` больше не опубликован; container не создает seeded user repository/usecase для runtime app; незапрошенная user-surface area исчезает
|
||||||
|
|
||||||
|
### M12. Регрессионные тесты на race conditions и cleanup resilience
|
||||||
|
|
||||||
|
- Субагент: `test-engineer`
|
||||||
|
- Статус: pending
|
||||||
|
- Зависимости: `M09`, `M10`, `M11`
|
||||||
|
- Commit required: no
|
||||||
|
- Scope: добавить тесты на новые гарантии после review fixes
|
||||||
|
- Файлы: `test/*`
|
||||||
|
- Критерии приемки: есть тест на duplicate create для одного `chat_id`; есть тест на create-vs-cleanup race или эквивалентную сериализацию; есть тест, что cleanup продолжает batch после stop failure; HTTP smoke/regression тесты обновлены под удаление user endpoint
|
||||||
|
|
||||||
|
### M13. Повторный boundary review после fix-pass
|
||||||
|
|
||||||
|
- Субагент: `code-reviewer`
|
||||||
|
- Статус: pending
|
||||||
|
- Зависимости: `M12`
|
||||||
|
- Commit required: no
|
||||||
|
- Scope: проверить, что must-fix и should-fix замечания из `M08` закрыты без нарушения clean architecture
|
||||||
|
- Файлы: весь измененный код после `M09`-`M12`
|
||||||
|
- Критерии приемки: нет гонки на one-sandbox-per-chat; cleanup не блокирует event loop и не валится на первом stop failure; runtime app не публикует лишний user API; замечания сведены к minor или отсутствуют
|
||||||
|
|
||||||
|
### M14. Починка mypy-типизации тестов после sandbox MVP
|
||||||
|
|
||||||
|
- Субагент: `test-engineer`
|
||||||
|
- Статус: completed
|
||||||
|
- Зависимости: `M07`
|
||||||
|
- Commit required: no
|
||||||
|
- Scope: устранить текущие ошибки `make pre-commit` в test-suite без изменения production behavior
|
||||||
|
- Файлы: `test/test_docker_runtime.py`, `test/test_create_http.py`, при необходимости общие test helpers в `test/*`
|
||||||
|
- Ошибки: несовместимый fake Docker client для `DockerSandboxRuntime`, неточная типизация `run_calls` и ASGI message payload, использование `object` вместо типизированных test doubles для `AppRepositories`, `AppUsecases`, `AppContainer`
|
||||||
|
- Решение: сделать test doubles типизированными через совместимые fake classes или локальные protocols; убрать `object` и неиндексируемые `dict[str, object]` там, где mypy не может вывести типы
|
||||||
|
- Критерии приемки: `uv run mypy .` проходит; `make pre-commit` доходит как минимум до pytest stage; production code не меняется или меняется только при явной необходимости для testability
|
||||||
|
|
|
||||||
332
test/test_create_http.py
Normal file
332
test/test_create_http.py
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from docker import DockerClient
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.types import Message, Scope
|
||||||
|
|
||||||
|
from adapter.config.model import (
|
||||||
|
AppConfig,
|
||||||
|
AppSectionConfig,
|
||||||
|
DockerConfig,
|
||||||
|
HttpConfig,
|
||||||
|
LoggingConfig,
|
||||||
|
MetricsConfig,
|
||||||
|
OtelConfig,
|
||||||
|
SandboxConfig,
|
||||||
|
SecurityConfig,
|
||||||
|
TracingConfig,
|
||||||
|
)
|
||||||
|
from adapter.di.container import AppContainer, AppRepositories, AppUsecases
|
||||||
|
from adapter.http.fastapi import app as app_module
|
||||||
|
from adapter.observability.noop import NoopMetrics, NoopTracer
|
||||||
|
from adapter.observability.runtime import ObservabilityRuntime
|
||||||
|
from domain.error import SandboxError, SandboxStartError
|
||||||
|
from domain.sandbox import SandboxSession, SandboxStatus
|
||||||
|
from repository.sandbox_session import InMemorySandboxSessionRepository
|
||||||
|
from repository.user import InMemoryUserRepository
|
||||||
|
from usecase.interface import Attrs
|
||||||
|
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand
|
||||||
|
from usecase.user import GetUser
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLogger:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages: list[tuple[str, str, Attrs | None]] = []
|
||||||
|
|
||||||
|
def debug(self, message: str, attrs: Attrs | None = None) -> None:
|
||||||
|
self.messages.append(('debug', message, attrs))
|
||||||
|
|
||||||
|
def info(self, message: str, attrs: Attrs | None = None) -> None:
|
||||||
|
self.messages.append(('info', message, attrs))
|
||||||
|
|
||||||
|
def warning(self, message: str, attrs: Attrs | None = None) -> None:
|
||||||
|
self.messages.append(('warning', message, attrs))
|
||||||
|
|
||||||
|
def error(self, message: str, attrs: Attrs | None = None) -> None:
|
||||||
|
self.messages.append(('error', message, attrs))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCreateSandboxUsecase(CreateSandbox):
|
||||||
|
def __init__(
|
||||||
|
self, session: SandboxSession | None = None, error: Exception | None = None
|
||||||
|
) -> None:
|
||||||
|
self._session = session
|
||||||
|
self._error = error
|
||||||
|
self.commands: list[CreateSandboxCommand] = []
|
||||||
|
|
||||||
|
def execute(self, command: CreateSandboxCommand) -> SandboxSession:
|
||||||
|
self.commands.append(command)
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
if self._session is None:
|
||||||
|
raise AssertionError('missing session')
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCleanupExpiredSandboxes(CleanupExpiredSandboxes):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def execute(self) -> list[SandboxSession]:
|
||||||
|
self.calls += 1
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDockerClient(DockerClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.close_calls = 0
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.close_calls += 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_config() -> AppConfig:
|
||||||
|
return AppConfig(
|
||||||
|
app=AppSectionConfig(name='master', env='test'),
|
||||||
|
http=HttpConfig(host='127.0.0.1', port=8000),
|
||||||
|
logging=LoggingConfig(
|
||||||
|
level='INFO', output='stdout', format='json', file_path=None
|
||||||
|
),
|
||||||
|
metrics=MetricsConfig(enabled=False),
|
||||||
|
tracing=TracingConfig(enabled=False),
|
||||||
|
otel=OtelConfig(
|
||||||
|
service_name='master',
|
||||||
|
logs_endpoint='http://localhost:4318/v1/logs',
|
||||||
|
metrics_endpoint='http://localhost:4318/v1/metrics',
|
||||||
|
traces_endpoint='http://localhost:4318/v1/traces',
|
||||||
|
metric_export_interval=1000,
|
||||||
|
),
|
||||||
|
docker=DockerConfig(base_url='unix:///var/run/docker.sock'),
|
||||||
|
sandbox=SandboxConfig(
|
||||||
|
image='sandbox:latest',
|
||||||
|
ttl_seconds=300,
|
||||||
|
cleanup_interval_seconds=60,
|
||||||
|
chats_root='/tmp/chats',
|
||||||
|
dependencies_host_path='/tmp/dependencies',
|
||||||
|
lambda_tools_host_path='/tmp/lambda-tools',
|
||||||
|
chat_mount_path='/workspace/chat',
|
||||||
|
dependencies_mount_path='/workspace/dependencies',
|
||||||
|
lambda_tools_mount_path='/workspace/lambda-tools',
|
||||||
|
),
|
||||||
|
security=SecurityConfig(
|
||||||
|
token_header='Authorization',
|
||||||
|
api_token='token',
|
||||||
|
signing_key='signing-key',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_container(
|
||||||
|
config: AppConfig,
|
||||||
|
create_sandbox_usecase: FakeCreateSandboxUsecase,
|
||||||
|
cleanup_usecase: FakeCleanupExpiredSandboxes,
|
||||||
|
logger: FakeLogger,
|
||||||
|
docker_client: FakeDockerClient,
|
||||||
|
) -> AppContainer:
|
||||||
|
observability = ObservabilityRuntime(
|
||||||
|
logger=logger,
|
||||||
|
metrics=NoopMetrics(),
|
||||||
|
tracer=NoopTracer(),
|
||||||
|
)
|
||||||
|
repositories = AppRepositories(
|
||||||
|
user=InMemoryUserRepository(NoopTracer()),
|
||||||
|
sandbox_session=InMemorySandboxSessionRepository(),
|
||||||
|
)
|
||||||
|
usecases = AppUsecases(
|
||||||
|
get_user=GetUser(
|
||||||
|
repository=repositories.user,
|
||||||
|
logger=logger,
|
||||||
|
tracer=NoopTracer(),
|
||||||
|
),
|
||||||
|
create_sandbox=create_sandbox_usecase,
|
||||||
|
cleanup_expired_sandboxes=cleanup_usecase,
|
||||||
|
)
|
||||||
|
return AppContainer(
|
||||||
|
config=config,
|
||||||
|
observability=observability,
|
||||||
|
repositories=repositories,
|
||||||
|
usecases=usecases,
|
||||||
|
_docker_client=docker_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def post_json(
|
||||||
|
app: FastAPI, path: str, payload: dict[str, str]
|
||||||
|
) -> tuple[int, dict[str, object]]:
|
||||||
|
body = json.dumps(payload).encode()
|
||||||
|
messages: list[Message] = []
|
||||||
|
request_sent = False
|
||||||
|
|
||||||
|
async def receive() -> Message:
|
||||||
|
nonlocal request_sent
|
||||||
|
if request_sent:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return {'type': 'http.disconnect'}
|
||||||
|
|
||||||
|
request_sent = True
|
||||||
|
return {
|
||||||
|
'type': 'http.request',
|
||||||
|
'body': body,
|
||||||
|
'more_body': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def send(message: Message) -> None:
|
||||||
|
messages.append(message)
|
||||||
|
|
||||||
|
scope: Scope = {
|
||||||
|
'type': 'http',
|
||||||
|
'asgi': {'version': '3.0'},
|
||||||
|
'http_version': '1.1',
|
||||||
|
'method': 'POST',
|
||||||
|
'scheme': 'http',
|
||||||
|
'path': path,
|
||||||
|
'raw_path': path.encode(),
|
||||||
|
'query_string': b'',
|
||||||
|
'root_path': '',
|
||||||
|
'headers': [
|
||||||
|
(b'host', b'testserver'),
|
||||||
|
(b'content-type', b'application/json'),
|
||||||
|
(b'content-length', str(len(body)).encode()),
|
||||||
|
],
|
||||||
|
'client': ('testclient', 50000),
|
||||||
|
'server': ('testserver', 80),
|
||||||
|
'state': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
await app(scope, receive, send)
|
||||||
|
|
||||||
|
status = 500
|
||||||
|
response_body = b''
|
||||||
|
for message in messages:
|
||||||
|
if message['type'] == 'http.response.start':
|
||||||
|
status = int(message['status'])
|
||||||
|
if message['type'] == 'http.response.body':
|
||||||
|
response_body += bytes(message.get('body', b''))
|
||||||
|
|
||||||
|
return status, json.loads(response_body.decode())
|
||||||
|
|
||||||
|
|
||||||
|
async def exercise_create_request(
|
||||||
|
app: FastAPI,
|
||||||
|
payload: dict[str, str],
|
||||||
|
) -> tuple[int, dict[str, object]]:
|
||||||
|
await app.router.startup()
|
||||||
|
try:
|
||||||
|
status, response = await post_json(app, '/api/v1/create', payload)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
return status, response
|
||||||
|
finally:
|
||||||
|
await app.router.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_create_returns_session(monkeypatch) -> None:
|
||||||
|
config = build_config()
|
||||||
|
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
|
||||||
|
session = SandboxSession(
|
||||||
|
session_id='session-123',
|
||||||
|
chat_id='chat-123',
|
||||||
|
container_id='container-123',
|
||||||
|
status=SandboxStatus.RUNNING,
|
||||||
|
created_at=expires_at - timedelta(minutes=5),
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
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': 'chat-123'})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status_code == 200
|
||||||
|
assert response == {
|
||||||
|
'session_id': 'session-123',
|
||||||
|
'chat_id': 'chat-123',
|
||||||
|
'container_id': 'container-123',
|
||||||
|
'status': 'running',
|
||||||
|
'expires_at': '2026-04-02T12:05:00Z',
|
||||||
|
}
|
||||||
|
assert len(create_usecase.commands) == 1
|
||||||
|
assert create_usecase.commands[0].chat_id == 'chat-123'
|
||||||
|
assert cleanup_usecase.calls >= 1
|
||||||
|
assert any(
|
||||||
|
message == 'http_request'
|
||||||
|
and attrs is not None
|
||||||
|
and attrs['http.path'] == '/api/v1/create'
|
||||||
|
for _, message, attrs in logger.messages
|
||||||
|
)
|
||||||
|
assert docker_client.close_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_create_maps_start_errors_to_service_unavailable(monkeypatch) -> None:
|
||||||
|
config = build_config()
|
||||||
|
logger = FakeLogger()
|
||||||
|
create_usecase = FakeCreateSandboxUsecase(error=SandboxStartError('chat-123'))
|
||||||
|
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': 'chat-123'})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status_code == 503
|
||||||
|
assert response == {'detail': 'sandbox_start_failed'}
|
||||||
|
assert docker_client.close_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_create_maps_generic_sandbox_errors_to_internal_error(monkeypatch) -> None:
|
||||||
|
config = build_config()
|
||||||
|
logger = FakeLogger()
|
||||||
|
create_usecase = FakeCreateSandboxUsecase(error=SandboxError('sandbox_broken'))
|
||||||
|
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': 'chat-123'})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status_code == 500
|
||||||
|
assert response == {'detail': 'sandbox_broken'}
|
||||||
|
assert docker_client.close_calls == 1
|
||||||
212
test/test_docker_runtime.py
Normal file
212
test/test_docker_runtime.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from docker import DockerClient
|
||||||
|
from docker.errors import DockerException, NotFound
|
||||||
|
from docker.types import Mount
|
||||||
|
|
||||||
|
from adapter.config.model import SandboxConfig
|
||||||
|
from adapter.docker.runtime import DockerSandboxRuntime
|
||||||
|
from domain.error import SandboxError, SandboxStartError
|
||||||
|
from domain.sandbox import SandboxStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FakeContainer:
|
||||||
|
def __init__(self, container_id: str) -> None:
|
||||||
|
self.id = container_id
|
||||||
|
self.stop_calls = 0
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.stop_calls += 1
|
||||||
|
|
||||||
|
|
||||||
|
class RunKwargs(TypedDict):
|
||||||
|
detach: bool
|
||||||
|
labels: dict[str, str]
|
||||||
|
mounts: list[Mount]
|
||||||
|
|
||||||
|
|
||||||
|
class RunCall(TypedDict):
|
||||||
|
args: tuple[str]
|
||||||
|
kwargs: RunKwargs
|
||||||
|
|
||||||
|
|
||||||
|
class FakeContainers:
|
||||||
|
def __init__(self, run_result: FakeContainer | None = None) -> None:
|
||||||
|
self.run_calls: list[RunCall] = []
|
||||||
|
self.get_calls: list[str] = []
|
||||||
|
self.run_result = run_result or FakeContainer('container-123')
|
||||||
|
self.get_result: FakeContainer | Exception | None = None
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
image: str,
|
||||||
|
*,
|
||||||
|
detach: bool,
|
||||||
|
labels: dict[str, str],
|
||||||
|
mounts: list[Mount],
|
||||||
|
) -> FakeContainer:
|
||||||
|
self.run_calls.append(
|
||||||
|
{
|
||||||
|
'args': (image,),
|
||||||
|
'kwargs': {
|
||||||
|
'detach': detach,
|
||||||
|
'labels': labels,
|
||||||
|
'mounts': mounts,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return self.run_result
|
||||||
|
|
||||||
|
def get(self, container_id: str) -> FakeContainer:
|
||||||
|
self.get_calls.append(container_id)
|
||||||
|
if isinstance(self.get_result, Exception):
|
||||||
|
raise self.get_result
|
||||||
|
if self.get_result is None:
|
||||||
|
raise AssertionError('missing get result')
|
||||||
|
return self.get_result
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDockerClient(DockerClient):
|
||||||
|
def __init__(self, containers: FakeContainers) -> None:
|
||||||
|
self._containers = containers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def containers(self) -> Any:
|
||||||
|
return self._containers
|
||||||
|
|
||||||
|
|
||||||
|
def build_config(tmp_path: Path) -> SandboxConfig:
|
||||||
|
return SandboxConfig(
|
||||||
|
image='sandbox:latest',
|
||||||
|
ttl_seconds=300,
|
||||||
|
cleanup_interval_seconds=60,
|
||||||
|
chats_root=str(tmp_path / 'chats'),
|
||||||
|
dependencies_host_path=str(tmp_path / 'dependencies'),
|
||||||
|
lambda_tools_host_path=str(tmp_path / 'lambda-tools'),
|
||||||
|
chat_mount_path='/workspace/chat',
|
||||||
|
dependencies_mount_path='/workspace/dependencies',
|
||||||
|
lambda_tools_mount_path='/workspace/lambda-tools',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_create_applies_mount_policy_and_labels(tmp_path: Path) -> None:
|
||||||
|
config = build_config(tmp_path)
|
||||||
|
(tmp_path / 'dependencies').mkdir()
|
||||||
|
(tmp_path / 'lambda-tools').mkdir()
|
||||||
|
containers = FakeContainers()
|
||||||
|
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
|
||||||
|
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
||||||
|
expires_at = created_at + timedelta(minutes=5)
|
||||||
|
|
||||||
|
session = runtime.create(
|
||||||
|
session_id='session-123',
|
||||||
|
chat_id='chat-123',
|
||||||
|
created_at=created_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert session.session_id == 'session-123'
|
||||||
|
assert session.chat_id == 'chat-123'
|
||||||
|
assert session.container_id == 'container-123'
|
||||||
|
assert session.status is SandboxStatus.RUNNING
|
||||||
|
assert session.created_at == created_at
|
||||||
|
assert session.expires_at == expires_at
|
||||||
|
assert (tmp_path / 'chats' / 'chat-123').is_dir()
|
||||||
|
|
||||||
|
call = containers.run_calls[0]
|
||||||
|
assert call['args'] == ('sandbox:latest',)
|
||||||
|
assert call['kwargs']['detach'] is True
|
||||||
|
assert call['kwargs']['labels'] == {
|
||||||
|
'session_id': 'session-123',
|
||||||
|
'chat_id': 'chat-123',
|
||||||
|
'expires_at': expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
mounts = call['kwargs']['mounts']
|
||||||
|
assert [dict(mount) for mount in mounts] == [
|
||||||
|
{
|
||||||
|
'Target': '/workspace/chat',
|
||||||
|
'Source': str((tmp_path / 'chats' / 'chat-123').resolve(strict=False)),
|
||||||
|
'Type': 'bind',
|
||||||
|
'ReadOnly': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Target': '/workspace/dependencies',
|
||||||
|
'Source': str((tmp_path / 'dependencies').resolve(strict=False)),
|
||||||
|
'Type': 'bind',
|
||||||
|
'ReadOnly': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Target': '/workspace/lambda-tools',
|
||||||
|
'Source': str((tmp_path / 'lambda-tools').resolve(strict=False)),
|
||||||
|
'Type': 'bind',
|
||||||
|
'ReadOnly': True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_create_raises_start_error_when_container_id_is_missing(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
config = build_config(tmp_path)
|
||||||
|
(tmp_path / 'dependencies').mkdir()
|
||||||
|
(tmp_path / 'lambda-tools').mkdir()
|
||||||
|
containers = FakeContainers(run_result=FakeContainer(''))
|
||||||
|
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
|
||||||
|
|
||||||
|
with pytest.raises(SandboxStartError) as excinfo:
|
||||||
|
runtime.create(
|
||||||
|
session_id='session-123',
|
||||||
|
chat_id='chat-123',
|
||||||
|
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=UTC),
|
||||||
|
expires_at=datetime(2026, 4, 2, 12, 5, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert str(excinfo.value) == 'sandbox_start_failed'
|
||||||
|
assert excinfo.value.chat_id == 'chat-123'
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_stop_ignores_missing_container(tmp_path: Path) -> None:
|
||||||
|
config = build_config(tmp_path)
|
||||||
|
containers = FakeContainers()
|
||||||
|
containers.get_result = NotFound('missing')
|
||||||
|
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
|
||||||
|
|
||||||
|
runtime.stop('container-123')
|
||||||
|
|
||||||
|
assert containers.get_calls == ['container-123']
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_stop_wraps_docker_errors(tmp_path: Path) -> None:
|
||||||
|
config = build_config(tmp_path)
|
||||||
|
containers = FakeContainers()
|
||||||
|
containers.get_result = DockerException('boom')
|
||||||
|
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
|
||||||
|
|
||||||
|
with pytest.raises(SandboxError) as excinfo:
|
||||||
|
runtime.stop('container-123')
|
||||||
|
|
||||||
|
assert str(excinfo.value) == 'sandbox_stop_failed'
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_create_rejects_chat_path_traversal(tmp_path: Path) -> None:
|
||||||
|
config = build_config(tmp_path)
|
||||||
|
(tmp_path / 'dependencies').mkdir()
|
||||||
|
(tmp_path / 'lambda-tools').mkdir()
|
||||||
|
containers = FakeContainers()
|
||||||
|
runtime = DockerSandboxRuntime(config, FakeDockerClient(containers))
|
||||||
|
|
||||||
|
with pytest.raises(SandboxStartError) as excinfo:
|
||||||
|
runtime.create(
|
||||||
|
session_id='session-123',
|
||||||
|
chat_id='../escape',
|
||||||
|
created_at=datetime(2026, 4, 2, 12, 0, tzinfo=UTC),
|
||||||
|
expires_at=datetime(2026, 4, 2, 12, 5, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert str(excinfo.value) == 'sandbox_start_failed'
|
||||||
|
assert excinfo.value.chat_id == '../escape'
|
||||||
|
assert containers.run_calls == []
|
||||||
284
test/test_sandbox_usecase.py
Normal file
284
test/test_sandbox_usecase.py
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from domain.sandbox import SandboxSession, SandboxStatus
|
||||||
|
from repository.sandbox_session import InMemorySandboxSessionRepository
|
||||||
|
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self, now: datetime) -> None:
|
||||||
|
self._now = now
|
||||||
|
|
||||||
|
def now(self) -> datetime:
|
||||||
|
return self._now
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLogger:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages: list[
|
||||||
|
tuple[str, str, dict[str, str | int | float | bool] | None]
|
||||||
|
] = []
|
||||||
|
|
||||||
|
def debug(self, message: str, attrs=None) -> None:
|
||||||
|
self.messages.append(('debug', message, attrs))
|
||||||
|
|
||||||
|
def info(self, message: str, attrs=None) -> None:
|
||||||
|
self.messages.append(('info', message, attrs))
|
||||||
|
|
||||||
|
def warning(self, message: str, attrs=None) -> None:
|
||||||
|
self.messages.append(('warning', message, attrs))
|
||||||
|
|
||||||
|
def error(self, message: str, attrs=None) -> None:
|
||||||
|
self.messages.append(('error', message, attrs))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRuntime:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.create_calls: list[dict[str, object]] = []
|
||||||
|
self.stop_calls: list[str] = []
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
chat_id: str,
|
||||||
|
created_at: datetime,
|
||||||
|
expires_at: datetime,
|
||||||
|
) -> SandboxSession:
|
||||||
|
self.create_calls.append(
|
||||||
|
{
|
||||||
|
'session_id': session_id,
|
||||||
|
'chat_id': chat_id,
|
||||||
|
'created_at': created_at,
|
||||||
|
'expires_at': expires_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return SandboxSession(
|
||||||
|
session_id=session_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
container_id=f'container-{session_id}',
|
||||||
|
status=SandboxStatus.RUNNING,
|
||||||
|
created_at=created_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self, container_id: str) -> None:
|
||||||
|
self.stop_calls.append(container_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_sandbox_reuses_active_session_when_not_expired() -> None:
|
||||||
|
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
||||||
|
session = SandboxSession(
|
||||||
|
session_id='session-1',
|
||||||
|
chat_id='chat-1',
|
||||||
|
container_id='container-1',
|
||||||
|
status=SandboxStatus.RUNNING,
|
||||||
|
created_at=now - timedelta(minutes=1),
|
||||||
|
expires_at=now + timedelta(minutes=4),
|
||||||
|
)
|
||||||
|
repository = InMemorySandboxSessionRepository()
|
||||||
|
repository.save(session)
|
||||||
|
runtime = FakeRuntime()
|
||||||
|
logger = FakeLogger()
|
||||||
|
usecase = CreateSandbox(
|
||||||
|
repository=repository,
|
||||||
|
runtime=runtime,
|
||||||
|
clock=FakeClock(now),
|
||||||
|
logger=logger,
|
||||||
|
ttl=timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = usecase.execute(CreateSandboxCommand(chat_id='chat-1'))
|
||||||
|
|
||||||
|
assert result == session
|
||||||
|
assert runtime.create_calls == []
|
||||||
|
assert runtime.stop_calls == []
|
||||||
|
assert repository.get_active_by_chat_id('chat-1') == session
|
||||||
|
assert logger.messages == [
|
||||||
|
(
|
||||||
|
'info',
|
||||||
|
'sandbox_reused',
|
||||||
|
{
|
||||||
|
'chat_id': 'chat-1',
|
||||||
|
'session_id': 'session-1',
|
||||||
|
'container_id': 'container-1',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_sandbox_replaces_expired_session_and_creates_new_one(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
||||||
|
expired_session = SandboxSession(
|
||||||
|
session_id='session-old',
|
||||||
|
chat_id='chat-1',
|
||||||
|
container_id='container-old',
|
||||||
|
status=SandboxStatus.RUNNING,
|
||||||
|
created_at=now - timedelta(minutes=10),
|
||||||
|
expires_at=now,
|
||||||
|
)
|
||||||
|
repository = InMemorySandboxSessionRepository()
|
||||||
|
repository.save(expired_session)
|
||||||
|
runtime = FakeRuntime()
|
||||||
|
logger = FakeLogger()
|
||||||
|
usecase = CreateSandbox(
|
||||||
|
repository=repository,
|
||||||
|
runtime=runtime,
|
||||||
|
clock=FakeClock(now),
|
||||||
|
logger=logger,
|
||||||
|
ttl=timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr('usecase.sandbox._new_session_id', lambda: 'session-new')
|
||||||
|
|
||||||
|
result = usecase.execute(CreateSandboxCommand(chat_id='chat-1'))
|
||||||
|
|
||||||
|
assert runtime.stop_calls == ['container-old']
|
||||||
|
assert runtime.create_calls == [
|
||||||
|
{
|
||||||
|
'session_id': 'session-new',
|
||||||
|
'chat_id': 'chat-1',
|
||||||
|
'created_at': now,
|
||||||
|
'expires_at': now + timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert result == SandboxSession(
|
||||||
|
session_id='session-new',
|
||||||
|
chat_id='chat-1',
|
||||||
|
container_id='container-session-new',
|
||||||
|
status=SandboxStatus.RUNNING,
|
||||||
|
created_at=now,
|
||||||
|
expires_at=now + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
assert repository.get_active_by_chat_id('chat-1') == result
|
||||||
|
assert logger.messages == [
|
||||||
|
(
|
||||||
|
'info',
|
||||||
|
'sandbox_replaced',
|
||||||
|
{
|
||||||
|
'chat_id': 'chat-1',
|
||||||
|
'session_id': 'session-old',
|
||||||
|
'container_id': 'container-old',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'info',
|
||||||
|
'sandbox_created',
|
||||||
|
{
|
||||||
|
'chat_id': 'chat-1',
|
||||||
|
'session_id': 'session-new',
|
||||||
|
'container_id': 'container-session-new',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_sandbox_creates_new_session_when_none_exists() -> None:
|
||||||
|
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
||||||
|
repository = InMemorySandboxSessionRepository()
|
||||||
|
runtime = FakeRuntime()
|
||||||
|
logger = FakeLogger()
|
||||||
|
usecase = CreateSandbox(
|
||||||
|
repository=repository,
|
||||||
|
runtime=runtime,
|
||||||
|
clock=FakeClock(now),
|
||||||
|
logger=logger,
|
||||||
|
ttl=timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = usecase.execute(CreateSandboxCommand(chat_id='chat-1'))
|
||||||
|
|
||||||
|
assert result.chat_id == 'chat-1'
|
||||||
|
assert result.container_id == f'container-{result.session_id}'
|
||||||
|
assert result.status is SandboxStatus.RUNNING
|
||||||
|
assert result.created_at == now
|
||||||
|
assert result.expires_at == now + timedelta(minutes=5)
|
||||||
|
assert len(runtime.create_calls) == 1
|
||||||
|
assert runtime.create_calls[0] == {
|
||||||
|
'session_id': result.session_id,
|
||||||
|
'chat_id': 'chat-1',
|
||||||
|
'created_at': now,
|
||||||
|
'expires_at': now + timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
assert runtime.stop_calls == []
|
||||||
|
assert repository.get_active_by_chat_id('chat-1') == result
|
||||||
|
assert logger.messages == [
|
||||||
|
(
|
||||||
|
'info',
|
||||||
|
'sandbox_created',
|
||||||
|
{
|
||||||
|
'chat_id': 'chat-1',
|
||||||
|
'session_id': result.session_id,
|
||||||
|
'container_id': result.container_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_expired_sandboxes_stops_and_deletes_only_expired_sessions() -> None:
|
||||||
|
now = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
||||||
|
expired_session = SandboxSession(
|
||||||
|
session_id='session-expired',
|
||||||
|
chat_id='chat-expired',
|
||||||
|
container_id='container-expired',
|
||||||
|
status=SandboxStatus.RUNNING,
|
||||||
|
created_at=now - timedelta(minutes=10),
|
||||||
|
expires_at=now - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
boundary_session = SandboxSession(
|
||||||
|
session_id='session-boundary',
|
||||||
|
chat_id='chat-boundary',
|
||||||
|
container_id='container-boundary',
|
||||||
|
status=SandboxStatus.RUNNING,
|
||||||
|
created_at=now - timedelta(minutes=5),
|
||||||
|
expires_at=now,
|
||||||
|
)
|
||||||
|
active_session = SandboxSession(
|
||||||
|
session_id='session-active',
|
||||||
|
chat_id='chat-active',
|
||||||
|
container_id='container-active',
|
||||||
|
status=SandboxStatus.RUNNING,
|
||||||
|
created_at=now - timedelta(minutes=1),
|
||||||
|
expires_at=now + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
repository = InMemorySandboxSessionRepository()
|
||||||
|
repository.save(expired_session)
|
||||||
|
repository.save(boundary_session)
|
||||||
|
repository.save(active_session)
|
||||||
|
runtime = FakeRuntime()
|
||||||
|
logger = FakeLogger()
|
||||||
|
usecase = CleanupExpiredSandboxes(
|
||||||
|
repository=repository,
|
||||||
|
runtime=runtime,
|
||||||
|
clock=FakeClock(now),
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = usecase.execute()
|
||||||
|
|
||||||
|
assert result == [expired_session, boundary_session]
|
||||||
|
assert runtime.stop_calls == ['container-expired', 'container-boundary']
|
||||||
|
assert repository.get_active_by_chat_id('chat-expired') is None
|
||||||
|
assert repository.get_active_by_chat_id('chat-boundary') is None
|
||||||
|
assert repository.get_active_by_chat_id('chat-active') == active_session
|
||||||
|
assert logger.messages == [
|
||||||
|
(
|
||||||
|
'info',
|
||||||
|
'sandbox_cleaned',
|
||||||
|
{
|
||||||
|
'chat_id': 'chat-expired',
|
||||||
|
'session_id': 'session-expired',
|
||||||
|
'container_id': 'container-expired',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'info',
|
||||||
|
'sandbox_cleaned',
|
||||||
|
{
|
||||||
|
'chat_id': 'chat-boundary',
|
||||||
|
'session_id': 'session-boundary',
|
||||||
|
'container_id': 'container-boundary',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue