From d2506e0c63206c1acc589303831084dca0227de2 Mon Sep 17 00:00:00 2001 From: Azamat Date: Thu, 2 Apr 2026 13:41:41 +0300 Subject: [PATCH 1/2] ref #8: [feat] add http endpoint --- adapter/docker/runtime.py | 12 +---- adapter/http/fastapi/dependencies.py | 13 ++++-- adapter/http/fastapi/routers/v1/router.py | 57 +++++++++++++++++++++-- adapter/http/fastapi/schemas.py | 18 ++++++- tasks.md | 2 +- usecase/interface.py | 1 + usecase/sandbox.py | 4 +- 7 files changed, 87 insertions(+), 20 deletions(-) diff --git a/adapter/docker/runtime.py b/adapter/docker/runtime.py index 89df9ab..61fcaf6 100644 --- a/adapter/docker/runtime.py +++ b/adapter/docker/runtime.py @@ -1,4 +1,3 @@ -from collections.abc import Callable from datetime import datetime from pathlib import Path @@ -11,25 +10,22 @@ from domain.error import SandboxError, SandboxStartError from domain.sandbox import SandboxSession, SandboxStatus from usecase.interface import SandboxRuntime -type NowFactory = Callable[[datetime], datetime] - class DockerSandboxRuntime(SandboxRuntime): def __init__( self, config: SandboxConfig, client: DockerClient, - now: NowFactory | None = None, ) -> None: self._config = config self._client = client - self._now = _current_time if now is None else now def create( self, *, session_id: str, chat_id: str, + created_at: datetime, expires_at: datetime, ) -> SandboxSession: try: @@ -59,7 +55,7 @@ class DockerSandboxRuntime(SandboxRuntime): chat_id=chat_id, container_id=container_id, status=SandboxStatus.RUNNING, - created_at=self._now(expires_at), + created_at=created_at, expires_at=expires_at, ) @@ -128,7 +124,3 @@ class DockerSandboxRuntime(SandboxRuntime): def _host_path(self, path_value: str) -> Path: return Path(path_value).expanduser().resolve(strict=False) - - -def _current_time(expires_at: datetime) -> datetime: - return datetime.now(tz=expires_at.tzinfo) diff --git a/adapter/http/fastapi/dependencies.py b/adapter/http/fastapi/dependencies.py index 4892459..87a9224 100644 --- a/adapter/http/fastapi/dependencies.py +++ b/adapter/http/fastapi/dependencies.py @@ -1,8 +1,7 @@ -from typing import cast - from fastapi import Depends, Request from adapter.di.container import AppContainer +from usecase.sandbox import CreateSandbox from usecase.user import GetUser APP_CONTAINER_STATE = 'container' @@ -11,10 +10,16 @@ APP_CONFIG_STATE = 'config' def get_container(request: Request) -> AppContainer: container = getattr(request.app.state, APP_CONTAINER_STATE, None) - if container is None: + if not isinstance(container, AppContainer): raise RuntimeError('container unavailable') - return cast(AppContainer, container) + return container def get_get_user(container: AppContainer = Depends(get_container)) -> GetUser: return container.usecases.get_user + + +def get_create_sandbox( + container: AppContainer = Depends(get_container), +) -> CreateSandbox: + return container.usecases.create_sandbox diff --git a/adapter/http/fastapi/routers/v1/router.py b/adapter/http/fastapi/routers/v1/router.py index df3d575..1f0aff4 100644 --- a/adapter/http/fastapi/routers/v1/router.py +++ b/adapter/http/fastapi/routers/v1/router.py @@ -1,9 +1,21 @@ from fastapi import APIRouter, Depends, HTTPException, status from adapter.di.container import AppContainer -from adapter.http.fastapi.dependencies import get_container, get_get_user -from adapter.http.fastapi.schemas import ErrorResponse, HealthResponse, UserResponse -from domain.error import UserNotFoundError +from adapter.http.fastapi.dependencies import ( + get_container, + get_create_sandbox, + get_get_user, +) +from adapter.http.fastapi.schemas import ( + CreateSandboxRequest, + ErrorResponse, + HealthResponse, + SandboxSessionResponse, + UserResponse, +) +from domain.error import SandboxError, SandboxStartError, UserNotFoundError +from domain.sandbox import SandboxSession +from usecase.sandbox import CreateSandbox, CreateSandboxCommand from usecase.user import GetUser, GetUserQuery router = APIRouter() @@ -38,3 +50,42 @@ def get_user(user_id: str, usecase: GetUser = Depends(get_get_user)) -> UserResp ) from exc return UserResponse(id=user.id, email=user.email, name=user.name) + + +@router.post( + '/create', + response_model=SandboxSessionResponse, + responses={ + status.HTTP_503_SERVICE_UNAVAILABLE: {'model': ErrorResponse}, + status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, + }, + status_code=status.HTTP_200_OK, +) +def create_sandbox( + request: CreateSandboxRequest, + usecase: CreateSandbox = Depends(get_create_sandbox), +) -> SandboxSessionResponse: + try: + session = usecase.execute(CreateSandboxCommand(chat_id=request.chat_id)) + except SandboxStartError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except SandboxError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(exc), + ) from exc + + return _to_sandbox_session_response(session) + + +def _to_sandbox_session_response(session: SandboxSession) -> SandboxSessionResponse: + return SandboxSessionResponse( + session_id=session.session_id, + chat_id=session.chat_id, + container_id=session.container_id, + status=session.status.value, + expires_at=session.expires_at, + ) diff --git a/adapter/http/fastapi/schemas.py b/adapter/http/fastapi/schemas.py index e11c95b..08d9056 100644 --- a/adapter/http/fastapi/schemas.py +++ b/adapter/http/fastapi/schemas.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field class HealthResponse(BaseModel): @@ -7,6 +9,20 @@ class HealthResponse(BaseModel): env: str +class CreateSandboxRequest(BaseModel): + model_config = ConfigDict(extra='forbid', str_strip_whitespace=True) + + chat_id: str = Field(min_length=1) + + +class SandboxSessionResponse(BaseModel): + session_id: str + chat_id: str + container_id: str + status: str + expires_at: datetime + + class UserResponse(BaseModel): id: str email: str diff --git a/tasks.md b/tasks.md index e8c293c..49c99a6 100644 --- a/tasks.md +++ b/tasks.md @@ -86,7 +86,7 @@ ### M06. HTTP endpoint `POST /api/v1/create` - Субагент: `feature-developer` -- Статус: pending +- Статус: completed - Зависимости: `M04` - Commit required: no - Scope: добавить минимальную HTTP ручку для создания или переиспользования sandbox без auth diff --git a/usecase/interface.py b/usecase/interface.py index fcc1fe6..0c8bcaa 100644 --- a/usecase/interface.py +++ b/usecase/interface.py @@ -34,6 +34,7 @@ class SandboxRuntime(Protocol): *, session_id: str, chat_id: str, + created_at: datetime, expires_at: datetime, ) -> SandboxSession: ... diff --git a/usecase/sandbox.py b/usecase/sandbox.py index ae60946..65740ef 100644 --- a/usecase/sandbox.py +++ b/usecase/sandbox.py @@ -53,10 +53,12 @@ class CreateSandbox: self._runtime.stop(session.container_id) self._repository.delete(session.session_id) + expires_at = now + self._ttl new_session = self._runtime.create( session_id=_new_session_id(), chat_id=command.chat_id, - expires_at=now + self._ttl, + created_at=now, + expires_at=expires_at, ) self._repository.save(new_session) self._logger.info( From 3a7973accdb0895f2a33794d57451e641e6d58e3 Mon Sep 17 00:00:00 2001 From: Azamat Date: Thu, 2 Apr 2026 14:09:41 +0300 Subject: [PATCH 2/2] ref #8: [feat] add config for docker daemon --- adapter/config/loader.py | 11 +++++++++++ adapter/config/model.py | 6 ++++++ adapter/di/container.py | 2 +- config/app.yaml | 3 +++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/adapter/config/loader.py b/adapter/config/loader.py index 0cb4b6b..f33b908 100644 --- a/adapter/config/loader.py +++ b/adapter/config/loader.py @@ -8,6 +8,7 @@ from dotenv import dotenv_values from .model import ( AppConfig, AppSectionConfig, + DockerConfig, HttpConfig, LoggingConfig, MetricsConfig, @@ -39,6 +40,7 @@ def load_config( logging_section = _section(yaml_data, 'logging') metrics_section = _section(yaml_data, 'metrics') tracing_section = _section(yaml_data, 'tracing') + docker_section = _section(yaml_data, 'docker') sandbox_section = _section(yaml_data, 'sandbox') security_section = _section(yaml_data, 'security') @@ -130,6 +132,15 @@ def load_config( enable_metrics=metrics_enabled, enable_tracing=tracing_enabled, ), + docker=DockerConfig( + base_url=_yaml_or_env_str( + docker_section, + 'base_url', + 'docker.base_url', + env_values, + 'APP_DOCKER_BASE_URL', + ) + ), sandbox=_load_sandbox_config(sandbox_section, env_values), security=SecurityConfig( token_header=_yaml_or_env_str( diff --git a/adapter/config/model.py b/adapter/config/model.py index ca18347..3a8e70d 100644 --- a/adapter/config/model.py +++ b/adapter/config/model.py @@ -40,6 +40,11 @@ class OtelConfig: metric_export_interval: int +@dataclass(frozen=True, slots=True) +class DockerConfig: + base_url: str + + @dataclass(frozen=True, slots=True) class SandboxConfig: image: str @@ -68,5 +73,6 @@ class AppConfig: metrics: MetricsConfig tracing: TracingConfig otel: OtelConfig + docker: DockerConfig sandbox: SandboxConfig security: SecurityConfig diff --git a/adapter/di/container.py b/adapter/di/container.py index c28fbfa..592cf6e 100644 --- a/adapter/di/container.py +++ b/adapter/di/container.py @@ -78,8 +78,8 @@ def build_container( ) observability = build_observability(app_config) - docker_client: DockerClient = docker.from_env() clock = SystemClock() + docker_client = docker.DockerClient(base_url=app_config.docker.base_url) user_repository = InMemoryUserRepository( observability.tracer, [User(id='123', email='aza@gglamer.ru', name='gglamer')] diff --git a/config/app.yaml b/config/app.yaml index 2de4c27..0e729db 100644 --- a/config/app.yaml +++ b/config/app.yaml @@ -24,6 +24,9 @@ otel: traces_endpoint: http://localhost:4318/v1/traces metric_export_interval: 1000 +docker: + base_url: unix:///var/run/docker.sock + sandbox: image: ai-agent:latest ttl_seconds: 300