diff --git a/Makefile b/Makefile index 1c6eaf3..ed72fd7 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ test: uv run pytest -v lint: - uv run ruff check . + uv run ruff check . --fix typecheck: uv run mypy . diff --git a/adapter/http/fastapi/app.py b/adapter/http/fastapi/app.py index c23d0f9..06ac839 100644 --- a/adapter/http/fastapi/app.py +++ b/adapter/http/fastapi/app.py @@ -1,5 +1,6 @@ from collections.abc import Callable +from fastapi import FastAPI from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from adapter.config.loader import load_config @@ -8,7 +9,6 @@ from adapter.di.container import AppContainer, build_container from adapter.http.fastapi.dependencies import APP_CONFIG_STATE, APP_CONTAINER_STATE from adapter.http.fastapi.middleware import register_middleware from adapter.http.fastapi.routers.v1.router import router as v1_router -from fastapi import FastAPI API_V1_PREFIX = '/api/v1' diff --git a/adapter/http/fastapi/dependencies.py b/adapter/http/fastapi/dependencies.py index 5afba58..4892459 100644 --- a/adapter/http/fastapi/dependencies.py +++ b/adapter/http/fastapi/dependencies.py @@ -1,7 +1,8 @@ from typing import cast -from adapter.di.container import AppContainer from fastapi import Depends, Request + +from adapter.di.container import AppContainer from usecase.user import GetUser APP_CONTAINER_STATE = 'container' diff --git a/adapter/http/fastapi/middleware.py b/adapter/http/fastapi/middleware.py index 83d277e..598e991 100644 --- a/adapter/http/fastapi/middleware.py +++ b/adapter/http/fastapi/middleware.py @@ -1,8 +1,9 @@ from time import perf_counter +from fastapi import FastAPI, Request, Response + from adapter.config.model import AppConfig from adapter.http.fastapi.dependencies import get_container -from fastapi import FastAPI, Request, Response def register_middleware(app: FastAPI, config: AppConfig) -> None: diff --git a/docs/006-mvp-docker-sandbox-orchestration.md b/docs/006-mvp-docker-sandbox-orchestration.md new file mode 100644 index 0000000..ef404c3 --- /dev/null +++ b/docs/006-mvp-docker-sandbox-orchestration.md @@ -0,0 +1,19 @@ +# 006 MVP Docker Sandbox Orchestration + +Context +- The service needs a first MVP for sandbox orchestration behind `/api/v1/create`. +- The first version must stay small, avoid auth, and preserve clean architecture boundaries. + +Decision +- Use Docker as the outer runtime adapter for sandbox start and stop operations. +- Keep sandbox entities and errors in `domain/` and orchestration ports in `usecase/`. +- Use an in-memory session repository for the MVP instead of a central database. +- Keep one active sandbox per `chat_id` and reuse it until TTL expiry. +- Set default sandbox TTL to 300 seconds. +- Mount chat storage as `rw`, dependencies as `ro`, and lambda-tools as `ro`. +- Run expired sandbox cleanup as an in-process background loop in the HTTP app lifecycle. + +Consequences +- Inner layers stay free from Docker and FastAPI details. +- The MVP is single-instance oriented and not yet suitable for multi-node coordination. +- Repository and runtime can be replaced later without changing usecase contracts. diff --git a/domain/error.py b/domain/error.py index 1179f43..f691113 100644 --- a/domain/error.py +++ b/domain/error.py @@ -16,3 +16,19 @@ class UserConflictError(UserError): def __init__(self, email: str) -> None: super().__init__('user_conflict') self.email = email + + +class SandboxError(DomainError): + pass + + +class SandboxStartError(SandboxError): + def __init__(self, chat_id: str) -> None: + super().__init__('sandbox_start_failed') + self.chat_id = chat_id + + +class SandboxAlreadyRunningError(SandboxError): + def __init__(self, chat_id: str) -> None: + super().__init__('sandbox_already_running') + self.chat_id = chat_id diff --git a/domain/sandbox.py b/domain/sandbox.py new file mode 100644 index 0000000..110b4e4 --- /dev/null +++ b/domain/sandbox.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + + +class SandboxStatus(str, Enum): + STARTING = 'starting' + RUNNING = 'running' + STOPPING = 'stopping' + STOPPED = 'stopped' + FAILED = 'failed' + + +@dataclass(frozen=True, slots=True) +class SandboxSession: + session_id: str + chat_id: str + container_id: str + status: SandboxStatus + created_at: datetime + expires_at: datetime diff --git a/tasks.md b/tasks.md index c6ed917..98d8426 100644 --- a/tasks.md +++ b/tasks.md @@ -32,7 +32,7 @@ ### M01. ADR и минимальный sandbox scaffolding - Исполнитель: `primary-agent` -- Статус: pending +- Статус: completed - Зависимости: нет - Commit required: no - Scope: зафиксировать MVP-решение в ADR и создать минимальные сущности, ошибки и usecase-контракты для sandbox orchestration diff --git a/usecase/interface.py b/usecase/interface.py index 89811a8..fcc1fe6 100644 --- a/usecase/interface.py +++ b/usecase/interface.py @@ -1,7 +1,9 @@ from collections.abc import Mapping +from datetime import datetime from types import TracebackType from typing import Protocol, TypeAlias +from domain.sandbox import SandboxSession from domain.user import User AttrValue: TypeAlias = str | int | float | bool @@ -16,6 +18,32 @@ class UserRepository(Protocol): def save(self, user: User) -> None: ... +class SandboxSessionRepository(Protocol): + def get_active_by_chat_id(self, chat_id: str) -> SandboxSession | None: ... + + def list_expired(self, now: datetime) -> list[SandboxSession]: ... + + def save(self, session: SandboxSession) -> None: ... + + def delete(self, session_id: str) -> None: ... + + +class SandboxRuntime(Protocol): + def create( + self, + *, + session_id: str, + chat_id: str, + expires_at: datetime, + ) -> SandboxSession: ... + + def stop(self, container_id: str) -> None: ... + + +class Clock(Protocol): + def now(self) -> datetime: ... + + class Logger(Protocol): def debug(self, message: str, attrs: Attrs | None = None) -> None: ... diff --git a/usecase/sandbox.py b/usecase/sandbox.py new file mode 100644 index 0000000..0c34422 --- /dev/null +++ b/usecase/sandbox.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass +from datetime import timedelta + +from domain.sandbox import SandboxSession +from usecase.interface import Clock, Logger, SandboxRuntime, SandboxSessionRepository + + +@dataclass(frozen=True, slots=True) +class CreateSandboxCommand: + chat_id: str + + +class CreateSandbox: + def __init__( + self, + repository: SandboxSessionRepository, + runtime: SandboxRuntime, + clock: Clock, + logger: Logger, + ttl: timedelta, + ) -> None: + self._repository = repository + self._runtime = runtime + self._clock = clock + self._logger = logger + self._ttl = ttl + + def execute(self, command: CreateSandboxCommand) -> SandboxSession: + raise NotImplementedError + + +class CleanupExpiredSandboxes: + def __init__( + self, + repository: SandboxSessionRepository, + runtime: SandboxRuntime, + clock: Clock, + logger: Logger, + ) -> None: + self._repository = repository + self._runtime = runtime + self._clock = clock + self._logger = logger + + def execute(self) -> list[SandboxSession]: + raise NotImplementedError