Compare commits
No commits in common. "0ca0bac9bf12bdb9e18dadb74dc90bcc52a49751" and "f0c4988b4439afd4e7aefef25f22e18a957d5498" have entirely different histories.
0ca0bac9bf
...
f0c4988b44
39 changed files with 124 additions and 5442 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -14,5 +14,3 @@ wheels/
|
||||||
!docs
|
!docs
|
||||||
!AGENTS.md
|
!AGENTS.md
|
||||||
!tasks.md
|
!tasks.md
|
||||||
|
|
||||||
opencode.json
|
|
||||||
|
|
|
||||||
15
AGENTS.md
15
AGENTS.md
|
|
@ -41,16 +41,16 @@
|
||||||
- Keep HTTP models and middleware inside `adapter/http/fastapi/`
|
- Keep HTTP models and middleware inside `adapter/http/fastapi/`
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
- Use dir `tasks/` for planning
|
- Use `tasks.md` for planning
|
||||||
- Do not use Beads
|
- Do not use Beads
|
||||||
- Do not use `bd`
|
- Do not use `bd`
|
||||||
- Use `uv` for Python commands and dependency management
|
- Use `uv` for Python commands and dependency management
|
||||||
|
- Do not create commits on your own
|
||||||
|
- Work on one task at a time
|
||||||
- Prefer delegation for implementation
|
- Prefer delegation for implementation
|
||||||
- After implementation, run `Code-Reviewer` agent
|
- Delegate only one task at a time
|
||||||
- Pass errors to `test-engineer` agent to capture
|
- After one task return to the user with result verification and next options
|
||||||
- Delegate `Feature-Developer` agent fix the errors
|
- Wait for the user before the next task commit or fix
|
||||||
- Repeat the cycle until no errors remain
|
|
||||||
- Ensure all tests pass
|
|
||||||
|
|
||||||
## Makefile
|
## Makefile
|
||||||
- `make install` install deps with `uv`
|
- `make install` install deps with `uv`
|
||||||
|
|
@ -83,6 +83,3 @@
|
||||||
- Prefer explicit wiring over magic
|
- Prefer explicit wiring over magic
|
||||||
- Do not expand scope without user approval
|
- Do not expand scope without user approval
|
||||||
- Do not `from __future__ import annotations`
|
- Do not `from __future__ import annotations`
|
||||||
- Do not `from importlib import import_module`
|
|
||||||
- Do not `importlib`
|
|
||||||
- Do not `cast`
|
|
||||||
|
|
|
||||||
2
Makefile
2
Makefile
|
|
@ -31,7 +31,7 @@ test:
|
||||||
uv run pytest -v
|
uv run pytest -v
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
uv run ruff check . --fix
|
uv run ruff check .
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
uv run mypy .
|
uv run mypy .
|
||||||
|
|
|
||||||
315
README.md
315
README.md
|
|
@ -1,268 +1,36 @@
|
||||||
# master-service
|
Это шаблон Python-сервиса на чистой архитектуре с заменяемым web-слоем, типизированным конфигом, явным dependency wiring и observability через порты.
|
||||||
|
|
||||||
`master-service` — это control-plane сервис для sandbox-контейнеров с AI-агентом.
|
## Что это за проект
|
||||||
Он поднимает и переиспользует sandbox на чат, подключает рабочие volume, восстанавливает state после рестарта и отдает наружу минимальный HTTP API под `/api/v1`.
|
|
||||||
|
|
||||||
Важно: в локальном `config/app.yaml` исторически еще стоят template-имена `web-python-skelet`.
|
- Небольшой референсный сервис со слоями `domain/`, `usecase/`, `repository/` и `adapter/`
|
||||||
Если хочешь, чтобы `/health` и OTel service name локально тоже показывали `master-service`, переопредели:
|
- Шаблон для сервисов на FastAPI, где FastAPI остается только во внешнем HTTP adapter
|
||||||
- `APP_NAME=master-service`
|
- Проект, где конфиг собирается из `config/app.yaml`, `.env` и env vars в одно дерево dataclass-конфигов
|
||||||
- `APP_OTEL_SERVICE_NAME=master-service`
|
- Проект, где repository и usecase создаются один раз на старте приложения в composition root
|
||||||
|
- Проект, где логи, метрики и трейсы скрыты за интерфейсами и могут работать через `stdout`, файл или OpenTelemetry runtime
|
||||||
|
|
||||||
Сервис реализован на Python с Clean Architecture:
|
## Основные идеи
|
||||||
- `domain/` — сущности и доменные ошибки
|
|
||||||
- `usecase/` — сценарии приложения и порты
|
|
||||||
- `repository/` — реализации repository
|
|
||||||
- `adapter/` — HTTP, config, DI, Docker runtime и observability
|
|
||||||
|
|
||||||
## Что умеет сейчас
|
- Clean Architecture и границы SOLID
|
||||||
|
- Направление зависимостей только внутрь
|
||||||
Текущий sandbox MVP покрывает:
|
- Тонкие adapter-слои и явная сборка зависимостей
|
||||||
- `GET /api/v1/health`
|
- Заменяемый HTTP-слой
|
||||||
- `POST /api/v1/create` с `chat_id: UUID`
|
- Observability без протекания OpenTelemetry во внутренние слои
|
||||||
- одну активную sandbox на чат
|
|
||||||
- reuse активной sandbox до истечения TTL
|
|
||||||
- cleanup просроченных sandbox в фоне
|
|
||||||
- startup reconciliation по Docker labels после рестарта сервиса
|
|
||||||
- chat mount `rw`, dependencies mount `ro`, lambda-tools mount `ro`
|
|
||||||
- логи, метрики и трейсы через порты `Logger`, `Metrics`, `Tracer`
|
|
||||||
|
|
||||||
Пока вне scope:
|
|
||||||
- auth и access control
|
|
||||||
- p2p/WebSocket lease
|
|
||||||
- workspace/chat CRUD API
|
|
||||||
- central DB, artifacts, S3, quota и retention policy
|
|
||||||
|
|
||||||
## Как устроен проект
|
|
||||||
|
|
||||||
- FastAPI живет только во внешнем adapter слое
|
|
||||||
- Docker живет только во внешнем adapter слое
|
|
||||||
- конфиг собирается из `config/app.yaml`, `.env` и env vars в один dataclass tree
|
|
||||||
- repository и usecase создаются один раз на старте в `adapter/di/container.py`
|
|
||||||
- observability не протекает во внутренние слои через OpenTelemetry SDK
|
|
||||||
|
|
||||||
## Структура
|
|
||||||
|
|
||||||
- `domain/` — core model и domain errors
|
|
||||||
- `usecase/` — use cases и interfaces
|
|
||||||
- `repository/` — in-memory и другие repository implementations
|
|
||||||
- `adapter/config/` — typed config models и loader
|
|
||||||
- `adapter/docker/` — Docker sandbox runtime
|
|
||||||
- `adapter/observability/` — logger/metrics/tracer runtime factory
|
|
||||||
- `adapter/otel/` — OpenTelemetry adapters
|
|
||||||
- `adapter/di/` — composition root
|
|
||||||
- `adapter/http/fastapi/` — app, middleware, schemas, routers
|
|
||||||
- `adapter/sandbox/` — sandbox reconciliation logic
|
|
||||||
- `config/` — YAML config files
|
|
||||||
- `docs/` — ADR и проектные гайды
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
### Требования
|
|
||||||
|
|
||||||
- Python 3.13
|
|
||||||
- `uv`
|
|
||||||
- локальный Docker daemon
|
|
||||||
- секреты `APP_API_TOKEN` и `APP_SIGNING_KEY`
|
|
||||||
|
|
||||||
### Установка
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make install
|
make install
|
||||||
```
|
|
||||||
|
|
||||||
### Локальный запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APP_API_TOKEN=local-api-token APP_SIGNING_KEY=local-signing-key make run
|
APP_API_TOKEN=local-api-token APP_SIGNING_KEY=local-signing-key make run
|
||||||
```
|
```
|
||||||
|
|
||||||
Это поднимет сам API, но для успешного `POST /api/v1/create` локально нужен еще рабочий sandbox runtime:
|
Приложение стартует на `http://0.0.0.0:8123` и публикует versioned API под `/api/v1`.
|
||||||
|
|
||||||
- Docker daemon должен быть доступен по `docker.base_url`
|
|
||||||
- образ `sandbox.image` должен существовать локально
|
|
||||||
- директории `sandbox.dependencies_host_path` и `sandbox.lambda_tools_host_path` должны существовать
|
|
||||||
|
|
||||||
В дефолтном `config/app.yaml` это значит:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p var/sandbox/dependencies var/sandbox/lambda-tools
|
|
||||||
docker image inspect ai-agent:latest >/dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
Если у тебя нет готового `ai-agent:latest`, проще начать с Docker Compose smoke path ниже.
|
|
||||||
|
|
||||||
После старта сервис доступен на:
|
|
||||||
- `http://127.0.0.1:8123/api/v1/health`
|
|
||||||
|
|
||||||
Проверка health:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://127.0.0.1:8123/api/v1/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Создание или reuse sandbox:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://127.0.0.1:8123/api/v1/create \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"chat_id":"11111111-1111-1111-1111-111111111111"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример ответа:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"session_id": "3701cfe3-e05e-48af-8385-442dcd954ca2",
|
|
||||||
"chat_id": "11111111-1111-1111-1111-111111111111",
|
|
||||||
"container_id": "64d839c6007de9396ee08ad4af4a22a59a6410ec5f4892a9277a87eb49c3ff5d",
|
|
||||||
"status": "running",
|
|
||||||
"expires_at": "2026-04-02T21:11:38.292893Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Запуск через Docker Compose
|
|
||||||
|
|
||||||
Для локального smoke-run есть `docker-compose.yml`.
|
|
||||||
Он поднимает:
|
|
||||||
- `app`
|
|
||||||
- `docker-engine` в режиме Docker-in-Docker
|
|
||||||
- `otel-collector`
|
|
||||||
|
|
||||||
При этом `app` получает compose-specific config из:
|
|
||||||
- `config/docker-compose.yml`
|
|
||||||
|
|
||||||
Запуск:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make compose-up
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверка:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make compose-ps
|
|
||||||
make compose-logs
|
|
||||||
```
|
|
||||||
|
|
||||||
Остановка:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make compose-down
|
|
||||||
```
|
|
||||||
|
|
||||||
Важно:
|
|
||||||
- в `config/docker-compose.yml` сейчас для smoke-проверки стоит `sandbox.image: nginx:1.27-alpine`
|
|
||||||
- для реального agent runtime замени `sandbox.image` на образ своего sandbox/agent контейнера
|
|
||||||
- в compose auth env vars нужны для startup config, но текущий MVP API еще не проверяет request token
|
|
||||||
|
|
||||||
## Как конфигурировать
|
|
||||||
|
|
||||||
### Источники конфига
|
|
||||||
|
|
||||||
Конфиг собирается в таком порядке:
|
|
||||||
1. базовый YAML из `config/app.yaml`
|
|
||||||
2. значения из `.env`
|
|
||||||
3. process env vars поверх `.env`
|
|
||||||
|
|
||||||
То есть env vars имеют наивысший приоритет.
|
|
||||||
|
|
||||||
### Обязательные секреты
|
|
||||||
|
|
||||||
Нужны всегда:
|
|
||||||
- `APP_API_TOKEN`
|
|
||||||
- `APP_SIGNING_KEY`
|
|
||||||
|
|
||||||
Сейчас это startup config, а не активная request auth для `/api/v1/create` и `/api/v1/health`.
|
|
||||||
То есть в текущем MVP токен не нужно передавать в HTTP headers для вызова этих endpoint.
|
|
||||||
|
|
||||||
### Основные секции YAML
|
|
||||||
|
|
||||||
В `config/app.yaml` и `config/docker-compose.yml` есть секции:
|
|
||||||
- `app`
|
|
||||||
- `http`
|
|
||||||
- `logging`
|
|
||||||
- `metrics`
|
|
||||||
- `tracing`
|
|
||||||
- `otel`
|
|
||||||
- `docker`
|
|
||||||
- `sandbox`
|
|
||||||
- `security`
|
|
||||||
|
|
||||||
### Полезные env overrides
|
|
||||||
|
|
||||||
Чаще всего полезны:
|
|
||||||
|
|
||||||
#### Общие
|
|
||||||
- `APP_NAME`
|
|
||||||
- `APP_ENV`
|
|
||||||
- `APP_HTTP_HOST`
|
|
||||||
- `APP_HTTP_PORT`
|
|
||||||
|
|
||||||
#### Логирование и observability
|
|
||||||
- `APP_LOGGING_LEVEL`
|
|
||||||
- `APP_LOGGING_OUTPUT`
|
|
||||||
- `APP_LOGGING_FORMAT`
|
|
||||||
- `APP_LOGGING_FILE_PATH`
|
|
||||||
- `APP_METRICS_ENABLED`
|
|
||||||
- `APP_TRACING_ENABLED`
|
|
||||||
- `APP_OTEL_SERVICE_NAME`
|
|
||||||
- `APP_OTEL_LOGS_ENDPOINT`
|
|
||||||
- `APP_OTEL_METRICS_ENDPOINT`
|
|
||||||
- `APP_OTEL_TRACES_ENDPOINT`
|
|
||||||
|
|
||||||
#### Docker runtime
|
|
||||||
- `APP_DOCKER_BASE_URL`
|
|
||||||
|
|
||||||
#### Sandbox
|
|
||||||
- `APP_SANDBOX_IMAGE`
|
|
||||||
- `APP_SANDBOX_TTL_SECONDS`
|
|
||||||
- `APP_SANDBOX_CLEANUP_INTERVAL_SECONDS`
|
|
||||||
- `APP_SANDBOX_CHATS_ROOT`
|
|
||||||
- `APP_SANDBOX_DEPENDENCIES_HOST_PATH`
|
|
||||||
- `APP_SANDBOX_LAMBDA_TOOLS_HOST_PATH`
|
|
||||||
- `APP_SANDBOX_CHAT_MOUNT_PATH`
|
|
||||||
- `APP_SANDBOX_DEPENDENCIES_MOUNT_PATH`
|
|
||||||
- `APP_SANDBOX_LAMBDA_TOOLS_MOUNT_PATH`
|
|
||||||
|
|
||||||
#### Security
|
|
||||||
- `APP_API_TOKEN_HEADER`
|
|
||||||
- `APP_API_TOKEN`
|
|
||||||
- `APP_SIGNING_KEY`
|
|
||||||
|
|
||||||
### Что важно в sandbox config
|
|
||||||
|
|
||||||
- `docker.base_url` — адрес Docker daemon
|
|
||||||
- `sandbox.image` — образ sandbox контейнера
|
|
||||||
- `sandbox.ttl_seconds` — TTL sandbox
|
|
||||||
- `sandbox.cleanup_interval_seconds` — частота cleanup loop
|
|
||||||
- `sandbox.chats_root` — корень chat directories
|
|
||||||
- `sandbox.dependencies_host_path` — host path для dependency cache
|
|
||||||
- `sandbox.lambda_tools_host_path` — host path для read-only lambda-tools
|
|
||||||
- `sandbox.chat_mount_path` — путь внутри sandbox для chat volume
|
|
||||||
- `sandbox.dependencies_mount_path` — путь внутри sandbox для dependency cache
|
|
||||||
- `sandbox.lambda_tools_mount_path` — путь внутри sandbox для lambda-tools
|
|
||||||
|
|
||||||
## Основные команды
|
|
||||||
|
|
||||||
- `make install` — установить зависимости через `uv`
|
|
||||||
- `make run` — локальный запуск
|
|
||||||
- `make run-otel` — запуск с OTel endpoints из env
|
|
||||||
- `make test` — `pytest`
|
|
||||||
- `make lint` — `ruff`
|
|
||||||
- `make typecheck` — `mypy`
|
|
||||||
- `make pre-commit` — lint + typecheck + test
|
|
||||||
- `make compose-build` — собрать compose images
|
|
||||||
- `make compose-up` — поднять локальный stack
|
|
||||||
- `make compose-down` — остановить stack
|
|
||||||
- `make compose-logs` — смотреть логи
|
|
||||||
- `make compose-ps` — смотреть статус сервисов
|
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
### Гайды
|
### Гайды
|
||||||
|
|
||||||
- [Правила проекта и ограничения для агента](AGENTS.md)
|
- [Правила проекта и ограничения для агента](AGENTS.md)
|
||||||
- [Кодстайл проекта](docs/CODESTYLE.md)
|
- [Кодстайл проекта для AI-агента](docs/CODESTYLE.md)
|
||||||
- [Чистая архитектура, SOLID, DIP, Protocol и repository](docs/CLEAN_ARCHITECTURE_RU.md)
|
- [Чистая архитектура, SOLID, DIP, Protocol и repository](docs/CLEAN_ARCHITECTURE_RU.md)
|
||||||
- [Логи, метрики и трейсы в этом проекте](docs/OBSERVABILITY_RU.md)
|
- [Логи, метрики и трейсы в этом проекте](docs/OBSERVABILITY_RU.md)
|
||||||
- [Как чистая архитектура реализована здесь](docs/PROJECT_GUIDE_RU.md)
|
- [Как чистая архитектура реализована здесь](docs/PROJECT_GUIDE_RU.md)
|
||||||
|
|
@ -275,24 +43,43 @@ make compose-down
|
||||||
- [003 Observability Via Interfaces](docs/003-observability-via-interfaces.md)
|
- [003 Observability Via Interfaces](docs/003-observability-via-interfaces.md)
|
||||||
- [004 Versioned HTTP API](docs/004-versioned-http-api.md)
|
- [004 Versioned HTTP API](docs/004-versioned-http-api.md)
|
||||||
- [005 Early FastAPI OTel Instrumentation](docs/005-fastapi-otel-early-instrumentation.md)
|
- [005 Early FastAPI OTel Instrumentation](docs/005-fastapi-otel-early-instrumentation.md)
|
||||||
- [006 MVP Docker Sandbox Orchestration](docs/006-mvp-docker-sandbox-orchestration.md)
|
|
||||||
- [007 Startup Sandbox Reconciliation](docs/007-startup-sandbox-reconciliation.md)
|
|
||||||
- [008 Sandbox Lifecycle Observability](docs/008-sandbox-lifecycle-observability.md)
|
|
||||||
|
|
||||||
## Для AI-агента
|
## Структура проекта
|
||||||
|
|
||||||
Если ты меняешь проект как AI-агент, сначала прочитай:
|
- `domain/` - core-сущности и доменные ошибки
|
||||||
|
- `usecase/` - прикладные сценарии и порты
|
||||||
|
- `repository/` - реализации repository
|
||||||
|
- `adapter/config/` - загрузка и модели типизированного конфига
|
||||||
|
- `adapter/observability/` - выбор runtime для logger, metrics и tracer
|
||||||
|
- `adapter/otel/` - OpenTelemetry adapters
|
||||||
|
- `adapter/di/` - composition root и singleton wiring
|
||||||
|
- `adapter/http/fastapi/` - HTTP-схемы, dependencies, middleware и routers
|
||||||
|
- `config/` - YAML-конфиг приложения и локального OTel collector
|
||||||
|
|
||||||
1. [AGENTS.md](AGENTS.md)
|
## Для ИИ
|
||||||
2. [docs/CODESTYLE.md](docs/CODESTYLE.md)
|
|
||||||
3. [docs/PROJECT_GUIDE_RU.md](docs/PROJECT_GUIDE_RU.md)
|
|
||||||
4. [docs/CLEAN_ARCHITECTURE_RU.md](docs/CLEAN_ARCHITECTURE_RU.md)
|
|
||||||
5. [docs/OBSERVABILITY_RU.md](docs/OBSERVABILITY_RU.md)
|
|
||||||
6. релевантные ADR в `docs/`
|
|
||||||
7. [tasks.md](tasks.md)
|
|
||||||
|
|
||||||
Главные правила:
|
Если ты AI-агент и собираешься что-то менять в проекте, сначала прочитай документы в таком порядке:
|
||||||
- сначала определи слой изменения
|
|
||||||
- зависимости только внутрь
|
1. [Правила проекта и ограничения агента](AGENTS.md) - обязательные правила работы в этом репозитории
|
||||||
- не тащи FastAPI и OpenTelemetry во внутренние слои
|
2. [Кодстайл проекта для AI-агента](docs/CODESTYLE.md) - границы слоев, стиль кода и правила зависимостей
|
||||||
- архитектурные решения сверяй с ADR
|
3. [Как чистая архитектура реализована здесь](docs/PROJECT_GUIDE_RU.md) - практическая карта проекта и типовые сценарии изменений
|
||||||
|
4. [Чистая архитектура, SOLID, DIP, Protocol и repository](docs/CLEAN_ARCHITECTURE_RU.md) - базовые архитектурные принципы и примеры
|
||||||
|
5. [Логи, метрики и трейсы в этом проекте](docs/OBSERVABILITY_RU.md) - читать перед любыми изменениями в observability, middleware и runtime wiring
|
||||||
|
6. [ADR в `docs/`](docs/001-composition-root-and-lifetimes.md) - читать релевантные решения перед изменением архитектуры или startup wiring
|
||||||
|
7. [План задач и история работ](tasks.md) - понять, что уже сделано, что отложено и какие ограничения были зафиксированы
|
||||||
|
|
||||||
|
Перед началом работы:
|
||||||
|
|
||||||
|
- Определи, в каком слое будет изменение: `domain/`, `usecase/`, `repository/` или `adapter/`
|
||||||
|
- Убедись, что зависимости идут только внутрь
|
||||||
|
- Не тащи FastAPI и OpenTelemetry во внутренние слои
|
||||||
|
- Сначала изучи существующий код в нужной директории, потом вноси изменения
|
||||||
|
- Если задача затрагивает архитектурное решение, сначала сверяйся с ADR и проектными правилами
|
||||||
|
|
||||||
|
## Запуск и команды
|
||||||
|
|
||||||
|
- Для локального запуска нужны `APP_API_TOKEN` и `APP_SIGNING_KEY`
|
||||||
|
- `make run` запускает приложение локально
|
||||||
|
- `make run-otel` запускает приложение с локальными OTel endpoints из env vars
|
||||||
|
- `make pre-commit` запускает `ruff`, `mypy` и `pytest`
|
||||||
|
- `make compose-up` поднимает приложение и локальный LGTM stack через Docker Compose
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,10 @@ from dotenv import dotenv_values
|
||||||
from .model import (
|
from .model import (
|
||||||
AppConfig,
|
AppConfig,
|
||||||
AppSectionConfig,
|
AppSectionConfig,
|
||||||
DockerConfig,
|
|
||||||
HttpConfig,
|
HttpConfig,
|
||||||
LoggingConfig,
|
LoggingConfig,
|
||||||
MetricsConfig,
|
MetricsConfig,
|
||||||
OtelConfig,
|
OtelConfig,
|
||||||
SandboxConfig,
|
|
||||||
SecurityConfig,
|
SecurityConfig,
|
||||||
TracingConfig,
|
TracingConfig,
|
||||||
)
|
)
|
||||||
|
|
@ -40,8 +38,6 @@ def load_config(
|
||||||
logging_section = _section(yaml_data, 'logging')
|
logging_section = _section(yaml_data, 'logging')
|
||||||
metrics_section = _section(yaml_data, 'metrics')
|
metrics_section = _section(yaml_data, 'metrics')
|
||||||
tracing_section = _section(yaml_data, 'tracing')
|
tracing_section = _section(yaml_data, 'tracing')
|
||||||
docker_section = _section(yaml_data, 'docker')
|
|
||||||
sandbox_section = _section(yaml_data, 'sandbox')
|
|
||||||
security_section = _section(yaml_data, 'security')
|
security_section = _section(yaml_data, 'security')
|
||||||
|
|
||||||
logging_output = _yaml_or_env_choice(
|
logging_output = _yaml_or_env_choice(
|
||||||
|
|
@ -132,16 +128,6 @@ def load_config(
|
||||||
enable_metrics=metrics_enabled,
|
enable_metrics=metrics_enabled,
|
||||||
enable_tracing=tracing_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(
|
security=SecurityConfig(
|
||||||
token_header=_yaml_or_env_str(
|
token_header=_yaml_or_env_str(
|
||||||
security_section,
|
security_section,
|
||||||
|
|
@ -235,77 +221,6 @@ def _optional_section(data: Mapping[str, object], name: str) -> dict[str, object
|
||||||
return section
|
return section
|
||||||
|
|
||||||
|
|
||||||
def _load_sandbox_config(
|
|
||||||
section: Mapping[str, object],
|
|
||||||
env: Mapping[str, str],
|
|
||||||
) -> SandboxConfig:
|
|
||||||
return SandboxConfig(
|
|
||||||
image=_yaml_or_env_str(
|
|
||||||
section,
|
|
||||||
'image',
|
|
||||||
'sandbox.image',
|
|
||||||
env,
|
|
||||||
'APP_SANDBOX_IMAGE',
|
|
||||||
),
|
|
||||||
ttl_seconds=_yaml_or_env_int(
|
|
||||||
section,
|
|
||||||
'ttl_seconds',
|
|
||||||
'sandbox.ttl_seconds',
|
|
||||||
env,
|
|
||||||
'APP_SANDBOX_TTL_SECONDS',
|
|
||||||
),
|
|
||||||
cleanup_interval_seconds=_yaml_or_env_int(
|
|
||||||
section,
|
|
||||||
'cleanup_interval_seconds',
|
|
||||||
'sandbox.cleanup_interval_seconds',
|
|
||||||
env,
|
|
||||||
'APP_SANDBOX_CLEANUP_INTERVAL_SECONDS',
|
|
||||||
),
|
|
||||||
chats_root=_yaml_or_env_str(
|
|
||||||
section,
|
|
||||||
'chats_root',
|
|
||||||
'sandbox.chats_root',
|
|
||||||
env,
|
|
||||||
'APP_SANDBOX_CHATS_ROOT',
|
|
||||||
),
|
|
||||||
dependencies_host_path=_yaml_or_env_str(
|
|
||||||
section,
|
|
||||||
'dependencies_host_path',
|
|
||||||
'sandbox.dependencies_host_path',
|
|
||||||
env,
|
|
||||||
'APP_SANDBOX_DEPENDENCIES_HOST_PATH',
|
|
||||||
),
|
|
||||||
lambda_tools_host_path=_yaml_or_env_str(
|
|
||||||
section,
|
|
||||||
'lambda_tools_host_path',
|
|
||||||
'sandbox.lambda_tools_host_path',
|
|
||||||
env,
|
|
||||||
'APP_SANDBOX_LAMBDA_TOOLS_HOST_PATH',
|
|
||||||
),
|
|
||||||
chat_mount_path=_yaml_or_env_str(
|
|
||||||
section,
|
|
||||||
'chat_mount_path',
|
|
||||||
'sandbox.chat_mount_path',
|
|
||||||
env,
|
|
||||||
'APP_SANDBOX_CHAT_MOUNT_PATH',
|
|
||||||
),
|
|
||||||
dependencies_mount_path=_yaml_or_env_str(
|
|
||||||
section,
|
|
||||||
'dependencies_mount_path',
|
|
||||||
'sandbox.dependencies_mount_path',
|
|
||||||
env,
|
|
||||||
'APP_SANDBOX_DEPENDENCIES_MOUNT_PATH',
|
|
||||||
),
|
|
||||||
lambda_tools_mount_path=_yaml_or_env_str(
|
|
||||||
section,
|
|
||||||
'lambda_tools_mount_path',
|
|
||||||
'sandbox.lambda_tools_mount_path',
|
|
||||||
env,
|
|
||||||
'APP_SANDBOX_LAMBDA_TOOLS_MOUNT_PATH',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_otel_config(
|
def _load_otel_config(
|
||||||
data: Mapping[str, object],
|
data: Mapping[str, object],
|
||||||
env: Mapping[str, str],
|
env: Mapping[str, str],
|
||||||
|
|
|
||||||
|
|
@ -40,24 +40,6 @@ class OtelConfig:
|
||||||
metric_export_interval: int
|
metric_export_interval: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DockerConfig:
|
|
||||||
base_url: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class SandboxConfig:
|
|
||||||
image: str
|
|
||||||
ttl_seconds: int
|
|
||||||
cleanup_interval_seconds: int
|
|
||||||
chats_root: str
|
|
||||||
dependencies_host_path: str
|
|
||||||
lambda_tools_host_path: str
|
|
||||||
chat_mount_path: str
|
|
||||||
dependencies_mount_path: str
|
|
||||||
lambda_tools_mount_path: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SecurityConfig:
|
class SecurityConfig:
|
||||||
token_header: str
|
token_header: str
|
||||||
|
|
@ -73,6 +55,4 @@ class AppConfig:
|
||||||
metrics: MetricsConfig
|
metrics: MetricsConfig
|
||||||
tracing: TracingConfig
|
tracing: TracingConfig
|
||||||
otel: OtelConfig
|
otel: OtelConfig
|
||||||
docker: DockerConfig
|
|
||||||
sandbox: SandboxConfig
|
|
||||||
security: SecurityConfig
|
security: SecurityConfig
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,24 @@
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import docker
|
|
||||||
from docker import DockerClient
|
|
||||||
|
|
||||||
from adapter.config.loader import load_config
|
from adapter.config.loader import load_config
|
||||||
from adapter.config.model import AppConfig
|
from adapter.config.model import AppConfig
|
||||||
from adapter.docker.runtime import DockerSandboxRuntime
|
|
||||||
from adapter.observability.factory import build_observability
|
from adapter.observability.factory import build_observability
|
||||||
from adapter.observability.runtime import ObservabilityRuntime
|
from adapter.observability.runtime import ObservabilityRuntime
|
||||||
from adapter.sandbox.reconciliation import SandboxSessionReconciler
|
from domain.user import User
|
||||||
from repository.sandbox_lock import ProcessLocalSandboxLifecycleLocker
|
from repository.user import InMemoryUserRepository
|
||||||
from repository.sandbox_session import InMemorySandboxSessionRepository
|
from usecase.user import GetUser
|
||||||
from usecase.interface import Clock
|
|
||||||
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class AppRepositories:
|
class AppRepositories:
|
||||||
sandbox_session: InMemorySandboxSessionRepository
|
user: InMemoryUserRepository
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class AppUsecases:
|
class AppUsecases:
|
||||||
create_sandbox: CreateSandbox
|
get_user: GetUser
|
||||||
cleanup_expired_sandboxes: CleanupExpiredSandboxes
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -35,30 +27,16 @@ class AppContainer:
|
||||||
observability: ObservabilityRuntime
|
observability: ObservabilityRuntime
|
||||||
repositories: AppRepositories
|
repositories: AppRepositories
|
||||||
usecases: AppUsecases
|
usecases: AppUsecases
|
||||||
sandbox_reconciler: SandboxSessionReconciler = field(repr=False)
|
|
||||||
_docker_client: DockerClient = field(repr=False)
|
|
||||||
_is_shutdown: bool = field(default=False, init=False, repr=False)
|
_is_shutdown: bool = field(default=False, init=False, repr=False)
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
if self._is_shutdown:
|
if self._is_shutdown:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._is_shutdown = True
|
try:
|
||||||
errors: list[Exception] = []
|
self.observability.shutdown()
|
||||||
|
finally:
|
||||||
for action in (self._docker_client.close, self.observability.shutdown):
|
self._is_shutdown = True
|
||||||
try:
|
|
||||||
action()
|
|
||||||
except Exception as exc:
|
|
||||||
errors.append(exc)
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
raise ExceptionGroup('shutdown failed', errors)
|
|
||||||
|
|
||||||
|
|
||||||
class SystemClock(Clock):
|
|
||||||
def now(self) -> datetime:
|
|
||||||
return datetime.now(tz=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def build_container(
|
def build_container(
|
||||||
|
|
@ -76,46 +54,17 @@ def build_container(
|
||||||
)
|
)
|
||||||
|
|
||||||
observability = build_observability(app_config)
|
observability = build_observability(app_config)
|
||||||
clock = SystemClock()
|
|
||||||
docker_client = docker.DockerClient(base_url=app_config.docker.base_url)
|
|
||||||
|
|
||||||
sandbox_repository = InMemorySandboxSessionRepository()
|
user_repository = InMemoryUserRepository(
|
||||||
sandbox_locker = ProcessLocalSandboxLifecycleLocker()
|
observability.tracer, [User(id='123', email='aza@gglamer.ru', name='gglamer')]
|
||||||
sandbox_runtime = DockerSandboxRuntime(
|
|
||||||
app_config.sandbox,
|
|
||||||
docker_client,
|
|
||||||
observability.metrics,
|
|
||||||
observability.tracer,
|
|
||||||
)
|
)
|
||||||
sandbox_reconciler = SandboxSessionReconciler(
|
repositories = AppRepositories(user=user_repository)
|
||||||
state_source=sandbox_runtime,
|
|
||||||
registry=sandbox_repository,
|
|
||||||
logger=observability.logger,
|
|
||||||
metrics=observability.metrics,
|
|
||||||
tracer=observability.tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
repositories = AppRepositories(sandbox_session=sandbox_repository)
|
|
||||||
usecases = AppUsecases(
|
usecases = AppUsecases(
|
||||||
create_sandbox=CreateSandbox(
|
get_user=GetUser(
|
||||||
repository=sandbox_repository,
|
repository=user_repository,
|
||||||
locker=sandbox_locker,
|
|
||||||
runtime=sandbox_runtime,
|
|
||||||
clock=clock,
|
|
||||||
logger=observability.logger,
|
logger=observability.logger,
|
||||||
metrics=observability.metrics,
|
|
||||||
tracer=observability.tracer,
|
tracer=observability.tracer,
|
||||||
ttl=timedelta(seconds=app_config.sandbox.ttl_seconds),
|
)
|
||||||
),
|
|
||||||
cleanup_expired_sandboxes=CleanupExpiredSandboxes(
|
|
||||||
repository=sandbox_repository,
|
|
||||||
locker=sandbox_locker,
|
|
||||||
runtime=sandbox_runtime,
|
|
||||||
clock=clock,
|
|
||||||
logger=observability.logger,
|
|
||||||
metrics=observability.metrics,
|
|
||||||
tracer=observability.tracer,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return AppContainer(
|
return AppContainer(
|
||||||
|
|
@ -123,6 +72,4 @@ def build_container(
|
||||||
observability=observability,
|
observability=observability,
|
||||||
repositories=repositories,
|
repositories=repositories,
|
||||||
usecases=usecases,
|
usecases=usecases,
|
||||||
sandbox_reconciler=sandbox_reconciler,
|
|
||||||
_docker_client=docker_client,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from docker import DockerClient
|
|
||||||
from docker.errors import DockerException, NotFound
|
|
||||||
from docker.types import Mount
|
|
||||||
|
|
||||||
from adapter.config.model import SandboxConfig
|
|
||||||
from domain.error import SandboxError, SandboxStartError
|
|
||||||
from domain.sandbox import SandboxSession, SandboxStatus
|
|
||||||
from usecase.interface import Metrics, SandboxRuntime, Span, Tracer
|
|
||||||
|
|
||||||
SANDBOX_LABELS = ('session_id', 'chat_id', 'expires_at')
|
|
||||||
|
|
||||||
|
|
||||||
class DockerSandboxRuntime(SandboxRuntime):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: SandboxConfig,
|
|
||||||
client: DockerClient,
|
|
||||||
metrics: Metrics,
|
|
||||||
tracer: Tracer,
|
|
||||||
) -> None:
|
|
||||||
self._config = config
|
|
||||||
self._client = client
|
|
||||||
self._metrics = metrics
|
|
||||||
self._tracer = tracer
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session_id: UUID,
|
|
||||||
chat_id: UUID,
|
|
||||||
created_at: datetime,
|
|
||||||
expires_at: datetime,
|
|
||||||
) -> SandboxSession:
|
|
||||||
started_at = time.perf_counter()
|
|
||||||
result = 'error'
|
|
||||||
|
|
||||||
with self._tracer.start_span(
|
|
||||||
'adapter.docker.create_sandbox',
|
|
||||||
attrs={
|
|
||||||
'chat.id': str(chat_id),
|
|
||||||
'session.id': str(session_id),
|
|
||||||
},
|
|
||||||
) as span:
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
chat_path = self._chat_path(chat_id)
|
|
||||||
dependencies_path = self._readonly_host_path(
|
|
||||||
self._config.dependencies_host_path
|
|
||||||
)
|
|
||||||
lambda_tools_path = self._readonly_host_path(
|
|
||||||
self._config.lambda_tools_host_path
|
|
||||||
)
|
|
||||||
chat_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
container = self._client.containers.run(
|
|
||||||
self._config.image,
|
|
||||||
detach=True,
|
|
||||||
labels=self._labels(session_id, chat_id, expires_at),
|
|
||||||
mounts=self._mounts(
|
|
||||||
chat_path,
|
|
||||||
dependencies_path,
|
|
||||||
lambda_tools_path,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except (DockerException, OSError, ValueError) as exc:
|
|
||||||
raise SandboxStartError(str(chat_id)) from exc
|
|
||||||
|
|
||||||
container_id = str(getattr(container, 'id', '')).strip()
|
|
||||||
if not container_id:
|
|
||||||
raise SandboxStartError(str(chat_id))
|
|
||||||
|
|
||||||
result = 'created'
|
|
||||||
span.set_attribute('container.id', container_id)
|
|
||||||
span.set_attribute('sandbox.result', result)
|
|
||||||
return SandboxSession(
|
|
||||||
session_id=session_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
container_id=container_id,
|
|
||||||
status=SandboxStatus.RUNNING,
|
|
||||||
created_at=created_at,
|
|
||||||
expires_at=expires_at,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
span.set_attribute('sandbox.result', result)
|
|
||||||
span.record_error(exc)
|
|
||||||
self._metrics.increment(
|
|
||||||
'sandbox.runtime.error.total',
|
|
||||||
attrs=_runtime_error_metric_attrs('create', _error_type(exc)),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
self._metrics.record(
|
|
||||||
'sandbox.runtime.create.duration_ms',
|
|
||||||
_duration_ms(started_at),
|
|
||||||
attrs=_runtime_metric_attrs('create', result),
|
|
||||||
)
|
|
||||||
|
|
||||||
def stop(self, container_id: str) -> None:
|
|
||||||
started_at = time.perf_counter()
|
|
||||||
result = 'error'
|
|
||||||
|
|
||||||
with self._tracer.start_span(
|
|
||||||
'adapter.docker.stop_sandbox',
|
|
||||||
attrs={'container.id': container_id},
|
|
||||||
) as span:
|
|
||||||
try:
|
|
||||||
container = self._client.containers.get(container_id)
|
|
||||||
_set_span_container_attrs(span, container)
|
|
||||||
container.stop()
|
|
||||||
result = 'stopped'
|
|
||||||
span.set_attribute('sandbox.result', result)
|
|
||||||
except NotFound:
|
|
||||||
result = 'not_found'
|
|
||||||
span.set_attribute('sandbox.result', result)
|
|
||||||
return
|
|
||||||
except DockerException as exc:
|
|
||||||
span.set_attribute('sandbox.result', result)
|
|
||||||
span.record_error(exc)
|
|
||||||
self._metrics.increment(
|
|
||||||
'sandbox.runtime.error.total',
|
|
||||||
attrs=_runtime_error_metric_attrs('stop', type(exc).__name__),
|
|
||||||
)
|
|
||||||
raise SandboxError('sandbox_stop_failed') from exc
|
|
||||||
finally:
|
|
||||||
self._metrics.record(
|
|
||||||
'sandbox.runtime.stop.duration_ms',
|
|
||||||
_duration_ms(started_at),
|
|
||||||
attrs=_runtime_metric_attrs('stop', result),
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_active_sessions(self) -> list[SandboxSession]:
|
|
||||||
started_at = time.perf_counter()
|
|
||||||
result = 'error'
|
|
||||||
|
|
||||||
with self._tracer.start_span(
|
|
||||||
'adapter.docker.list_active_sandboxes',
|
|
||||||
) as span:
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
containers = self._client.containers.list(
|
|
||||||
filters={'label': list(SANDBOX_LABELS)}
|
|
||||||
)
|
|
||||||
except DockerException as exc:
|
|
||||||
raise SandboxError('sandbox_list_failed') from exc
|
|
||||||
|
|
||||||
sessions: list[SandboxSession] = []
|
|
||||||
for container in containers:
|
|
||||||
session = self._session_from_container(container)
|
|
||||||
if session is None:
|
|
||||||
continue
|
|
||||||
sessions.append(session)
|
|
||||||
|
|
||||||
result = 'listed'
|
|
||||||
span.set_attribute('sandbox.container_count', len(containers))
|
|
||||||
span.set_attribute('sandbox.active_count', len(sessions))
|
|
||||||
span.set_attribute('sandbox.result', result)
|
|
||||||
return sessions
|
|
||||||
except Exception as exc:
|
|
||||||
span.set_attribute('sandbox.result', result)
|
|
||||||
span.record_error(exc)
|
|
||||||
self._metrics.increment(
|
|
||||||
'sandbox.runtime.error.total',
|
|
||||||
attrs=_runtime_error_metric_attrs('list_active', _error_type(exc)),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
self._metrics.record(
|
|
||||||
'sandbox.runtime.list_active.duration_ms',
|
|
||||||
_duration_ms(started_at),
|
|
||||||
attrs=_runtime_metric_attrs('list_active', result),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _labels(
|
|
||||||
self,
|
|
||||||
session_id: UUID,
|
|
||||||
chat_id: UUID,
|
|
||||||
expires_at: datetime,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
'session_id': str(session_id),
|
|
||||||
'chat_id': str(chat_id),
|
|
||||||
'expires_at': expires_at.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _mounts(
|
|
||||||
self,
|
|
||||||
chat_path: Path,
|
|
||||||
dependencies_path: Path,
|
|
||||||
lambda_tools_path: Path,
|
|
||||||
) -> list[Mount]:
|
|
||||||
return [
|
|
||||||
Mount(
|
|
||||||
target=self._config.chat_mount_path,
|
|
||||||
source=str(chat_path),
|
|
||||||
type='bind',
|
|
||||||
),
|
|
||||||
Mount(
|
|
||||||
target=self._config.dependencies_mount_path,
|
|
||||||
source=str(dependencies_path),
|
|
||||||
type='bind',
|
|
||||||
read_only=True,
|
|
||||||
),
|
|
||||||
Mount(
|
|
||||||
target=self._config.lambda_tools_mount_path,
|
|
||||||
source=str(lambda_tools_path),
|
|
||||||
type='bind',
|
|
||||||
read_only=True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def _chat_path(self, chat_id: UUID) -> Path:
|
|
||||||
chats_root = self._host_path(self._config.chats_root)
|
|
||||||
chat_path = (chats_root / str(chat_id)).resolve(strict=False)
|
|
||||||
if not chat_path.is_relative_to(chats_root):
|
|
||||||
raise ValueError('invalid chat path')
|
|
||||||
return chat_path
|
|
||||||
|
|
||||||
def _readonly_host_path(self, path_value: str) -> Path:
|
|
||||||
host_path = self._host_path(path_value)
|
|
||||||
if not host_path.exists():
|
|
||||||
raise ValueError('invalid host path')
|
|
||||||
return host_path
|
|
||||||
|
|
||||||
def _session_from_container(self, container: object) -> SandboxSession | None:
|
|
||||||
container_id = str(getattr(container, 'id', '')).strip()
|
|
||||||
labels = getattr(container, 'labels', None)
|
|
||||||
if not container_id or not isinstance(labels, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
session_id = UUID(labels['session_id'])
|
|
||||||
chat_id = UUID(labels['chat_id'])
|
|
||||||
created_at = self._container_created_at(container)
|
|
||||||
expires_at = _parse_datetime(labels['expires_at'])
|
|
||||||
except (KeyError, TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return SandboxSession(
|
|
||||||
session_id=session_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
container_id=container_id,
|
|
||||||
status=SandboxStatus.RUNNING,
|
|
||||||
created_at=created_at,
|
|
||||||
expires_at=expires_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _container_created_at(self, container: object) -> datetime:
|
|
||||||
attrs = getattr(container, 'attrs', None)
|
|
||||||
if not isinstance(attrs, dict):
|
|
||||||
reload_container = getattr(container, 'reload', None)
|
|
||||||
if callable(reload_container):
|
|
||||||
reload_container()
|
|
||||||
attrs = getattr(container, 'attrs', None)
|
|
||||||
|
|
||||||
if not isinstance(attrs, dict):
|
|
||||||
raise ValueError('invalid container attrs')
|
|
||||||
|
|
||||||
raw_created_at = attrs.get('Created')
|
|
||||||
if not isinstance(raw_created_at, str):
|
|
||||||
raise ValueError('invalid created_at')
|
|
||||||
|
|
||||||
return _parse_datetime(raw_created_at)
|
|
||||||
|
|
||||||
def _host_path(self, path_value: str) -> Path:
|
|
||||||
return Path(path_value).expanduser().resolve(strict=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_datetime(value: str) -> datetime:
|
|
||||||
normalized = f'{value[:-1]}+00:00' if value.endswith('Z') else value
|
|
||||||
return datetime.fromisoformat(normalized)
|
|
||||||
|
|
||||||
|
|
||||||
def _duration_ms(started_at: float) -> float:
|
|
||||||
return (time.perf_counter() - started_at) * 1000
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_metric_attrs(operation: str, result: str) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
'operation': operation,
|
|
||||||
'result': result,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_error_metric_attrs(
|
|
||||||
operation: str,
|
|
||||||
error_type: str,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
'operation': operation,
|
|
||||||
'error.type': error_type,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _error_type(error: Exception) -> str:
|
|
||||||
if isinstance(error.__cause__, Exception):
|
|
||||||
return type(error.__cause__).__name__
|
|
||||||
return type(error).__name__
|
|
||||||
|
|
||||||
|
|
||||||
def _set_span_container_attrs(span: Span, container: object) -> None:
|
|
||||||
labels = getattr(container, 'labels', None)
|
|
||||||
if not isinstance(labels, dict):
|
|
||||||
return
|
|
||||||
|
|
||||||
session_id = labels.get('session_id')
|
|
||||||
if isinstance(session_id, str) and session_id:
|
|
||||||
span.set_attribute('session.id', session_id)
|
|
||||||
|
|
||||||
chat_id = labels.get('chat_id')
|
|
||||||
if isinstance(chat_id, str) and chat_id:
|
|
||||||
span.set_attribute('chat.id', chat_id)
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import asyncio
|
from collections.abc import Callable
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||||
|
|
||||||
from adapter.config.loader import load_config
|
from adapter.config.loader import load_config
|
||||||
|
|
@ -10,10 +8,9 @@ from adapter.di.container import AppContainer, build_container
|
||||||
from adapter.http.fastapi.dependencies import APP_CONFIG_STATE, APP_CONTAINER_STATE
|
from adapter.http.fastapi.dependencies import APP_CONFIG_STATE, APP_CONTAINER_STATE
|
||||||
from adapter.http.fastapi.middleware import register_middleware
|
from adapter.http.fastapi.middleware import register_middleware
|
||||||
from adapter.http.fastapi.routers.v1.router import router as v1_router
|
from adapter.http.fastapi.routers.v1.router import router as v1_router
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
API_V1_PREFIX = '/api/v1'
|
API_V1_PREFIX = '/api/v1'
|
||||||
APP_CLEANUP_TASK_STATE = 'cleanup_task'
|
|
||||||
APP_CLEANUP_STOP_STATE = 'cleanup_stop'
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(config: AppConfig | None = None) -> FastAPI:
|
def create_app(config: AppConfig | None = None) -> FastAPI:
|
||||||
|
|
@ -25,7 +22,6 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
|
||||||
app = FastAPI(title=app_config.app.name)
|
app = FastAPI(title=app_config.app.name)
|
||||||
setattr(app.state, APP_CONFIG_STATE, app_config)
|
setattr(app.state, APP_CONFIG_STATE, app_config)
|
||||||
setattr(app.state, APP_CONTAINER_STATE, container)
|
setattr(app.state, APP_CONTAINER_STATE, container)
|
||||||
app.add_event_handler('startup', _build_startup_handler(app, container))
|
|
||||||
app.add_event_handler('shutdown', _build_shutdown_handler(app, container))
|
app.add_event_handler('shutdown', _build_shutdown_handler(app, container))
|
||||||
register_middleware(app, app_config)
|
register_middleware(app, app_config)
|
||||||
app.include_router(v1_router, prefix=API_V1_PREFIX)
|
app.include_router(v1_router, prefix=API_V1_PREFIX)
|
||||||
|
|
@ -47,112 +43,19 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _build_startup_handler(
|
|
||||||
app: FastAPI,
|
|
||||||
container: AppContainer,
|
|
||||||
) -> Callable[[], Awaitable[None]]:
|
|
||||||
async def startup() -> None:
|
|
||||||
task = _get_cleanup_task(app)
|
|
||||||
if task is not None and not task.done():
|
|
||||||
return
|
|
||||||
|
|
||||||
await asyncio.to_thread(container.sandbox_reconciler.execute)
|
|
||||||
|
|
||||||
stop_event = asyncio.Event()
|
|
||||||
setattr(app.state, APP_CLEANUP_STOP_STATE, stop_event)
|
|
||||||
setattr(
|
|
||||||
app.state,
|
|
||||||
APP_CLEANUP_TASK_STATE,
|
|
||||||
asyncio.create_task(
|
|
||||||
_run_cleanup_loop(container, stop_event),
|
|
||||||
name='sandbox_cleanup',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return startup
|
|
||||||
|
|
||||||
|
|
||||||
def _build_shutdown_handler(
|
def _build_shutdown_handler(
|
||||||
app: FastAPI,
|
app: FastAPI,
|
||||||
container: AppContainer,
|
container: AppContainer,
|
||||||
) -> Callable[[], Awaitable[None]]:
|
) -> Callable[[], None]:
|
||||||
async def shutdown() -> None:
|
def shutdown() -> None:
|
||||||
errors: list[Exception] = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
await _stop_cleanup_loop(app)
|
|
||||||
except Exception as exc:
|
|
||||||
errors.append(exc)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_uninstrument_app(app)
|
_uninstrument_app(app)
|
||||||
except Exception as exc:
|
finally:
|
||||||
errors.append(exc)
|
|
||||||
|
|
||||||
try:
|
|
||||||
container.shutdown()
|
container.shutdown()
|
||||||
except Exception as exc:
|
|
||||||
errors.append(exc)
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
raise ExceptionGroup('app shutdown failed', errors)
|
|
||||||
|
|
||||||
return shutdown
|
return shutdown
|
||||||
|
|
||||||
|
|
||||||
async def _run_cleanup_loop(
|
|
||||||
container: AppContainer,
|
|
||||||
stop_event: asyncio.Event,
|
|
||||||
) -> None:
|
|
||||||
interval = container.config.sandbox.cleanup_interval_seconds
|
|
||||||
|
|
||||||
while not stop_event.is_set():
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(
|
|
||||||
container.usecases.cleanup_expired_sandboxes.execute
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
container.observability.logger.error(
|
|
||||||
'sandbox_cleanup_failed',
|
|
||||||
attrs={
|
|
||||||
'error': type(exc).__name__,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(stop_event.wait(), timeout=interval)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
async def _stop_cleanup_loop(app: FastAPI) -> None:
|
|
||||||
stop_event = _get_cleanup_stop_event(app)
|
|
||||||
if stop_event is not None:
|
|
||||||
stop_event.set()
|
|
||||||
|
|
||||||
task = _get_cleanup_task(app)
|
|
||||||
try:
|
|
||||||
if task is not None:
|
|
||||||
await task
|
|
||||||
finally:
|
|
||||||
setattr(app.state, APP_CLEANUP_TASK_STATE, None)
|
|
||||||
setattr(app.state, APP_CLEANUP_STOP_STATE, None)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_cleanup_task(app: FastAPI) -> asyncio.Task[None] | None:
|
|
||||||
task = getattr(app.state, APP_CLEANUP_TASK_STATE, None)
|
|
||||||
if isinstance(task, asyncio.Task):
|
|
||||||
return task
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_cleanup_stop_event(app: FastAPI) -> asyncio.Event | None:
|
|
||||||
stop_event = getattr(app.state, APP_CLEANUP_STOP_STATE, None)
|
|
||||||
if isinstance(stop_event, asyncio.Event):
|
|
||||||
return stop_event
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _uninstrument_app(app: FastAPI) -> None:
|
def _uninstrument_app(app: FastAPI) -> None:
|
||||||
if _is_instrumented(app):
|
if _is_instrumented(app):
|
||||||
FastAPIInstrumentor.uninstrument_app(app)
|
FastAPIInstrumentor.uninstrument_app(app)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
from fastapi import Depends, Request
|
from typing import cast
|
||||||
|
|
||||||
from adapter.di.container import AppContainer
|
from adapter.di.container import AppContainer
|
||||||
from usecase.sandbox import CreateSandbox
|
from fastapi import Depends, Request
|
||||||
|
from usecase.user import GetUser
|
||||||
|
|
||||||
APP_CONTAINER_STATE = 'container'
|
APP_CONTAINER_STATE = 'container'
|
||||||
APP_CONFIG_STATE = 'config'
|
APP_CONFIG_STATE = 'config'
|
||||||
|
|
@ -9,12 +10,10 @@ APP_CONFIG_STATE = 'config'
|
||||||
|
|
||||||
def get_container(request: Request) -> AppContainer:
|
def get_container(request: Request) -> AppContainer:
|
||||||
container = getattr(request.app.state, APP_CONTAINER_STATE, None)
|
container = getattr(request.app.state, APP_CONTAINER_STATE, None)
|
||||||
if not isinstance(container, AppContainer):
|
if container is None:
|
||||||
raise RuntimeError('container unavailable')
|
raise RuntimeError('container unavailable')
|
||||||
return container
|
return cast(AppContainer, container)
|
||||||
|
|
||||||
|
|
||||||
def get_create_sandbox(
|
def get_get_user(container: AppContainer = Depends(get_container)) -> GetUser:
|
||||||
container: AppContainer = Depends(get_container),
|
return container.usecases.get_user
|
||||||
) -> CreateSandbox:
|
|
||||||
return container.usecases.create_sandbox
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response
|
|
||||||
|
|
||||||
from adapter.config.model import AppConfig
|
from adapter.config.model import AppConfig
|
||||||
from adapter.http.fastapi.dependencies import get_container
|
from adapter.http.fastapi.dependencies import get_container
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
|
||||||
|
|
||||||
def register_middleware(app: FastAPI, config: AppConfig) -> None:
|
def register_middleware(app: FastAPI, config: AppConfig) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from adapter.di.container import AppContainer
|
from adapter.di.container import AppContainer
|
||||||
from adapter.http.fastapi.dependencies import (
|
from adapter.http.fastapi.dependencies import get_container, get_get_user
|
||||||
get_container,
|
from adapter.http.fastapi.schemas import ErrorResponse, HealthResponse, UserResponse
|
||||||
get_create_sandbox,
|
from domain.error import UserNotFoundError
|
||||||
)
|
from usecase.user import GetUser, GetUserQuery
|
||||||
from adapter.http.fastapi.schemas import (
|
|
||||||
CreateSandboxRequest,
|
|
||||||
ErrorResponse,
|
|
||||||
HealthResponse,
|
|
||||||
SandboxSessionResponse,
|
|
||||||
)
|
|
||||||
from domain.error import SandboxError, SandboxStartError
|
|
||||||
from domain.sandbox import SandboxSession
|
|
||||||
from usecase.sandbox import CreateSandbox, CreateSandboxCommand
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -31,40 +22,19 @@ def health(container: AppContainer = Depends(get_container)) -> HealthResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.get(
|
||||||
'/create',
|
'/users/{user_id}',
|
||||||
response_model=SandboxSessionResponse,
|
response_model=UserResponse,
|
||||||
responses={
|
responses={status.HTTP_404_NOT_FOUND: {'model': ErrorResponse}},
|
||||||
status.HTTP_503_SERVICE_UNAVAILABLE: {'model': ErrorResponse},
|
|
||||||
status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse},
|
|
||||||
},
|
|
||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
def create_sandbox(
|
def get_user(user_id: str, usecase: GetUser = Depends(get_get_user)) -> UserResponse:
|
||||||
request: CreateSandboxRequest,
|
|
||||||
usecase: CreateSandbox = Depends(get_create_sandbox),
|
|
||||||
) -> SandboxSessionResponse:
|
|
||||||
try:
|
try:
|
||||||
session = usecase.execute(CreateSandboxCommand(chat_id=request.chat_id))
|
user = usecase.execute(GetUserQuery(user_id=user_id))
|
||||||
except SandboxStartError as exc:
|
except UserNotFoundError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
except SandboxError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
return _to_sandbox_session_response(session)
|
return UserResponse(id=user.id, email=user.email, name=user.name)
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
from datetime import datetime
|
from pydantic import BaseModel
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
|
|
@ -10,18 +7,10 @@ class HealthResponse(BaseModel):
|
||||||
env: str
|
env: str
|
||||||
|
|
||||||
|
|
||||||
class CreateSandboxRequest(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
model_config = ConfigDict(extra='forbid')
|
id: str
|
||||||
|
email: str
|
||||||
chat_id: UUID
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class SandboxSessionResponse(BaseModel):
|
|
||||||
session_id: UUID
|
|
||||||
chat_id: UUID
|
|
||||||
container_id: str
|
|
||||||
status: str
|
|
||||||
expires_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,6 @@ class NoopMetrics:
|
||||||
) -> None:
|
) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def set(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
value: int | float,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class NoopSpan:
|
class NoopSpan:
|
||||||
def set_attribute(self, name: str, value: AttrValue) -> None:
|
def set_attribute(self, name: str, value: AttrValue) -> None:
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,12 @@ from opentelemetry.metrics import Counter, Histogram, Meter
|
||||||
from usecase.interface import Attrs
|
from usecase.interface import Attrs
|
||||||
|
|
||||||
|
|
||||||
class _GaugeAdapter:
|
|
||||||
def __init__(self, gauge: object) -> None:
|
|
||||||
self._gauge = gauge
|
|
||||||
|
|
||||||
def set(self, value: int | float, attributes: object = None) -> None:
|
|
||||||
getattr(self._gauge, 'set')(value, attributes=attributes)
|
|
||||||
|
|
||||||
|
|
||||||
class OtelMetrics:
|
class OtelMetrics:
|
||||||
def __init__(self, meter: Meter) -> None:
|
def __init__(self, meter: Meter) -> None:
|
||||||
self._meter = meter
|
self._meter = meter
|
||||||
self._lock = Lock()
|
self._lock = Lock()
|
||||||
self._counters: dict[str, Counter] = {}
|
self._counters: dict[str, Counter] = {}
|
||||||
self._histograms: dict[str, Histogram] = {}
|
self._histograms: dict[str, Histogram] = {}
|
||||||
self._gauges: dict[str, _GaugeAdapter] = {}
|
|
||||||
|
|
||||||
def increment(
|
def increment(
|
||||||
self,
|
self,
|
||||||
|
|
@ -43,17 +34,6 @@ class OtelMetrics:
|
||||||
attributes=None if attrs is None else dict(attrs),
|
attributes=None if attrs is None else dict(attrs),
|
||||||
)
|
)
|
||||||
|
|
||||||
def set(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
value: int | float,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._gauge(name).set(
|
|
||||||
value,
|
|
||||||
attributes=None if attrs is None else dict(attrs),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _counter(self, name: str) -> Counter:
|
def _counter(self, name: str) -> Counter:
|
||||||
counter = self._counters.get(name)
|
counter = self._counters.get(name)
|
||||||
if counter is not None:
|
if counter is not None:
|
||||||
|
|
@ -77,15 +57,3 @@ class OtelMetrics:
|
||||||
histogram = self._meter.create_histogram(name)
|
histogram = self._meter.create_histogram(name)
|
||||||
self._histograms[name] = histogram
|
self._histograms[name] = histogram
|
||||||
return histogram
|
return histogram
|
||||||
|
|
||||||
def _gauge(self, name: str) -> _GaugeAdapter:
|
|
||||||
gauge = self._gauges.get(name)
|
|
||||||
if gauge is not None:
|
|
||||||
return gauge
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
gauge = self._gauges.get(name)
|
|
||||||
if gauge is None:
|
|
||||||
gauge = _GaugeAdapter(self._meter.create_gauge(name))
|
|
||||||
self._gauges[name] = gauge
|
|
||||||
return gauge
|
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Protocol
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from domain.sandbox import SandboxSession
|
|
||||||
from usecase.interface import Logger, Metrics, Tracer
|
|
||||||
|
|
||||||
|
|
||||||
class SandboxSessionStateSource(Protocol):
|
|
||||||
def list_active_sessions(self) -> list[SandboxSession]: ...
|
|
||||||
|
|
||||||
|
|
||||||
class SandboxSessionRegistry(Protocol):
|
|
||||||
def replace_all(self, sessions: list[SandboxSession]) -> None: ...
|
|
||||||
|
|
||||||
def count_active(self) -> int: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class SandboxSessionReconciler:
|
|
||||||
state_source: SandboxSessionStateSource
|
|
||||||
registry: SandboxSessionRegistry
|
|
||||||
logger: Logger
|
|
||||||
metrics: Metrics
|
|
||||||
tracer: Tracer
|
|
||||||
|
|
||||||
def execute(self) -> list[SandboxSession]:
|
|
||||||
with self.tracer.start_span(
|
|
||||||
'adapter.sandbox.reconcile_sessions',
|
|
||||||
) as span:
|
|
||||||
try:
|
|
||||||
sessions_by_chat_id: dict[UUID, SandboxSession] = {}
|
|
||||||
discovered_sessions = self.state_source.list_active_sessions()
|
|
||||||
span.set_attribute('sandbox.discovered_count', len(discovered_sessions))
|
|
||||||
for session in sorted(
|
|
||||||
discovered_sessions,
|
|
||||||
key=lambda item: item.created_at,
|
|
||||||
):
|
|
||||||
sessions_by_chat_id[session.chat_id] = session
|
|
||||||
|
|
||||||
sessions = list(sessions_by_chat_id.values())
|
|
||||||
self.registry.replace_all(sessions)
|
|
||||||
active_count = self.registry.count_active()
|
|
||||||
self.metrics.set('sandbox.active.count', active_count)
|
|
||||||
span.set_attribute('sandbox.active_count', active_count)
|
|
||||||
span.set_attribute('sandbox.result', 'reconciled')
|
|
||||||
self.logger.info(
|
|
||||||
'sandbox_reconciled',
|
|
||||||
attrs={
|
|
||||||
'session_count': active_count,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return sessions
|
|
||||||
except Exception as exc:
|
|
||||||
span.set_attribute('sandbox.result', 'error')
|
|
||||||
span.record_error(exc)
|
|
||||||
raise
|
|
||||||
|
|
@ -24,19 +24,5 @@ otel:
|
||||||
traces_endpoint: http://localhost:4318/v1/traces
|
traces_endpoint: http://localhost:4318/v1/traces
|
||||||
metric_export_interval: 1000
|
metric_export_interval: 1000
|
||||||
|
|
||||||
docker:
|
|
||||||
base_url: unix:///var/run/docker.sock
|
|
||||||
|
|
||||||
sandbox:
|
|
||||||
image: ai-agent:latest
|
|
||||||
ttl_seconds: 300
|
|
||||||
cleanup_interval_seconds: 60
|
|
||||||
chats_root: var/sandbox/chats
|
|
||||||
dependencies_host_path: var/sandbox/dependencies
|
|
||||||
lambda_tools_host_path: var/sandbox/lambda-tools
|
|
||||||
chat_mount_path: /workspace/chat
|
|
||||||
dependencies_mount_path: /opt/dependencies
|
|
||||||
lambda_tools_mount_path: /opt/lambda-tools
|
|
||||||
|
|
||||||
security:
|
security:
|
||||||
token_header: X-API-Token
|
token_header: X-API-Token
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
app:
|
|
||||||
name: master-service
|
|
||||||
env: docker-compose
|
|
||||||
|
|
||||||
http:
|
|
||||||
host: 0.0.0.0
|
|
||||||
port: 8123
|
|
||||||
|
|
||||||
logging:
|
|
||||||
level: INFO
|
|
||||||
output: otel
|
|
||||||
format: json
|
|
||||||
|
|
||||||
metrics:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
tracing:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
otel:
|
|
||||||
service_name: master-service
|
|
||||||
logs_endpoint: http://otel-collector:4318/v1/logs
|
|
||||||
metrics_endpoint: http://otel-collector:4318/v1/metrics
|
|
||||||
traces_endpoint: http://otel-collector:4318/v1/traces
|
|
||||||
metric_export_interval: 1000
|
|
||||||
|
|
||||||
docker:
|
|
||||||
base_url: tcp://docker-engine:2375
|
|
||||||
|
|
||||||
sandbox:
|
|
||||||
image: nginx:1.27-alpine
|
|
||||||
ttl_seconds: 300
|
|
||||||
cleanup_interval_seconds: 60
|
|
||||||
chats_root: /var/lib/master-sandbox/chats
|
|
||||||
dependencies_host_path: /var/lib/master-dependencies
|
|
||||||
lambda_tools_host_path: /var/lib/master-lambda-tools
|
|
||||||
chat_mount_path: /workspace/chat
|
|
||||||
dependencies_mount_path: /opt/dependencies
|
|
||||||
lambda_tools_mount_path: /opt/lambda-tools
|
|
||||||
|
|
||||||
security:
|
|
||||||
token_header: X-API-Token
|
|
||||||
|
|
@ -4,43 +4,22 @@ services:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: run
|
target: run
|
||||||
user: root
|
|
||||||
depends_on:
|
depends_on:
|
||||||
docker-engine:
|
- otel-collector
|
||||||
condition: service_healthy
|
|
||||||
otel-collector:
|
|
||||||
condition: service_started
|
|
||||||
environment:
|
environment:
|
||||||
APP_API_TOKEN: local-api-token
|
APP_API_TOKEN: ${APP_API_TOKEN:?APP_API_TOKEN is required}
|
||||||
APP_SIGNING_KEY: local-signing-key
|
APP_SIGNING_KEY: ${APP_SIGNING_KEY:?APP_SIGNING_KEY is required}
|
||||||
|
APP_ENV: docker
|
||||||
|
APP_HTTP_HOST: 0.0.0.0
|
||||||
|
APP_HTTP_PORT: '8123'
|
||||||
|
APP_LOGGING_OUTPUT: otel
|
||||||
|
APP_METRICS_ENABLED: 'true'
|
||||||
|
APP_TRACING_ENABLED: 'true'
|
||||||
|
APP_OTEL_LOGS_ENDPOINT: http://otel-collector:4318/v1/logs
|
||||||
|
APP_OTEL_METRICS_ENDPOINT: http://otel-collector:4318/v1/metrics
|
||||||
|
APP_OTEL_TRACES_ENDPOINT: http://otel-collector:4318/v1/traces
|
||||||
ports:
|
ports:
|
||||||
- '127.0.0.1:8123:8123'
|
- '127.0.0.1:8123:8123'
|
||||||
volumes:
|
|
||||||
- ./config/docker-compose.yml:/app/config/app.yaml:ro
|
|
||||||
- sandbox-data:/var/lib/master-sandbox
|
|
||||||
- sandbox-dependencies:/var/lib/master-dependencies:ro
|
|
||||||
- sandbox-tools:/var/lib/master-lambda-tools:ro
|
|
||||||
|
|
||||||
docker-engine:
|
|
||||||
image: docker:28-dind
|
|
||||||
privileged: true
|
|
||||||
environment:
|
|
||||||
DOCKER_TLS_CERTDIR: ''
|
|
||||||
command:
|
|
||||||
- --host=tcp://0.0.0.0:2375
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD
|
|
||||||
- docker
|
|
||||||
- info
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 12
|
|
||||||
volumes:
|
|
||||||
- docker-data:/var/lib/docker
|
|
||||||
- sandbox-data:/var/lib/master-sandbox
|
|
||||||
- sandbox-dependencies:/var/lib/master-dependencies
|
|
||||||
- sandbox-tools:/var/lib/master-lambda-tools
|
|
||||||
|
|
||||||
otel-collector:
|
otel-collector:
|
||||||
image: grafana/otel-lgtm:latest
|
image: grafana/otel-lgtm:latest
|
||||||
|
|
@ -50,8 +29,4 @@ services:
|
||||||
- lgtm-data:/data
|
- lgtm-data:/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
docker-data:
|
|
||||||
lgtm-data:
|
lgtm-data:
|
||||||
sandbox-data:
|
|
||||||
sandbox-dependencies:
|
|
||||||
sandbox-tools:
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# 007 Startup Sandbox Reconciliation
|
|
||||||
|
|
||||||
Context
|
|
||||||
- Active sandboxes outlive the process because Docker keeps containers running across master-service restarts.
|
|
||||||
- The in-memory session repository is rebuilt on each start and otherwise loses running sandbox state.
|
|
||||||
|
|
||||||
Decision
|
|
||||||
- Reconcile sandbox state during app startup before the cleanup loop starts serving requests.
|
|
||||||
- Read running Docker containers through sandbox labels `session_id`, `chat_id`, and `expires_at`.
|
|
||||||
- Rebuild the in-memory registry from the reconciled sessions and prefer the newest session per `chat_id`.
|
|
||||||
- Let the normal cleanup flow handle reconciled sessions that are already expired.
|
|
||||||
- Do not stop healthy sandbox containers during service shutdown; shutdown only stops background control-plane work and closes local resources.
|
|
||||||
|
|
||||||
Consequences
|
|
||||||
- A restarted master-service reuses existing sandboxes instead of starting duplicates for the same chat.
|
|
||||||
- Startup now depends on Docker state access and should fail fast if runtime state cannot be listed.
|
|
||||||
- The reconciliation rule stays local to outer layers and does not leak Docker into usecases.
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# 008 Sandbox lifecycle observability
|
|
||||||
|
|
||||||
## Context
|
|
||||||
- FR-034 требует метрики по active sandbox, startup latency и cleanup
|
|
||||||
- Issue #11 требует трассировку sandbox usecase и Docker adapter steps
|
|
||||||
- Inner layers должны знать только observability ports
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
- Usecase sandbox lifecycle использует только `Logger`, `Metrics`, `Tracer`
|
|
||||||
- `Metrics` получает `set(...)` для current-state signals
|
|
||||||
- `sandbox.active.count` считается из session registry через `count_active()`
|
|
||||||
- M19 добавляет только contracts и adapter support для будущих lifecycle signals
|
|
||||||
- M20 и M21 отдельно добавят spans и runtime metrics в usecase и Docker adapter
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
- OTel gauge остается в outer adapter, не протекает во внутренние слои
|
|
||||||
- Active sandbox count синхронизируется после create, cleanup и reconciliation
|
|
||||||
- Tests могут проверять observability через fake ports без реального OTel backend
|
|
||||||
|
|
@ -16,19 +16,3 @@ class UserConflictError(UserError):
|
||||||
def __init__(self, email: str) -> None:
|
def __init__(self, email: str) -> None:
|
||||||
super().__init__('user_conflict')
|
super().__init__('user_conflict')
|
||||||
self.email = email
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
|
|
||||||
class SandboxStatus(str, Enum):
|
|
||||||
STARTING = 'starting'
|
|
||||||
RUNNING = 'running'
|
|
||||||
STOPPING = 'stopping'
|
|
||||||
STOPPED = 'stopped'
|
|
||||||
FAILED = 'failed'
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class SandboxSession:
|
|
||||||
session_id: UUID
|
|
||||||
chat_id: UUID
|
|
||||||
container_id: str
|
|
||||||
status: SandboxStatus
|
|
||||||
created_at: datetime
|
|
||||||
expires_at: datetime
|
|
||||||
|
|
@ -5,7 +5,6 @@ description = ""
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"docker>=7.1.0",
|
|
||||||
"fastapi>=0.116.1",
|
"fastapi>=0.116.1",
|
||||||
"opentelemetry-api>=1.31.1",
|
"opentelemetry-api>=1.31.1",
|
||||||
"opentelemetry-exporter-otlp-proto-http>=1.31.1",
|
"opentelemetry-exporter-otlp-proto-http>=1.31.1",
|
||||||
|
|
@ -22,7 +21,6 @@ dev = [
|
||||||
"mypy>=1.18.2",
|
"mypy>=1.18.2",
|
||||||
"pytest>=8.4.2",
|
"pytest>=8.4.2",
|
||||||
"ruff>=0.13.1",
|
"ruff>=0.13.1",
|
||||||
"types-docker>=7.1.0.20260402",
|
|
||||||
"types-pyyaml>=6.0.12.20250915",
|
"types-pyyaml>=6.0.12.20250915",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
import threading
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from types import TracebackType
|
|
||||||
from typing import Protocol
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from usecase.interface import LockContext, SandboxLifecycleLocker
|
|
||||||
|
|
||||||
|
|
||||||
class _SyncLock(Protocol):
|
|
||||||
def acquire(self, blocking: bool = True, timeout: float = -1) -> bool: ...
|
|
||||||
|
|
||||||
def release(self) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class _LockEntry:
|
|
||||||
lock: _SyncLock
|
|
||||||
users: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class _ChatLock(LockContext):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
locker: 'ProcessLocalSandboxLifecycleLocker',
|
|
||||||
chat_id: UUID,
|
|
||||||
entry: _LockEntry,
|
|
||||||
) -> None:
|
|
||||||
self._locker = locker
|
|
||||||
self._chat_id = chat_id
|
|
||||||
self._entry = entry
|
|
||||||
|
|
||||||
def __enter__(self) -> None:
|
|
||||||
self._entry.lock.acquire()
|
|
||||||
|
|
||||||
def __exit__(
|
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
exc: BaseException | None,
|
|
||||||
traceback: TracebackType | None,
|
|
||||||
) -> bool | None:
|
|
||||||
self._entry.lock.release()
|
|
||||||
self._locker._release(self._chat_id, self._entry)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessLocalSandboxLifecycleLocker(SandboxLifecycleLocker):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._registry_lock = threading.Lock()
|
|
||||||
self._locks_by_chat_id: dict[UUID, _LockEntry] = {}
|
|
||||||
|
|
||||||
def lock(self, chat_id: UUID) -> LockContext:
|
|
||||||
with self._registry_lock:
|
|
||||||
entry = self._locks_by_chat_id.get(chat_id)
|
|
||||||
if entry is None:
|
|
||||||
entry = _LockEntry(lock=threading.Lock())
|
|
||||||
self._locks_by_chat_id[chat_id] = entry
|
|
||||||
entry.users += 1
|
|
||||||
|
|
||||||
return _ChatLock(self, chat_id, entry)
|
|
||||||
|
|
||||||
def _release(self, chat_id: UUID, entry: _LockEntry) -> None:
|
|
||||||
with self._registry_lock:
|
|
||||||
entry.users -= 1
|
|
||||||
if entry.users != 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
current_entry = self._locks_by_chat_id.get(chat_id)
|
|
||||||
if current_entry is entry:
|
|
||||||
del self._locks_by_chat_id[chat_id]
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import threading
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from domain.sandbox import SandboxSession
|
|
||||||
from usecase.interface import SandboxSessionRepository
|
|
||||||
|
|
||||||
|
|
||||||
class InMemorySandboxSessionRepository(SandboxSessionRepository):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._sessions_by_chat_id: dict[UUID, SandboxSession] = {}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def replace_all(self, sessions: list[SandboxSession]) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._sessions_by_chat_id = {
|
|
||||||
session.chat_id: session for session in sessions
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_active_by_chat_id(self, chat_id: UUID) -> SandboxSession | None:
|
|
||||||
with self._lock:
|
|
||||||
return self._sessions_by_chat_id.get(chat_id)
|
|
||||||
|
|
||||||
def list_expired(self, now: datetime) -> list[SandboxSession]:
|
|
||||||
with self._lock:
|
|
||||||
return [
|
|
||||||
session
|
|
||||||
for session in self._sessions_by_chat_id.values()
|
|
||||||
if session.expires_at <= now
|
|
||||||
]
|
|
||||||
|
|
||||||
def count_active(self) -> int:
|
|
||||||
with self._lock:
|
|
||||||
return len(self._sessions_by_chat_id)
|
|
||||||
|
|
||||||
def save(self, session: SandboxSession) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._sessions_by_chat_id[session.chat_id] = session
|
|
||||||
|
|
||||||
def delete(self, session_id: UUID) -> None:
|
|
||||||
with self._lock:
|
|
||||||
for chat_id, session in tuple(self._sessions_by_chat_id.items()):
|
|
||||||
if session.session_id == session_id:
|
|
||||||
del self._sessions_by_chat_id[chat_id]
|
|
||||||
return
|
|
||||||
261
tasks.md
261
tasks.md
|
|
@ -32,7 +32,7 @@
|
||||||
### M01. ADR и минимальный sandbox scaffolding
|
### M01. ADR и минимальный sandbox scaffolding
|
||||||
|
|
||||||
- Исполнитель: `primary-agent`
|
- Исполнитель: `primary-agent`
|
||||||
- Статус: completed
|
- Статус: pending
|
||||||
- Зависимости: нет
|
- Зависимости: нет
|
||||||
- Commit required: no
|
- Commit required: no
|
||||||
- Scope: зафиксировать MVP-решение в ADR и создать минимальные сущности, ошибки и usecase-контракты для sandbox orchestration
|
- Scope: зафиксировать MVP-решение в ADR и создать минимальные сущности, ошибки и usecase-контракты для sandbox orchestration
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
### M02. Typed config для sandbox runtime
|
### M02. Typed config для sandbox runtime
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
- Субагент: `feature-developer`
|
||||||
- Статус: completed
|
- Статус: pending
|
||||||
- Зависимости: `M01`
|
- Зависимости: `M01`
|
||||||
- Commit required: no
|
- Commit required: no
|
||||||
- Scope: расширить typed-config слоем `sandbox` с настройками image, TTL, cleanup interval, host paths и container mount paths
|
- Scope: расширить typed-config слоем `sandbox` с настройками image, TTL, cleanup interval, host paths и container mount paths
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
### M03. Docker runtime adapter для sandbox lifecycle
|
### M03. Docker runtime adapter для sandbox lifecycle
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
- Субагент: `feature-developer`
|
||||||
- Статус: completed
|
- Статус: pending
|
||||||
- Зависимости: `M01`, `M02`
|
- Зависимости: `M01`, `M02`
|
||||||
- Commit required: no
|
- Commit required: no
|
||||||
- Scope: реализовать outer adapter над Docker для создания и остановки sandbox контейнера с нужными labels и mount policy
|
- Scope: реализовать outer adapter над Docker для создания и остановки sandbox контейнера с нужными labels и mount policy
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
### M04. In-memory session repository и usecase `CreateSandbox`
|
### M04. In-memory session repository и usecase `CreateSandbox`
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
- Субагент: `feature-developer`
|
||||||
- Статус: completed
|
- Статус: pending
|
||||||
- Зависимости: `M01`, `M02`, `M03`
|
- Зависимости: `M01`, `M02`, `M03`
|
||||||
- Commit required: no
|
- Commit required: no
|
||||||
- Scope: реализовать in-memory registry активных sandbox-сессий и usecase создания sandbox с логикой reuse по `chat_id`
|
- Scope: реализовать in-memory registry активных sandbox-сессий и usecase создания sandbox с логикой reuse по `chat_id`
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
### M05. Cleanup expired sandboxes и lifecycle wiring
|
### M05. Cleanup expired sandboxes и lifecycle wiring
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
- Субагент: `feature-developer`
|
||||||
- Статус: completed
|
- Статус: pending
|
||||||
- Зависимости: `M04`
|
- Зависимости: `M04`
|
||||||
- Commit required: no
|
- Commit required: no
|
||||||
- Scope: реализовать usecase cleanup просроченных sandbox и подключить периодический cleanup loop в FastAPI lifecycle
|
- Scope: реализовать usecase cleanup просроченных sandbox и подключить периодический cleanup loop в FastAPI lifecycle
|
||||||
|
|
@ -86,7 +86,7 @@
|
||||||
### M06. HTTP endpoint `POST /api/v1/create`
|
### M06. HTTP endpoint `POST /api/v1/create`
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
- Субагент: `feature-developer`
|
||||||
- Статус: completed
|
- Статус: pending
|
||||||
- Зависимости: `M04`
|
- Зависимости: `M04`
|
||||||
- Commit required: no
|
- Commit required: no
|
||||||
- Scope: добавить минимальную HTTP ручку для создания или переиспользования sandbox без auth
|
- Scope: добавить минимальную HTTP ручку для создания или переиспользования sandbox без auth
|
||||||
|
|
@ -98,7 +98,7 @@
|
||||||
### M07. Тесты для create, reuse, TTL и mount policy
|
### M07. Тесты для create, reuse, TTL и mount policy
|
||||||
|
|
||||||
- Субагент: `test-engineer`
|
- Субагент: `test-engineer`
|
||||||
- Статус: completed
|
- Статус: pending
|
||||||
- Зависимости: `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,254 +108,9 @@
|
||||||
### M08. Архитектурный и boundary review по MVP sandbox
|
### M08. Архитектурный и boundary review по MVP sandbox
|
||||||
|
|
||||||
- Субагент: `code-reviewer`
|
- Субагент: `code-reviewer`
|
||||||
- Статус: completed
|
- Статус: pending
|
||||||
- Зависимости: `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`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `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`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `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`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `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`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `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`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `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
|
|
||||||
|
|
||||||
## Follow-up после M13 review
|
|
||||||
|
|
||||||
### M15. Канонизация и валидация `chat_id`
|
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M13`
|
|
||||||
- Commit required: no
|
|
||||||
- Scope: сделать `chat_id` строго UUID и убрать path alias/whole-root mount риск через неканоничные значения
|
|
||||||
- Файлы: `adapter/http/fastapi/schemas.py`, `adapter/docker/runtime.py`, при необходимости `usecase/sandbox.py` и тесты в `test/*`
|
|
||||||
- Решение: принять `chat_id` как UUID на HTTP boundary, использовать его каноничную строковую форму дальше в usecase/repository/path construction и не принимать произвольные path-like строки
|
|
||||||
- Критерии приемки: не-UUID значения отклоняются на HTTP boundary с `400/422`; UUID используется как единое каноничное значение для lock key, repository key и filesystem path; появляются регрессионные тесты на invalid `chat_id`
|
|
||||||
|
|
||||||
### M16. Lifecycle reconciliation на startup/shutdown
|
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M13`
|
|
||||||
- Commit required: no
|
|
||||||
- Scope: устранить restart-gap между in-memory registry и уже запущенными Docker containers
|
|
||||||
- Файлы: `adapter/docker/runtime.py`, `adapter/di/container.py`, `adapter/http/fastapi/app.py`, при необходимости новые outer-layer helper files и тесты в `test/*`
|
|
||||||
- Решение: основная стратегия — reconciliation по Docker labels на startup, чтобы после restart master-service продолжал видеть уже запущенные sandbox и не поднимал дубликаты; graceful shutdown cleanup остается опциональным дополнением
|
|
||||||
- Критерии приемки: после restart master-service может восстановить/синхронизировать state по Docker labels без потери работающих agent containers; one-sandbox-per-chat не нарушается из-за пустого in-memory registry; lifecycle policy явно зафиксирована и покрыта тестами
|
|
||||||
|
|
||||||
### M17. Управление жизненным циклом per-chat locks
|
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M13`
|
|
||||||
- Commit required: no
|
|
||||||
- Scope: ограничить неограниченный рост registry locks по числу когда-либо увиденных `chat_id`
|
|
||||||
- Файлы: `repository/sandbox_lock.py`, при необходимости тесты в `test/*`
|
|
||||||
- Решение: добавить eviction/ref-count/weakref policy во внешнем lock registry без нарушения сериализации lifecycle для активного `chat_id`
|
|
||||||
- Критерии приемки: registry locks не растет бесконечно без причины; сериализация для активных чатов сохраняется; поведение покрыто тестами
|
|
||||||
|
|
||||||
### M18. Перевести sandbox ids на UUID types
|
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M15`
|
|
||||||
- Commit required: no
|
|
||||||
- Scope: сделать `chat_id` и `session_id` типом `UUID` внутри sandbox scope, оставив `container_id` строкой как внешний Docker identifier
|
|
||||||
- Файлы: `domain/sandbox.py`, `usecase/interface.py`, `usecase/sandbox.py`, `repository/sandbox_session.py`, `adapter/http/fastapi/*`, `adapter/docker/runtime.py`, `adapter/di/container.py`, `test/*`
|
|
||||||
- Решение: HTTP boundary принимает/возвращает UUID, usecase и repository работают с UUID objects, Docker labels продолжают сериализоваться в строки через `str(uuid)`
|
|
||||||
- Критерии приемки: внутри sandbox flow `chat_id` и `session_id` больше не строки; `container_id` остается `str`; pydantic корректно сериализует UUID в response; `make pre-commit` проходит
|
|
||||||
|
|
||||||
## Follow-up после issue #11 observability
|
|
||||||
|
|
||||||
### M19. ADR и observability contracts для sandbox lifecycle
|
|
||||||
|
|
||||||
- Исполнитель: `primary-agent`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M18`
|
|
||||||
- Commit required: yes
|
|
||||||
- Commit message: `add sandbox observability contracts`
|
|
||||||
- Scope: зафиксировать sandbox lifecycle observability policy в ADR-lite и подготовить минимальные контракты для traces и current-state metrics без нарушения clean architecture
|
|
||||||
- Файлы: `docs/008-sandbox-lifecycle-observability.md`, `usecase/interface.py`, `repository/sandbox_session.py`, `adapter/otel/metrics.py`, `adapter/observability/noop.py`
|
|
||||||
- Решение: добавить в `Metrics` порт операцию `set(...)` для gauge-like current-state сигналов; добавить в `SandboxSessionRepository` `count_active()` как источник truth для `sandbox.active.count`
|
|
||||||
- Критерии приемки: ADR занимает 10-20 строк; inner layers по-прежнему знают только порты `Logger`/`Metrics`/`Tracer`; current-state метрика активных sandbox выражается без OTel imports во внутреннем слое
|
|
||||||
|
|
||||||
### M20. Трейсы и метрики в sandbox usecases
|
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M19`
|
|
||||||
- Commit required: yes
|
|
||||||
- Commit message: `instrument sandbox usecases`
|
|
||||||
- Scope: добавить spans и ключевые lifecycle metrics в `CreateSandbox` и `CleanupExpiredSandboxes`
|
|
||||||
- Файлы: `usecase/sandbox.py`, `adapter/di/container.py`, при необходимости тесты в `test/*`
|
|
||||||
- Решение: usecase получает `Metrics` и `Tracer` через конструктор; `CreateSandbox` и `CleanupExpiredSandboxes` публикуют `sandbox.create.total`, `sandbox.cleanup.total`, `sandbox.cleanup.error.total` и обновляют `sandbox.active.count` после мутаций registry
|
|
||||||
- Критерии приемки: есть spans `usecase.create_sandbox` и `usecase.cleanup_expired_sandboxes`; span attrs и metric attrs включают ключевые lifecycle identifiers/result fields; reuse/replace/cleanup paths наблюдаемы без OTel imports в usecase
|
|
||||||
|
|
||||||
### M21. Трейсы и runtime metrics в Docker adapter и reconciliation
|
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M19`
|
|
||||||
- Commit required: yes
|
|
||||||
- Commit message: `instrument sandbox docker runtime`
|
|
||||||
- Scope: добавить observability в `DockerSandboxRuntime` и reconciliation path для Docker operations и current-state sync
|
|
||||||
- Файлы: `adapter/docker/runtime.py`, `adapter/sandbox/reconciliation.py`, `adapter/di/container.py`, при необходимости тесты в `test/*`
|
|
||||||
- Решение: `DockerSandboxRuntime` получает `Metrics` и `Tracer`; create/stop/list публикуют duration histograms `sandbox.runtime.create.duration_ms`, `sandbox.runtime.stop.duration_ms`, `sandbox.runtime.list_active.duration_ms`, error counter `sandbox.runtime.error.total` и span attrs по chat/session/container; reconciliation обновляет `sandbox.active.count` по registry snapshot
|
|
||||||
- Критерии приемки: Docker adapter остается во внешнем слое; ошибки Docker операций отражаются в spans и metrics; после startup reconciliation current-state метрика активных sandbox синхронизирована с registry
|
|
||||||
|
|
||||||
### M22. Тесты на sandbox observability
|
|
||||||
|
|
||||||
- Субагент: `test-engineer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M20`, `M21`
|
|
||||||
- Commit required: yes
|
|
||||||
- Commit message: `add sandbox observability tests`
|
|
||||||
- Scope: покрыть regression tests новую observability policy без реального OTel backend
|
|
||||||
- Файлы: `test/test_sandbox_usecase.py`, `test/test_docker_runtime.py`, при необходимости новые focused tests в `test/*`
|
|
||||||
- Решение: использовать типизированные fake metrics/tracer implementations и проверить names/attrs ключевых spans и metrics на create/reuse/replace/cleanup/runtime paths
|
|
||||||
- Критерии приемки: тесты подтверждают spans и metrics на usecase и adapter paths; constructor wiring обновлен без mypy regressions; `make typecheck` и релевантный `pytest` проходят
|
|
||||||
|
|
||||||
### M23. Boundary review для sandbox observability
|
|
||||||
|
|
||||||
- Субагент: `code-reviewer`
|
|
||||||
- Статус: in_progress
|
|
||||||
- Зависимости: `M22`
|
|
||||||
- Commit required: no
|
|
||||||
- Scope: проверить, что observability изменения закрывают issue #11 и FR-034 без нарушения clean architecture
|
|
||||||
- Файлы: весь измененный код после `M19`-`M22`
|
|
||||||
- Критерии приемки: inner layers не импортируют OTel; Docker-specific tracing остается в `adapter/docker/`; current-state и duration metrics достаточно покрывают sandbox lifecycle; замечания сведены к minor или отсутствуют
|
|
||||||
|
|
||||||
## Follow-up после M23 boundary review
|
|
||||||
|
|
||||||
### M24. Исправить replace trace identity в CreateSandbox
|
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M23`
|
|
||||||
- Commit required: yes
|
|
||||||
- Commit message: `fix sandbox replace trace identity`
|
|
||||||
- Scope: устранить смешение old/new sandbox identifiers в replace path usecase tracing
|
|
||||||
- Файлы: `usecase/sandbox.py`, при необходимости точечные тесты в `test/*`
|
|
||||||
- Решение: сохранять старые и новые sandbox identifiers в отдельных span attrs или child spans так, чтобы replace success и replace failure оставались однозначно трассируемыми
|
|
||||||
- Критерии приемки: replace path не перетирает previous/new identifiers; при replace failure span остается консистентным и отражает обе стороны lifecycle
|
|
||||||
|
|
||||||
### M25. Добрать failure-path observability regression tests
|
|
||||||
|
|
||||||
- Субагент: `test-engineer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M24`
|
|
||||||
- Commit required: yes
|
|
||||||
- Commit message: `add sandbox observability failure tests`
|
|
||||||
- Scope: покрыть tests для replace-failure trace, cleanup error metrics/spans и Docker stop observability
|
|
||||||
- Файлы: `test/test_sandbox_usecase.py`, `test/test_docker_runtime.py`, при необходимости другие focused tests в `test/*`
|
|
||||||
- Решение: использовать presence-based assertions и проверять ключевые span/metric contracts без brittle exact-order checks
|
|
||||||
- Критерии приемки: есть тест на replace failure tracing; есть тест на `sandbox.cleanup.error.total`; есть тесты на Docker stop observability для success/error/not_found или эквивалентного набора outcome paths
|
|
||||||
|
|
||||||
### M26. Повторный boundary review для sandbox observability
|
|
||||||
|
|
||||||
- Субагент: `code-reviewer`
|
|
||||||
- Статус: in_progress
|
|
||||||
- Зависимости: `M25`
|
|
||||||
- Commit required: no
|
|
||||||
- Scope: подтвердить, что follow-up fixes закрыли M23 замечания без новых boundary нарушений
|
|
||||||
- Файлы: весь измененный код после `M24`-`M25`
|
|
||||||
- Критерии приемки: нет замечаний по replace tracing identity и missing failure-path observability coverage; clean architecture по-прежнему соблюдена
|
|
||||||
|
|
||||||
## Follow-up после M26 boundary review
|
|
||||||
|
|
||||||
### M27. Компенсация save failure после runtime.create
|
|
||||||
|
|
||||||
- Субагент: `feature-developer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M26`
|
|
||||||
- Commit required: yes
|
|
||||||
- Commit message: `fix sandbox create rollback gap`
|
|
||||||
- Scope: не оставлять untracked running container и неконсистентный `sandbox.active.count` при падении `repository.save()` после успешного `runtime.create()`
|
|
||||||
- Файлы: `usecase/sandbox.py`, при необходимости точечные тесты в `test/*`
|
|
||||||
- Решение: сделать create/replace path registry-safe через rollback или другой явный compensation path без нарушения clean architecture
|
|
||||||
- Критерии приемки: save failure не оставляет новый container в runtime без registry state; `sandbox.active.count` отражает финальное committed state; replace и fresh-create failure paths консистентны
|
|
||||||
|
|
||||||
### M28. Регрессии на rollback и startup failure observability
|
|
||||||
|
|
||||||
- Субагент: `test-engineer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M27`
|
|
||||||
- Commit required: yes
|
|
||||||
- Commit message: `add sandbox rollback regression tests`
|
|
||||||
- Scope: покрыть tests для save-failure rollback и startup observability failure paths
|
|
||||||
- Файлы: `test/test_sandbox_usecase.py`, `test/test_docker_runtime.py`, `test/test_create_http.py`, при необходимости другие focused tests в `test/*`
|
|
||||||
- Решение: добавить tests на fresh-create/replace save failure compensation, `list_active` failure observability и reconciliation failure span/metric expectations где применимо
|
|
||||||
- Критерии приемки: rollback path покрыт; list/reconciliation failure observability не регрессирует; tests остаются presence-based и стабильными
|
|
||||||
|
|
||||||
### M29. Финальный boundary review для sandbox observability
|
|
||||||
|
|
||||||
- Субагент: `code-reviewer`
|
|
||||||
- Статус: completed
|
|
||||||
- Зависимости: `M28`
|
|
||||||
- Commit required: no
|
|
||||||
- Scope: подтвердить, что M27-M28 закрыли remaining M26 замечания
|
|
||||||
- Файлы: весь измененный код после `M27`-`M28`
|
|
||||||
- Критерии приемки: нет замечаний по rollback gap и startup failure observability coverage; sandbox observability slice приемлем as-is
|
|
||||||
|
|
|
||||||
279
tasks/roadmap.md
279
tasks/roadmap.md
|
|
@ -1,279 +0,0 @@
|
||||||
# Roadmap: workspace/chat/files + artifacts
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
Следующий продуктовый приоритет для `master-service`:
|
|
||||||
1. управление `workspace` / `chat` / `chat files`
|
|
||||||
2. хранение и выдача `artifacts`
|
|
||||||
|
|
||||||
Идея этапа: превратить текущий sandbox MVP в storage-centric control plane, где sandbox работает поверх явных пользовательских данных, а не только поверх `chat_id`.
|
|
||||||
|
|
||||||
## Что считаем в scope этого roadmap
|
|
||||||
|
|
||||||
- workspace metadata
|
|
||||||
- chat metadata и chat directories
|
|
||||||
- `history.md` рядом с чатом
|
|
||||||
- upload/list/download/delete/clear для chat files
|
|
||||||
- quota / usage accounting
|
|
||||||
- artifact metadata
|
|
||||||
- object storage adapter для artifacts
|
|
||||||
- delivery / mark-for-delete flow для artifacts
|
|
||||||
- интеграция storage model с текущим sandbox lifecycle
|
|
||||||
|
|
||||||
## Что пока вне scope
|
|
||||||
|
|
||||||
- полноценный auth/access control
|
|
||||||
- access lease и WebSocket handoff
|
|
||||||
- multi-node orchestration как отдельный эпик
|
|
||||||
- full retention engine для workspace/account lifecycle
|
|
||||||
- внешние messenger integrations
|
|
||||||
|
|
||||||
## Зафиксированные продуктовые решения
|
|
||||||
|
|
||||||
- trusted caller передает `user_id: UUID`
|
|
||||||
- в v1 один `Workspace` на одного `User`
|
|
||||||
- metadata adapters пока делаем `in-memory`
|
|
||||||
- chat history в v1 хранится в `history.md`
|
|
||||||
- chat files отдаем через master: metadata API + download endpoint
|
|
||||||
- soft quota 10 GB блокирует quota-relevant write-path операции, но не делает hard reservation
|
|
||||||
- chat create сам по себе не блокируется quota check, пока не появляется значимый file/artifact payload
|
|
||||||
- delete в v1 — hard delete; chat delete запрещен при active sandbox
|
|
||||||
- artifact delivery подтверждается явным ack
|
|
||||||
- artifact blob отдается через presigned URL, metadata — через master
|
|
||||||
- object storage layout: один bucket на environment + prefixes
|
|
||||||
- artifact states: `created`, `stored`, `delivered`, `delivery_failed`, `marked_for_delete`, `deleted`
|
|
||||||
- artifact retention: после ack -> `marked_for_delete` -> async cleanup; без ack действует отдельный TTL
|
|
||||||
|
|
||||||
## Принципы выполнения
|
|
||||||
|
|
||||||
- Clean Architecture и dependency direction сохраняются
|
|
||||||
- filesystem и object storage живут только во внешних adapter/repository слоях
|
|
||||||
- `domain/` и `usecase/` не знают про FastAPI, Docker, OpenTelemetry, env
|
|
||||||
- один delivery slice = одна атомарная задача = один commit
|
|
||||||
- архитектурные развилки сначала фиксируются кратким ADR-lite в `docs/`
|
|
||||||
|
|
||||||
## Легенда исполнителей
|
|
||||||
|
|
||||||
- `primary-agent` — архитектура, ADR, domain/contracts, финальная сборка решений
|
|
||||||
- `junior` — простые in-memory adapters и базовые unit-level задачи
|
|
||||||
- `junior+opus` — usecase/adapter/API slices со средним уровнем связности
|
|
||||||
- `test-engineer` — regression и integration test packs
|
|
||||||
- `code-reviewer` — review-only этапы
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0. Design checkpoints
|
|
||||||
|
|
||||||
### R00. ADR: storage source of truth
|
|
||||||
- **Рекомендуемый исполнитель:** `primary-agent`
|
|
||||||
- **Зачем:** определить, что является source of truth для `Workspace`, `Chat`, `ChatFile`, `Artifact`
|
|
||||||
- **Нужно решить:** как устроен in-memory-first этап, как metadata связаны с filesystem paths, как sandbox получает path текущего chat
|
|
||||||
- **Выход:** ADR-lite + список сущностей и ownership boundaries
|
|
||||||
|
|
||||||
### R01. ADR: artifact lifecycle
|
|
||||||
- **Рекомендуемый исполнитель:** `primary-agent`
|
|
||||||
- **Зачем:** зафиксировать states и delivery contract для artifact flow
|
|
||||||
- **Нужно решить:** `created -> stored -> delivered -> delivery_failed -> marked_for_delete -> deleted`
|
|
||||||
- **Выход:** ADR-lite + минимальный state machine
|
|
||||||
|
|
||||||
### R02. ADR: chat history policy
|
|
||||||
- **Рекомендуемый исполнитель:** `primary-agent`
|
|
||||||
- **Зачем:** зафиксировать, как `history.md` соотносится с metadata чата
|
|
||||||
- **Нужно решить:** кто создает файл, кто пишет initial header, как читать/обновлять metadata без парсинга файла
|
|
||||||
- **Выход:** ADR-lite + правила sync между metadata и history file
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1. Foundation for workspace/chat/file domain
|
|
||||||
|
|
||||||
### R10. Domain model for user storage
|
|
||||||
- **Рекомендуемый исполнитель:** `primary-agent`
|
|
||||||
- **Scope:** `Workspace`, `Chat`, `ChatFile`, domain errors
|
|
||||||
- **Layer:** `domain/`
|
|
||||||
- **Выход:** минимальные сущности, value objects, ошибки конфликтов/не-найдено/квота
|
|
||||||
- **Depends on:** `R00`, `R02`
|
|
||||||
|
|
||||||
### R11. Usecase ports for storage and metadata
|
|
||||||
- **Рекомендуемый исполнитель:** `primary-agent`
|
|
||||||
- **Scope:** repository/storage interfaces для workspace/chat/chat-file/history/quota
|
|
||||||
- **Layer:** `usecase/`
|
|
||||||
- **Выход:** порты для metadata repo, file storage, usage reader, id generator, clock и trusted caller identity input
|
|
||||||
- **Depends on:** `R10`
|
|
||||||
|
|
||||||
### R12. In-memory metadata adapter foundation
|
|
||||||
- **Рекомендуемый исполнитель:** `junior`
|
|
||||||
- **Scope:** первая metadata implementation для workspace/chat/chat-file/artifact metadata
|
|
||||||
- **Layer:** `repository/`
|
|
||||||
- **Примечание:** это промежуточный этап; durable DB идет отдельным follow-up после storage/product slice
|
|
||||||
- **Выход:** in-memory CRUD для workspace/chat/chat-file/artifact metadata
|
|
||||||
- **Depends on:** `R11`
|
|
||||||
|
|
||||||
### R13. Filesystem storage adapter foundation
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** storage adapter для chat directories, `history.md`, uploads и file metadata extraction
|
|
||||||
- **Layer:** `adapter/` или `repository/` в зависимости от финальной границы
|
|
||||||
- **Выход:** операции create/list/delete/read metadata для chat files, history path management и download path resolution
|
|
||||||
- **Depends on:** `R11`
|
|
||||||
|
|
||||||
### R14. Sandbox integration with chat metadata
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** перестать считать `chat_id` единственным источником layout, использовать metadata-backed chat path
|
|
||||||
- **Layer:** `usecase/` + outer adapters
|
|
||||||
- **Выход:** sandbox mounts current chat directory, созданную через storage model
|
|
||||||
- **Depends on:** `R12`, `R13`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2. Workspace and chat API
|
|
||||||
|
|
||||||
### R20. Create workspace on first touch
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** auto-create workspace при первом запросе на user storage
|
|
||||||
- **API:** внутренний usecase + при необходимости явная HTTP ручка
|
|
||||||
- **Выход:** гарантированный `Workspace` для пользователя по `user_id`
|
|
||||||
- **Depends on:** `R12`
|
|
||||||
|
|
||||||
### R21. Chat CRUD
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** create/get/list/delete chat
|
|
||||||
- **API:** versioned HTTP under `/api/v1`
|
|
||||||
- **Выход:** first-class chat metadata вместо implicit-only `chat_id`
|
|
||||||
- **Depends on:** `R20`, `R12`, `R13`
|
|
||||||
|
|
||||||
### R22. History file lifecycle
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** создавать `history.md` вместе с chat directory, читать и обновлять metadata
|
|
||||||
- **Выход:** первый стабильный contract для истории чата в filesystem
|
|
||||||
- **Depends on:** `R21`, `R13`
|
|
||||||
|
|
||||||
### R23. Chat file upload and file metadata API
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** upload/list/get metadata/delete single file/clear chat files
|
|
||||||
- **API:** HTTP adapter + schemas
|
|
||||||
- **Выход:** базовый file management API на chat scope + master download endpoint
|
|
||||||
- **Depends on:** `R21`, `R13`
|
|
||||||
|
|
||||||
### R24. Usage and quota accounting
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** считать storage usage по workspace/chat, мягкая quota 10 GB
|
|
||||||
- **Выход:** usecases и metrics для current usage; file/artifact write-path reject при превышении soft quota
|
|
||||||
- **Depends on:** `R23`
|
|
||||||
|
|
||||||
### R25. Tests and review for storage slice
|
|
||||||
- **Рекомендуемый исполнитель:** `test-engineer`
|
|
||||||
- **Scope:** unit + adapter + HTTP tests, boundary review
|
|
||||||
- **Выход:** regression coverage для workspace/chat/files
|
|
||||||
- **Depends on:** `R20`-`R24`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3. Artifact pipeline
|
|
||||||
|
|
||||||
### R30. Artifact domain and metadata model
|
|
||||||
- **Рекомендуемый исполнитель:** `primary-agent`
|
|
||||||
- **Scope:** `Artifact` entity, states, delivery metadata, linkage to `user/chat`
|
|
||||||
- **Layer:** `domain/`, `usecase/`
|
|
||||||
- **Выход:** базовая artifact model + errors + repository ports для in-memory metadata stage
|
|
||||||
- **Depends on:** `R01`, `R12`
|
|
||||||
|
|
||||||
### R31. Object storage adapter
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** S3-compatible adapter for artifact blobs
|
|
||||||
- **Layer:** outer `adapter/` or `repository/`
|
|
||||||
- **Выход:** upload/get/delete primitives behind interface + presigned URL support
|
|
||||||
- **Depends on:** `R30`
|
|
||||||
|
|
||||||
### R32. Artifact registration and upload flow
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** usecase, который связывает metadata и object storage
|
|
||||||
- **Выход:** artifact можно зарегистрировать, сохранить blob и получить external reference
|
|
||||||
- **Depends on:** `R30`, `R31`
|
|
||||||
|
|
||||||
### R33. Artifact list/get metadata API
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** list artifacts by chat/user, get artifact metadata/status
|
|
||||||
- **Выход:** внешний API для управления artifact metadata и выдачи presigned download references
|
|
||||||
- **Depends on:** `R32`
|
|
||||||
|
|
||||||
### R34. Delivery acknowledgement flow
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** mark artifact as delivered / delivery_failed
|
|
||||||
- **Выход:** подтвержденный delivery state через explicit ack и база для retention
|
|
||||||
- **Depends on:** `R32`
|
|
||||||
|
|
||||||
### R35. Artifact delete / mark-for-delete policy
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** policy-driven cleanup hooks после delivery confirmation или TTL
|
|
||||||
- **Выход:** artifact lifecycle завершен, metadata и blob cleanup согласованы, `marked_for_delete` используется как переходное состояние
|
|
||||||
- **Depends on:** `R34`
|
|
||||||
|
|
||||||
### R36. Tests and review for artifact slice
|
|
||||||
- **Рекомендуемый исполнитель:** `test-engineer`
|
|
||||||
- **Scope:** unit + adapter + HTTP tests, boundary review
|
|
||||||
- **Выход:** regression coverage для artifact lifecycle и object storage integration
|
|
||||||
- **Depends on:** `R30`-`R35`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4. Cross-slice hardening
|
|
||||||
|
|
||||||
### R40. Observability for storage usage and artifacts
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** metrics по usage/quota, artifact upload latency, artifact errors
|
|
||||||
- **Выход:** dashboards and alerts for storage-centric flows
|
|
||||||
- **Depends on:** `R24`, `R32`, `R35`
|
|
||||||
|
|
||||||
### R41. Cleanup safety rules
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Scope:** не удалять chat files/artifacts, если есть активный sandbox или незавершенный delivery flow
|
|
||||||
- **Выход:** safe cleanup invariants
|
|
||||||
- **Depends on:** `R24`, `R35`
|
|
||||||
|
|
||||||
### R42. Docs refresh
|
|
||||||
- **Рекомендуемый исполнитель:** `primary-agent`
|
|
||||||
- **Scope:** README, ADRs, API docs, operator notes
|
|
||||||
- **Выход:** актуальная документация для storage and artifact flows
|
|
||||||
- **Depends on:** все предыдущие slices
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Рекомендуемый порядок выполнения
|
|
||||||
|
|
||||||
### Priority A — workspace/chat/files
|
|
||||||
1. `R00` `R02`
|
|
||||||
2. `R10` `R11`
|
|
||||||
3. `R12` `R13`
|
|
||||||
4. `R20` `R21` `R22`
|
|
||||||
5. `R23` `R24`
|
|
||||||
6. `R14` `R25`
|
|
||||||
|
|
||||||
### Priority B — artifacts
|
|
||||||
1. `R01`
|
|
||||||
2. `R30` `R31`
|
|
||||||
3. `R32` `R33`
|
|
||||||
4. `R34` `R35`
|
|
||||||
5. `R36`
|
|
||||||
|
|
||||||
### Future follow-up after Priority A+B
|
|
||||||
1. durable metadata repository instead of in-memory adapters
|
|
||||||
2. auth/access control and access lease
|
|
||||||
3. multi-node storage/session coordination
|
|
||||||
|
|
||||||
### Priority C — hardening
|
|
||||||
1. `R40`
|
|
||||||
2. `R41`
|
|
||||||
3. `R42`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что даст этот roadmap
|
|
||||||
|
|
||||||
После выполнения Priority A + B сервис сможет:
|
|
||||||
- создавать и хранить workspace/chat metadata
|
|
||||||
- управлять файлами пользователя в chat scope
|
|
||||||
- хранить историю чата в предсказуемом layout
|
|
||||||
- поднимать sandbox поверх first-class chat storage
|
|
||||||
- хранить artifact metadata и blob отдельно
|
|
||||||
- отдавать artifact metadata наружу и подтверждать доставку
|
|
||||||
|
|
||||||
То есть `master-service` станет не только sandbox orchestrator, но и полноценным control-plane для user storage и artifact lifecycle.
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
# Sprint 01 — storage foundation
|
|
||||||
|
|
||||||
## Цель спринта
|
|
||||||
|
|
||||||
Подготовить первый исполнимый storage slice для `workspace` / `chat` / `chat files` без durable DB и без auth, но с корректными clean-architecture границами.
|
|
||||||
|
|
||||||
## Зафиксированные допущения
|
|
||||||
|
|
||||||
- trusted caller передает `user_id: UUID`
|
|
||||||
- один `Workspace` на одного `User`
|
|
||||||
- metadata adapters пока `in-memory`
|
|
||||||
- история чата живет в `history.md`
|
|
||||||
- chat files отдаются через master download endpoint
|
|
||||||
- soft quota блокирует quota-relevant write-path операции
|
|
||||||
- chat create сам по себе quota не блокирует
|
|
||||||
- delete в v1 — hard delete; chat delete запрещен при active sandbox
|
|
||||||
|
|
||||||
## Scope in
|
|
||||||
|
|
||||||
- domain model для `Workspace`, `Chat`, `ChatFile`
|
|
||||||
- usecase ports для metadata, filesystem storage и usage
|
|
||||||
- in-memory metadata adapters
|
|
||||||
- filesystem adapter для chat directories, `history.md` и file operations
|
|
||||||
- usecases для auto-create workspace, chat CRUD и file CRUD
|
|
||||||
- HTTP API для chat/file operations
|
|
||||||
- базовый usage/quota check
|
|
||||||
|
|
||||||
## Scope out
|
|
||||||
|
|
||||||
- durable DB
|
|
||||||
- auth/access control
|
|
||||||
- artifacts
|
|
||||||
- retention engine
|
|
||||||
- p2p/access lease
|
|
||||||
- multi-node behavior
|
|
||||||
|
|
||||||
## Легенда исполнителей
|
|
||||||
|
|
||||||
- `primary-agent` — архитектура, ADR, domain/contracts
|
|
||||||
- `junior` — простые repositories и базовые unit tasks
|
|
||||||
- `junior+opus` — adapter/usecase/API задачи средней сложности
|
|
||||||
- `test-engineer` — test packs
|
|
||||||
- `code-reviewer` — review-only этап
|
|
||||||
|
|
||||||
## Порядок задач
|
|
||||||
|
|
||||||
### S01. ADR-lite и storage contracts
|
|
||||||
- **Рекомендуемый исполнитель:** `primary-agent`
|
|
||||||
- **Commit:** `add storage foundation contracts`
|
|
||||||
- **Scope:** зафиксировать storage source-of-truth и history policy; добавить domain entities и usecase ports
|
|
||||||
- **Files:** `docs/009-storage-foundation.md`, `docs/010-chat-history-policy.md`, `domain/*`, `usecase/interface.py` или новые storage usecase files
|
|
||||||
- **Acceptance:** есть минимальные сущности/ошибки/порты; ADR краткий и консистентный
|
|
||||||
|
|
||||||
### S02. In-memory metadata adapters
|
|
||||||
- **Рекомендуемый исполнитель:** `junior`
|
|
||||||
- **Commit:** `add in-memory storage repositories`
|
|
||||||
- **Scope:** `WorkspaceRepository`, `ChatRepository`, `ChatFileRepository` in-memory implementations
|
|
||||||
- **Files:** `repository/*`
|
|
||||||
- **Acceptance:** CRUD и базовые query paths работают без HTTP
|
|
||||||
|
|
||||||
### S03. Filesystem chat storage adapter
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Commit:** `add chat filesystem storage`
|
|
||||||
- **Scope:** create chat directory, create `history.md`, save/list/delete files, collect file metadata
|
|
||||||
- **Files:** outer adapter/repository storage files
|
|
||||||
- **Acceptance:** chat directory layout стабилен; adapter не протекает внутрь
|
|
||||||
|
|
||||||
### S04. Workspace and chat usecases
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Commit:** `add workspace and chat usecases`
|
|
||||||
- **Scope:** auto-create workspace on first touch, create/get/list/delete chat
|
|
||||||
- **Files:** `usecase/*`, `adapter/di/container.py`
|
|
||||||
- **Acceptance:** delete chat конфликтует при active sandbox; history path создается через storage adapter
|
|
||||||
|
|
||||||
### S05. Chat file usecases + quota
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Commit:** `add chat file usecases`
|
|
||||||
- **Scope:** upload/list/metadata/delete/clear files; current usage and soft quota reject on write
|
|
||||||
- **Files:** `usecase/*`, `adapter/di/container.py`
|
|
||||||
- **Acceptance:** file write-path reject при quota overflow; usage включает `history.md` и chat files; chat metadata create не блокируется quota check
|
|
||||||
|
|
||||||
### S06. HTTP API for chat and files
|
|
||||||
- **Рекомендуемый исполнитель:** `junior+opus`
|
|
||||||
- **Commit:** `add chat and file http api`
|
|
||||||
- **Scope:** versioned routes, schemas, error mapping, file download endpoint
|
|
||||||
- **Files:** `adapter/http/fastapi/*`
|
|
||||||
- **Acceptance:** API тонкий; FastAPI не протекает во внутренние слои
|
|
||||||
|
|
||||||
### S07. Tests for storage foundation
|
|
||||||
- **Рекомендуемый исполнитель:** `test-engineer`
|
|
||||||
- **Commit:** `add storage foundation tests`
|
|
||||||
- **Scope:** unit + adapter + HTTP tests для workspace/chat/files/quota
|
|
||||||
- **Files:** `test/*`
|
|
||||||
- **Acceptance:** есть regression coverage для CRUD, history creation, quota reject, delete conflict, file download
|
|
||||||
|
|
||||||
### S08. Boundary review
|
|
||||||
- **Рекомендуемый исполнитель:** `code-reviewer`
|
|
||||||
- **Commit:** no
|
|
||||||
- **Scope:** final review storage foundation slice
|
|
||||||
- **Acceptance:** clean architecture соблюдена, нет must-fix замечаний
|
|
||||||
|
|
||||||
## Definition of done
|
|
||||||
|
|
||||||
Спринт считается завершенным, когда:
|
|
||||||
- можно создать chat для `user_id`
|
|
||||||
- для chat создается directory и `history.md`
|
|
||||||
- можно загрузить и удалить chat files
|
|
||||||
- можно получить metadata и скачать файл через master
|
|
||||||
- quota check блокирует quota-relevant file write operations
|
|
||||||
- все тесты проходят
|
|
||||||
- boundary review не содержит must-fix замечаний
|
|
||||||
|
|
||||||
## Риски
|
|
||||||
|
|
||||||
- in-memory metadata не переживает restart
|
|
||||||
- download endpoint может потребовать уточнения streaming contract
|
|
||||||
- quota semantics нужно держать простыми, чтобы не породить скрытый retention scope
|
|
||||||
|
|
||||||
## Что идет следующим спринтом
|
|
||||||
|
|
||||||
Сразу после этого спринта нужен bridge-спринт для sandbox/storage integration:
|
|
||||||
- metadata-backed chat path для sandbox flow
|
|
||||||
- wiring текущего `CreateSandbox` поверх first-class chat metadata
|
|
||||||
- review, что sandbox lifecycle не расходится с новым storage slice
|
|
||||||
|
|
||||||
И только потом логично брать artifact slice:
|
|
||||||
- artifact domain + metadata
|
|
||||||
- object storage adapter
|
|
||||||
- artifact upload/list/status
|
|
||||||
- delivery ack flow
|
|
||||||
|
|
@ -1,909 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from docker import DockerClient
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from starlette.types import Message, Scope
|
|
||||||
|
|
||||||
import adapter.di.container as container_module
|
|
||||||
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.docker.runtime import DockerSandboxRuntime
|
|
||||||
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 repository.sandbox_lock import ProcessLocalSandboxLifecycleLocker
|
|
||||||
from repository.sandbox_session import InMemorySandboxSessionRepository
|
|
||||||
from usecase.interface import Attrs
|
|
||||||
from usecase.sandbox import CleanupExpiredSandboxes, CreateSandbox, CreateSandboxCommand
|
|
||||||
|
|
||||||
CHAT_ID = UUID('123e4567-e89b-12d3-a456-426614174000')
|
|
||||||
NON_CANONICAL_CHAT_ID = '123E4567E89B12D3A456426614174000'
|
|
||||||
SESSION_ID = UUID('00000000-0000-0000-0000-000000000011')
|
|
||||||
|
|
||||||
|
|
||||||
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, base_url: str | None = None) -> None:
|
|
||||||
self.base_url = base_url
|
|
||||||
self.close_calls = 0
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.close_calls += 1
|
|
||||||
|
|
||||||
|
|
||||||
class EmptySandboxState:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls = 0
|
|
||||||
|
|
||||||
def list_active_sessions(self) -> list[SandboxSession]:
|
|
||||||
self.calls += 1
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
class FakeClock:
|
|
||||||
def __init__(self, now: datetime) -> None:
|
|
||||||
self._now = now
|
|
||||||
|
|
||||||
def now(self) -> datetime:
|
|
||||||
return self._now
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingMetrics:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.increment_calls: list[tuple[str, int, Attrs | None]] = []
|
|
||||||
self.record_calls: list[tuple[str, float, Attrs | None]] = []
|
|
||||||
self.set_calls: list[tuple[str, int | float, Attrs | None]] = []
|
|
||||||
|
|
||||||
def increment(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
value: int = 1,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.increment_calls.append((name, value, attrs))
|
|
||||||
|
|
||||||
def record(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
value: float,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.record_calls.append((name, value, attrs))
|
|
||||||
|
|
||||||
def set(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
value: int | float,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.set_calls.append((name, value, attrs))
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingSpan:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.attrs: dict[str, str | int | float | bool] = {}
|
|
||||||
self.errors: list[Exception] = []
|
|
||||||
|
|
||||||
def set_attribute(self, name: str, value: str | int | float | bool) -> None:
|
|
||||||
self.attrs[name] = value
|
|
||||||
|
|
||||||
def record_error(self, error: Exception) -> None:
|
|
||||||
self.errors.append(error)
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingSpanContext:
|
|
||||||
def __init__(self, span: RecordingSpan) -> None:
|
|
||||||
self._span = span
|
|
||||||
|
|
||||||
def __enter__(self) -> RecordingSpan:
|
|
||||||
return self._span
|
|
||||||
|
|
||||||
def __exit__(
|
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
exc: BaseException | None,
|
|
||||||
traceback: object,
|
|
||||||
) -> bool | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingTracer:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.spans: list[tuple[str, Attrs | None, RecordingSpan]] = []
|
|
||||||
|
|
||||||
def start_span(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> RecordingSpanContext:
|
|
||||||
span = RecordingSpan()
|
|
||||||
self.spans.append((name, attrs, span))
|
|
||||||
return RecordingSpanContext(span)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLifecycleRuntime:
|
|
||||||
def __init__(self, sessions: list[SandboxSession]) -> None:
|
|
||||||
self._sessions = list(sessions)
|
|
||||||
self.list_calls = 0
|
|
||||||
self.create_calls: list[CreateSandboxCommand] = []
|
|
||||||
self.stop_calls: list[str] = []
|
|
||||||
|
|
||||||
def list_active_sessions(self) -> list[SandboxSession]:
|
|
||||||
self.list_calls += 1
|
|
||||||
return list(self._sessions)
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session_id: UUID,
|
|
||||||
chat_id: UUID,
|
|
||||||
created_at: datetime,
|
|
||||||
expires_at: datetime,
|
|
||||||
) -> SandboxSession:
|
|
||||||
self.create_calls.append(CreateSandboxCommand(chat_id=chat_id))
|
|
||||||
session = 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,
|
|
||||||
)
|
|
||||||
self._sessions = [
|
|
||||||
existing for existing in self._sessions if existing.chat_id != chat_id
|
|
||||||
]
|
|
||||||
self._sessions.append(session)
|
|
||||||
return session
|
|
||||||
|
|
||||||
def stop(self, container_id: str) -> None:
|
|
||||||
self.stop_calls.append(container_id)
|
|
||||||
|
|
||||||
|
|
||||||
class FixedSandboxState:
|
|
||||||
def __init__(self, sessions: list[SandboxSession]) -> None:
|
|
||||||
self._sessions = list(sessions)
|
|
||||||
|
|
||||||
def list_active_sessions(self) -> list[SandboxSession]:
|
|
||||||
return list(self._sessions)
|
|
||||||
|
|
||||||
|
|
||||||
class FailingSandboxState:
|
|
||||||
def __init__(self, error: Exception) -> None:
|
|
||||||
self._error = error
|
|
||||||
self.calls = 0
|
|
||||||
|
|
||||||
def list_active_sessions(self) -> list[SandboxSession]:
|
|
||||||
self.calls += 1
|
|
||||||
raise self._error
|
|
||||||
|
|
||||||
|
|
||||||
class CountingRegistry:
|
|
||||||
def __init__(self, count_active_result: int) -> None:
|
|
||||||
self._count_active_result = count_active_result
|
|
||||||
self.replaced_sessions: list[SandboxSession] = []
|
|
||||||
|
|
||||||
def replace_all(self, sessions: list[SandboxSession]) -> None:
|
|
||||||
self.replaced_sessions = list(sessions)
|
|
||||||
|
|
||||||
def count_active(self) -> int:
|
|
||||||
return self._count_active_result
|
|
||||||
|
|
||||||
|
|
||||||
class FailingRegistry:
|
|
||||||
def __init__(self, error: Exception, *, fail_on: str = 'replace_all') -> None:
|
|
||||||
self._error = error
|
|
||||||
self._fail_on = fail_on
|
|
||||||
self.replaced_sessions: list[SandboxSession] = []
|
|
||||||
self.count_calls = 0
|
|
||||||
|
|
||||||
def replace_all(self, sessions: list[SandboxSession]) -> None:
|
|
||||||
self.replaced_sessions = list(sessions)
|
|
||||||
if self._fail_on == 'replace_all':
|
|
||||||
raise self._error
|
|
||||||
|
|
||||||
def count_active(self) -> int:
|
|
||||||
self.count_calls += 1
|
|
||||||
if self._fail_on == 'count_active':
|
|
||||||
raise self._error
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
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: CreateSandbox,
|
|
||||||
cleanup_usecase: CleanupExpiredSandboxes,
|
|
||||||
logger: FakeLogger,
|
|
||||||
docker_client: FakeDockerClient,
|
|
||||||
sandbox_reconciler: SandboxSessionReconciler | None = None,
|
|
||||||
) -> AppContainer:
|
|
||||||
observability = ObservabilityRuntime(
|
|
||||||
logger=logger,
|
|
||||||
metrics=NoopMetrics(),
|
|
||||||
tracer=NoopTracer(),
|
|
||||||
)
|
|
||||||
repositories = AppRepositories(sandbox_session=InMemorySandboxSessionRepository())
|
|
||||||
reconciler = sandbox_reconciler
|
|
||||||
if reconciler is None:
|
|
||||||
reconciler = SandboxSessionReconciler(
|
|
||||||
state_source=EmptySandboxState(),
|
|
||||||
registry=repositories.sandbox_session,
|
|
||||||
logger=logger,
|
|
||||||
metrics=observability.metrics,
|
|
||||||
tracer=observability.tracer,
|
|
||||||
)
|
|
||||||
usecases = AppUsecases(
|
|
||||||
create_sandbox=create_sandbox_usecase,
|
|
||||||
cleanup_expired_sandboxes=cleanup_usecase,
|
|
||||||
)
|
|
||||||
return AppContainer(
|
|
||||||
config=config,
|
|
||||||
observability=observability,
|
|
||||||
repositories=repositories,
|
|
||||||
usecases=usecases,
|
|
||||||
sandbox_reconciler=reconciler,
|
|
||||||
_docker_client=docker_client,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def request_json(
|
|
||||||
app: FastAPI,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
payload: dict[str, str] | None = None,
|
|
||||||
) -> tuple[int, dict[str, object]]:
|
|
||||||
body = b'' if payload is None else 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': method,
|
|
||||||
'scheme': 'http',
|
|
||||||
'path': path,
|
|
||||||
'raw_path': path.encode(),
|
|
||||||
'query_string': b'',
|
|
||||||
'root_path': '',
|
|
||||||
'headers': _build_headers(body, payload is not None),
|
|
||||||
'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''))
|
|
||||||
|
|
||||||
if not response_body:
|
|
||||||
return status, {}
|
|
||||||
|
|
||||||
return status, json.loads(response_body.decode())
|
|
||||||
|
|
||||||
|
|
||||||
def _build_headers(body: bytes, has_json_body: bool) -> list[tuple[bytes, bytes]]:
|
|
||||||
headers = [
|
|
||||||
(b'host', b'testserver'),
|
|
||||||
(b'content-length', str(len(body)).encode()),
|
|
||||||
]
|
|
||||||
if has_json_body:
|
|
||||||
headers.append((b'content-type', b'application/json'))
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
async def post_json(
|
|
||||||
app: FastAPI, path: str, payload: dict[str, str]
|
|
||||||
) -> tuple[int, dict[str, object]]:
|
|
||||||
return await request_json(app, 'POST', path, payload)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_json(app: FastAPI, path: str) -> tuple[int, dict[str, object]]:
|
|
||||||
return await request_json(app, 'GET', path)
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
async def exercise_get_request(
|
|
||||||
app: FastAPI,
|
|
||||||
path: str,
|
|
||||||
) -> tuple[int, dict[str, object]]:
|
|
||||||
await app.router.startup()
|
|
||||||
try:
|
|
||||||
status, response = await get_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)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
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': NON_CANONICAL_CHAT_ID})
|
|
||||||
)
|
|
||||||
|
|
||||||
assert status_code == 200
|
|
||||||
assert response == {
|
|
||||||
'session_id': str(SESSION_ID),
|
|
||||||
'chat_id': str(CHAT_ID),
|
|
||||||
'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_ID
|
|
||||||
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_rejects_non_uuid_chat_id(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,
|
|
||||||
)
|
|
||||||
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': 'x/../y'})
|
|
||||||
)
|
|
||||||
|
|
||||||
assert status_code == 422
|
|
||||||
assert 'detail' in response
|
|
||||||
assert create_usecase.commands == []
|
|
||||||
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(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)})
|
|
||||||
)
|
|
||||||
|
|
||||||
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': str(CHAT_ID)})
|
|
||||||
)
|
|
||||||
|
|
||||||
assert status_code == 500
|
|
||||||
assert response == {'detail': 'sandbox_broken'}
|
|
||||||
assert docker_client.close_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_startup_reconciliation_reuses_existing_container_after_restart(
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
config = build_config()
|
|
||||||
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
|
||||||
restored_session = SandboxSession(
|
|
||||||
session_id=SESSION_ID,
|
|
||||||
chat_id=CHAT_ID,
|
|
||||||
container_id='container-123',
|
|
||||||
status=SandboxStatus.RUNNING,
|
|
||||||
created_at=created_at,
|
|
||||||
expires_at=created_at + timedelta(minutes=5),
|
|
||||||
)
|
|
||||||
logger = FakeLogger()
|
|
||||||
docker_client = FakeDockerClient()
|
|
||||||
runtime = FakeLifecycleRuntime([restored_session])
|
|
||||||
repository = InMemorySandboxSessionRepository()
|
|
||||||
observability = ObservabilityRuntime(
|
|
||||||
logger=logger,
|
|
||||||
metrics=NoopMetrics(),
|
|
||||||
tracer=NoopTracer(),
|
|
||||||
)
|
|
||||||
repositories = AppRepositories(sandbox_session=repository)
|
|
||||||
reconciler = SandboxSessionReconciler(
|
|
||||||
state_source=runtime,
|
|
||||||
registry=repository,
|
|
||||||
logger=logger,
|
|
||||||
metrics=observability.metrics,
|
|
||||||
tracer=observability.tracer,
|
|
||||||
)
|
|
||||||
usecases = AppUsecases(
|
|
||||||
create_sandbox=CreateSandbox(
|
|
||||||
repository=repository,
|
|
||||||
locker=ProcessLocalSandboxLifecycleLocker(),
|
|
||||||
runtime=runtime,
|
|
||||||
clock=FakeClock(created_at),
|
|
||||||
logger=logger,
|
|
||||||
metrics=NoopMetrics(),
|
|
||||||
tracer=NoopTracer(),
|
|
||||||
ttl=timedelta(minutes=5),
|
|
||||||
),
|
|
||||||
cleanup_expired_sandboxes=CleanupExpiredSandboxes(
|
|
||||||
repository=repository,
|
|
||||||
locker=ProcessLocalSandboxLifecycleLocker(),
|
|
||||||
runtime=runtime,
|
|
||||||
clock=FakeClock(created_at),
|
|
||||||
logger=logger,
|
|
||||||
metrics=NoopMetrics(),
|
|
||||||
tracer=NoopTracer(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
container = AppContainer(
|
|
||||||
config=config,
|
|
||||||
observability=observability,
|
|
||||||
repositories=repositories,
|
|
||||||
usecases=usecases,
|
|
||||||
sandbox_reconciler=reconciler,
|
|
||||||
_docker_client=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)})
|
|
||||||
)
|
|
||||||
|
|
||||||
assert status_code == 200
|
|
||||||
assert response == {
|
|
||||||
'session_id': str(SESSION_ID),
|
|
||||||
'chat_id': str(CHAT_ID),
|
|
||||||
'container_id': 'container-123',
|
|
||||||
'status': 'running',
|
|
||||||
'expires_at': '2026-04-02T12:05:00Z',
|
|
||||||
}
|
|
||||||
assert runtime.list_calls == 1
|
|
||||||
assert runtime.create_calls == []
|
|
||||||
assert runtime.stop_calls == []
|
|
||||||
assert repository.get_active_by_chat_id(CHAT_ID) == restored_session
|
|
||||||
assert docker_client.close_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_removed_user_endpoint_returns_not_found(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,
|
|
||||||
)
|
|
||||||
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_get_request(app, '/api/v1/users/user-123')
|
|
||||||
)
|
|
||||||
|
|
||||||
assert status_code == 404
|
|
||||||
assert response == {'detail': 'Not Found'}
|
|
||||||
assert docker_client.close_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconciliation_uses_registry_backed_active_count_metric() -> None:
|
|
||||||
logger = FakeLogger()
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
|
||||||
session = SandboxSession(
|
|
||||||
session_id=SESSION_ID,
|
|
||||||
chat_id=CHAT_ID,
|
|
||||||
container_id='container-123',
|
|
||||||
status=SandboxStatus.RUNNING,
|
|
||||||
created_at=created_at,
|
|
||||||
expires_at=created_at + timedelta(minutes=5),
|
|
||||||
)
|
|
||||||
registry = CountingRegistry(count_active_result=7)
|
|
||||||
reconciler = SandboxSessionReconciler(
|
|
||||||
state_source=FixedSandboxState([session]),
|
|
||||||
registry=registry,
|
|
||||||
logger=logger,
|
|
||||||
metrics=metrics,
|
|
||||||
tracer=tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
sessions = reconciler.execute()
|
|
||||||
|
|
||||||
assert sessions == [session]
|
|
||||||
assert registry.replaced_sessions == [session]
|
|
||||||
assert metrics.set_calls == [('sandbox.active.count', 7, None)]
|
|
||||||
assert tracer.spans[0][0] == 'adapter.sandbox.reconcile_sessions'
|
|
||||||
assert tracer.spans[0][2].attrs['sandbox.active_count'] == 7
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconciliation_records_error_when_state_source_fails() -> None:
|
|
||||||
logger = FakeLogger()
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
state_error = RuntimeError('state_failed')
|
|
||||||
state_source = FailingSandboxState(state_error)
|
|
||||||
reconciler = SandboxSessionReconciler(
|
|
||||||
state_source=state_source,
|
|
||||||
registry=CountingRegistry(count_active_result=7),
|
|
||||||
logger=logger,
|
|
||||||
metrics=metrics,
|
|
||||||
tracer=tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match='state_failed') as excinfo:
|
|
||||||
reconciler.execute()
|
|
||||||
|
|
||||||
assert state_source.calls == 1
|
|
||||||
assert metrics.set_calls == []
|
|
||||||
spans = [
|
|
||||||
span
|
|
||||||
for name, _, span in tracer.spans
|
|
||||||
if name == 'adapter.sandbox.reconcile_sessions'
|
|
||||||
]
|
|
||||||
assert spans
|
|
||||||
span = spans[0]
|
|
||||||
assert span.attrs['sandbox.result'] == 'error'
|
|
||||||
assert 'sandbox.discovered_count' not in span.attrs
|
|
||||||
assert 'sandbox.active_count' not in span.attrs
|
|
||||||
assert excinfo.value in span.errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconciliation_records_error_without_active_count_metric_on_registry_failure() -> (
|
|
||||||
None
|
|
||||||
):
|
|
||||||
logger = FakeLogger()
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
|
||||||
session = SandboxSession(
|
|
||||||
session_id=SESSION_ID,
|
|
||||||
chat_id=CHAT_ID,
|
|
||||||
container_id='container-123',
|
|
||||||
status=SandboxStatus.RUNNING,
|
|
||||||
created_at=created_at,
|
|
||||||
expires_at=created_at + timedelta(minutes=5),
|
|
||||||
)
|
|
||||||
registry_error = RuntimeError('replace_failed')
|
|
||||||
registry = FailingRegistry(registry_error)
|
|
||||||
reconciler = SandboxSessionReconciler(
|
|
||||||
state_source=FixedSandboxState([session]),
|
|
||||||
registry=registry,
|
|
||||||
logger=logger,
|
|
||||||
metrics=metrics,
|
|
||||||
tracer=tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match='replace_failed') as excinfo:
|
|
||||||
reconciler.execute()
|
|
||||||
|
|
||||||
assert registry.replaced_sessions == [session]
|
|
||||||
assert registry.count_calls == 0
|
|
||||||
assert metrics.set_calls == []
|
|
||||||
spans = [
|
|
||||||
span
|
|
||||||
for name, _, span in tracer.spans
|
|
||||||
if name == 'adapter.sandbox.reconcile_sessions'
|
|
||||||
]
|
|
||||||
assert spans
|
|
||||||
span = spans[0]
|
|
||||||
assert span.attrs['sandbox.discovered_count'] == 1
|
|
||||||
assert span.attrs['sandbox.result'] == 'error'
|
|
||||||
assert 'sandbox.active_count' not in span.attrs
|
|
||||||
assert excinfo.value in span.errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconciliation_records_error_when_registry_count_active_fails() -> None:
|
|
||||||
logger = FakeLogger()
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
|
||||||
session = SandboxSession(
|
|
||||||
session_id=SESSION_ID,
|
|
||||||
chat_id=CHAT_ID,
|
|
||||||
container_id='container-123',
|
|
||||||
status=SandboxStatus.RUNNING,
|
|
||||||
created_at=created_at,
|
|
||||||
expires_at=created_at + timedelta(minutes=5),
|
|
||||||
)
|
|
||||||
registry_error = RuntimeError('count_failed')
|
|
||||||
registry = FailingRegistry(registry_error, fail_on='count_active')
|
|
||||||
reconciler = SandboxSessionReconciler(
|
|
||||||
state_source=FixedSandboxState([session]),
|
|
||||||
registry=registry,
|
|
||||||
logger=logger,
|
|
||||||
metrics=metrics,
|
|
||||||
tracer=tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match='count_failed') as excinfo:
|
|
||||||
reconciler.execute()
|
|
||||||
|
|
||||||
assert registry.replaced_sessions == [session]
|
|
||||||
assert registry.count_calls == 1
|
|
||||||
assert metrics.set_calls == []
|
|
||||||
spans = [
|
|
||||||
span
|
|
||||||
for name, _, span in tracer.spans
|
|
||||||
if name == 'adapter.sandbox.reconcile_sessions'
|
|
||||||
]
|
|
||||||
assert spans
|
|
||||||
span = spans[0]
|
|
||||||
assert span.attrs['sandbox.discovered_count'] == 1
|
|
||||||
assert 'sandbox.active_count' not in span.attrs
|
|
||||||
assert span.attrs['sandbox.result'] == 'error'
|
|
||||||
assert excinfo.value in span.errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_container_wires_observability_into_runtime_and_reconciler(
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
logger = FakeLogger()
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
observability = ObservabilityRuntime(
|
|
||||||
logger=logger,
|
|
||||||
metrics=metrics,
|
|
||||||
tracer=tracer,
|
|
||||||
)
|
|
||||||
docker_client = FakeDockerClient()
|
|
||||||
monkeypatch.setattr(
|
|
||||||
container_module, 'build_observability', lambda config: observability
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
container_module.docker,
|
|
||||||
'DockerClient',
|
|
||||||
lambda base_url: docker_client,
|
|
||||||
)
|
|
||||||
|
|
||||||
container = container_module.build_container(config=build_config())
|
|
||||||
|
|
||||||
runtime = container.sandbox_reconciler.state_source
|
|
||||||
assert isinstance(runtime, DockerSandboxRuntime)
|
|
||||||
assert runtime._metrics is metrics
|
|
||||||
assert runtime._tracer is tracer
|
|
||||||
assert container.sandbox_reconciler.metrics is metrics
|
|
||||||
assert container.sandbox_reconciler.tracer is tracer
|
|
||||||
assert container.usecases.create_sandbox._runtime is runtime
|
|
||||||
assert container.usecases.create_sandbox._metrics is metrics
|
|
||||||
assert container.usecases.create_sandbox._tracer is tracer
|
|
||||||
assert container.usecases.cleanup_expired_sandboxes._runtime is runtime
|
|
||||||
assert container.usecases.cleanup_expired_sandboxes._metrics is metrics
|
|
||||||
assert container.usecases.cleanup_expired_sandboxes._tracer is tracer
|
|
||||||
assert container._docker_client is docker_client
|
|
||||||
|
|
||||||
container.shutdown()
|
|
||||||
|
|
||||||
assert docker_client.close_calls == 1
|
|
||||||
|
|
@ -1,732 +0,0 @@
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from types import TracebackType
|
|
||||||
from typing import Any, TypedDict
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
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 adapter.observability.noop import NoopMetrics, NoopTracer
|
|
||||||
from domain.error import SandboxError, SandboxStartError
|
|
||||||
from domain.sandbox import SandboxSession, SandboxStatus
|
|
||||||
from usecase.interface import Attrs, AttrValue
|
|
||||||
|
|
||||||
CHAT_ID = UUID('123e4567-e89b-12d3-a456-426614174000')
|
|
||||||
NON_CANONICAL_CHAT_ID = '123E4567E89B12D3A456426614174000'
|
|
||||||
SESSION_ID = UUID('00000000-0000-0000-0000-000000000010')
|
|
||||||
|
|
||||||
|
|
||||||
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 FakeListedContainer(FakeContainer):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
container_id: str,
|
|
||||||
*,
|
|
||||||
labels: dict[str, str],
|
|
||||||
created_at: str,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(container_id)
|
|
||||||
self.labels = labels
|
|
||||||
self.attrs = {'Created': created_at}
|
|
||||||
|
|
||||||
|
|
||||||
class FailingStopContainer(FakeListedContainer):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
container_id: str,
|
|
||||||
*,
|
|
||||||
labels: dict[str, str],
|
|
||||||
created_at: str,
|
|
||||||
error: Exception,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
container_id,
|
|
||||||
labels=labels,
|
|
||||||
created_at=created_at,
|
|
||||||
)
|
|
||||||
self._error = error
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
self.stop_calls += 1
|
|
||||||
raise self._error
|
|
||||||
|
|
||||||
|
|
||||||
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.list_calls: list[dict[str, object]] = []
|
|
||||||
self.run_result = run_result or FakeContainer('container-123')
|
|
||||||
self.get_result: FakeContainer | Exception | None = None
|
|
||||||
self.list_result: list[object] = []
|
|
||||||
self.list_error: 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
|
|
||||||
|
|
||||||
def list(self, *, filters: dict[str, list[str]]) -> list[object]:
|
|
||||||
self.list_calls.append({'filters': filters})
|
|
||||||
if self.list_error is not None:
|
|
||||||
raise self.list_error
|
|
||||||
return self.list_result
|
|
||||||
|
|
||||||
|
|
||||||
class FakeDockerClient(DockerClient):
|
|
||||||
def __init__(self, containers: FakeContainers) -> None:
|
|
||||||
self._containers = containers
|
|
||||||
|
|
||||||
@property
|
|
||||||
def containers(self) -> Any:
|
|
||||||
return self._containers
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingMetrics:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.increment_calls: list[tuple[str, int, Attrs | None]] = []
|
|
||||||
self.record_calls: list[tuple[str, float, Attrs | None]] = []
|
|
||||||
self.set_calls: list[tuple[str, int | float, Attrs | None]] = []
|
|
||||||
|
|
||||||
def increment(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
value: int = 1,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.increment_calls.append((name, value, attrs))
|
|
||||||
|
|
||||||
def record(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
value: float,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.record_calls.append((name, value, attrs))
|
|
||||||
|
|
||||||
def set(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
value: int | float,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.set_calls.append((name, value, attrs))
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingSpan:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.attrs: dict[str, AttrValue] = {}
|
|
||||||
self.errors: list[Exception] = []
|
|
||||||
|
|
||||||
def set_attribute(self, name: str, value: AttrValue) -> None:
|
|
||||||
self.attrs[name] = value
|
|
||||||
|
|
||||||
def record_error(self, error: Exception) -> None:
|
|
||||||
self.errors.append(error)
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingSpanContext:
|
|
||||||
def __init__(self, span: RecordingSpan) -> None:
|
|
||||||
self._span = span
|
|
||||||
|
|
||||||
def __enter__(self) -> RecordingSpan:
|
|
||||||
return self._span
|
|
||||||
|
|
||||||
def __exit__(
|
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
exc: BaseException | None,
|
|
||||||
traceback: TracebackType | None,
|
|
||||||
) -> bool | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingTracer:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.spans: list[tuple[str, Attrs | None, RecordingSpan]] = []
|
|
||||||
|
|
||||||
def start_span(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> RecordingSpanContext:
|
|
||||||
span = RecordingSpan()
|
|
||||||
self.spans.append((name, attrs, span))
|
|
||||||
return RecordingSpanContext(span)
|
|
||||||
|
|
||||||
|
|
||||||
def _attrs_include(
|
|
||||||
actual: Attrs | dict[str, AttrValue] | None,
|
|
||||||
expected: dict[str, AttrValue],
|
|
||||||
) -> bool:
|
|
||||||
if actual is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return all(actual.get(name) == value for name, value in expected.items())
|
|
||||||
|
|
||||||
|
|
||||||
def _find_span(
|
|
||||||
tracer: RecordingTracer,
|
|
||||||
name: str,
|
|
||||||
attrs: dict[str, AttrValue] | None = None,
|
|
||||||
span_attrs: dict[str, AttrValue] | None = None,
|
|
||||||
) -> RecordingSpan:
|
|
||||||
for recorded_name, recorded_attrs, span in tracer.spans:
|
|
||||||
if recorded_name != name:
|
|
||||||
continue
|
|
||||||
if attrs is not None and not _attrs_include(recorded_attrs, attrs):
|
|
||||||
continue
|
|
||||||
if span_attrs is not None and not _attrs_include(span.attrs, span_attrs):
|
|
||||||
continue
|
|
||||||
return span
|
|
||||||
|
|
||||||
raise AssertionError(f'missing span {name}')
|
|
||||||
|
|
||||||
|
|
||||||
def _find_increment_call(
|
|
||||||
metrics: RecordingMetrics,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
value: int = 1,
|
|
||||||
attrs: dict[str, AttrValue] | None = None,
|
|
||||||
) -> tuple[str, int, Attrs | None]:
|
|
||||||
for recorded_name, recorded_value, recorded_attrs in metrics.increment_calls:
|
|
||||||
if recorded_name != name or recorded_value != value:
|
|
||||||
continue
|
|
||||||
if attrs is not None and not _attrs_include(recorded_attrs, attrs):
|
|
||||||
continue
|
|
||||||
return recorded_name, recorded_value, recorded_attrs
|
|
||||||
|
|
||||||
raise AssertionError(f'missing increment metric {name}')
|
|
||||||
|
|
||||||
|
|
||||||
def _find_record_call(
|
|
||||||
metrics: RecordingMetrics,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
attrs: dict[str, AttrValue] | None = None,
|
|
||||||
) -> tuple[str, float, Attrs | None]:
|
|
||||||
for recorded_name, recorded_value, recorded_attrs in metrics.record_calls:
|
|
||||||
if recorded_name != name:
|
|
||||||
continue
|
|
||||||
if attrs is not None and not _attrs_include(recorded_attrs, attrs):
|
|
||||||
continue
|
|
||||||
return recorded_name, recorded_value, recorded_attrs
|
|
||||||
|
|
||||||
raise AssertionError(f'missing record metric {name}')
|
|
||||||
|
|
||||||
|
|
||||||
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 build_runtime(
|
|
||||||
config: SandboxConfig,
|
|
||||||
containers: FakeContainers,
|
|
||||||
) -> DockerSandboxRuntime:
|
|
||||||
return DockerSandboxRuntime(
|
|
||||||
config,
|
|
||||||
FakeDockerClient(containers),
|
|
||||||
NoopMetrics(),
|
|
||||||
NoopTracer(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_create_applies_mount_policy_and_labels_with_canonical_chat_id(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
config = build_config(tmp_path)
|
|
||||||
(tmp_path / 'dependencies').mkdir()
|
|
||||||
(tmp_path / 'lambda-tools').mkdir()
|
|
||||||
containers = FakeContainers()
|
|
||||||
runtime = build_runtime(config, containers)
|
|
||||||
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
|
||||||
expires_at = created_at + timedelta(minutes=5)
|
|
||||||
|
|
||||||
session = runtime.create(
|
|
||||||
session_id=SESSION_ID,
|
|
||||||
chat_id=UUID(NON_CANONICAL_CHAT_ID),
|
|
||||||
created_at=created_at,
|
|
||||||
expires_at=expires_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session.session_id == SESSION_ID
|
|
||||||
assert session.chat_id == CHAT_ID
|
|
||||||
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' / str(CHAT_ID)).is_dir()
|
|
||||||
|
|
||||||
call = containers.run_calls[0]
|
|
||||||
assert call['args'] == ('sandbox:latest',)
|
|
||||||
assert call['kwargs']['detach'] is True
|
|
||||||
assert call['kwargs']['labels'] == {
|
|
||||||
'session_id': str(SESSION_ID),
|
|
||||||
'chat_id': str(CHAT_ID),
|
|
||||||
'expires_at': expires_at.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
mounts = call['kwargs']['mounts']
|
|
||||||
assert [dict(mount) for mount in mounts] == [
|
|
||||||
{
|
|
||||||
'Target': '/workspace/chat',
|
|
||||||
'Source': str((tmp_path / 'chats' / str(CHAT_ID)).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_records_observability(tmp_path: Path) -> None:
|
|
||||||
config = build_config(tmp_path)
|
|
||||||
(tmp_path / 'dependencies').mkdir()
|
|
||||||
(tmp_path / 'lambda-tools').mkdir()
|
|
||||||
containers = FakeContainers()
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
runtime = DockerSandboxRuntime(
|
|
||||||
config,
|
|
||||||
FakeDockerClient(containers),
|
|
||||||
metrics,
|
|
||||||
tracer,
|
|
||||||
)
|
|
||||||
created_at = datetime(2026, 4, 2, 12, 0, tzinfo=UTC)
|
|
||||||
expires_at = created_at + timedelta(minutes=5)
|
|
||||||
|
|
||||||
session = runtime.create(
|
|
||||||
session_id=SESSION_ID,
|
|
||||||
chat_id=CHAT_ID,
|
|
||||||
created_at=created_at,
|
|
||||||
expires_at=expires_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session.container_id == 'container-123'
|
|
||||||
duration_call = _find_record_call(
|
|
||||||
metrics,
|
|
||||||
'sandbox.runtime.create.duration_ms',
|
|
||||||
attrs={'operation': 'create', 'result': 'created'},
|
|
||||||
)
|
|
||||||
assert duration_call[1] >= 0
|
|
||||||
span = _find_span(
|
|
||||||
tracer,
|
|
||||||
'adapter.docker.create_sandbox',
|
|
||||||
{'chat.id': str(CHAT_ID), 'session.id': str(SESSION_ID)},
|
|
||||||
{
|
|
||||||
'container.id': 'container-123',
|
|
||||||
'sandbox.result': 'created',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert not span.errors
|
|
||||||
|
|
||||||
|
|
||||||
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 = build_runtime(config, containers)
|
|
||||||
|
|
||||||
with pytest.raises(SandboxStartError) as excinfo:
|
|
||||||
runtime.create(
|
|
||||||
session_id=SESSION_ID,
|
|
||||||
chat_id=CHAT_ID,
|
|
||||||
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 == str(CHAT_ID)
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_create_error_records_observability_when_container_id_missing(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
config = build_config(tmp_path)
|
|
||||||
(tmp_path / 'dependencies').mkdir()
|
|
||||||
(tmp_path / 'lambda-tools').mkdir()
|
|
||||||
containers = FakeContainers(run_result=FakeContainer(''))
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
runtime = DockerSandboxRuntime(
|
|
||||||
config,
|
|
||||||
FakeDockerClient(containers),
|
|
||||||
metrics,
|
|
||||||
tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(SandboxStartError) as excinfo:
|
|
||||||
runtime.create(
|
|
||||||
session_id=SESSION_ID,
|
|
||||||
chat_id=CHAT_ID,
|
|
||||||
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'
|
|
||||||
_find_increment_call(
|
|
||||||
metrics,
|
|
||||||
'sandbox.runtime.error.total',
|
|
||||||
attrs={'operation': 'create', 'error.type': 'SandboxStartError'},
|
|
||||||
)
|
|
||||||
duration_call = _find_record_call(
|
|
||||||
metrics,
|
|
||||||
'sandbox.runtime.create.duration_ms',
|
|
||||||
attrs={'operation': 'create', 'result': 'error'},
|
|
||||||
)
|
|
||||||
assert duration_call[1] >= 0
|
|
||||||
span = _find_span(
|
|
||||||
tracer,
|
|
||||||
'adapter.docker.create_sandbox',
|
|
||||||
{'chat.id': str(CHAT_ID), 'session.id': str(SESSION_ID)},
|
|
||||||
{'sandbox.result': 'error'},
|
|
||||||
)
|
|
||||||
assert excinfo.value in span.errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_stop_ignores_missing_container(tmp_path: Path) -> None:
|
|
||||||
config = build_config(tmp_path)
|
|
||||||
containers = FakeContainers()
|
|
||||||
containers.get_result = NotFound('missing')
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
runtime = DockerSandboxRuntime(
|
|
||||||
config,
|
|
||||||
FakeDockerClient(containers),
|
|
||||||
metrics,
|
|
||||||
tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
runtime.stop('container-123')
|
|
||||||
|
|
||||||
assert containers.get_calls == ['container-123']
|
|
||||||
duration_call = _find_record_call(
|
|
||||||
metrics,
|
|
||||||
'sandbox.runtime.stop.duration_ms',
|
|
||||||
attrs={'operation': 'stop', 'result': 'not_found'},
|
|
||||||
)
|
|
||||||
assert duration_call[1] >= 0
|
|
||||||
span = _find_span(
|
|
||||||
tracer,
|
|
||||||
'adapter.docker.stop_sandbox',
|
|
||||||
{'container.id': 'container-123'},
|
|
||||||
{'sandbox.result': 'not_found'},
|
|
||||||
)
|
|
||||||
assert not span.errors
|
|
||||||
stop_error_calls = [
|
|
||||||
call
|
|
||||||
for call in metrics.increment_calls
|
|
||||||
if call[0] == 'sandbox.runtime.error.total'
|
|
||||||
and call[2] is not None
|
|
||||||
and call[2].get('operation') == 'stop'
|
|
||||||
]
|
|
||||||
assert stop_error_calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_stop_wraps_docker_errors(tmp_path: Path) -> None:
|
|
||||||
config = build_config(tmp_path)
|
|
||||||
containers = FakeContainers()
|
|
||||||
containers.get_result = FailingStopContainer(
|
|
||||||
'container-123',
|
|
||||||
labels={
|
|
||||||
'session_id': str(SESSION_ID),
|
|
||||||
'chat_id': str(CHAT_ID),
|
|
||||||
'expires_at': '2026-04-02T12:05:00+00:00',
|
|
||||||
},
|
|
||||||
created_at='2026-04-02T12:00:00Z',
|
|
||||||
error=DockerException('boom'),
|
|
||||||
)
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
runtime = DockerSandboxRuntime(
|
|
||||||
config,
|
|
||||||
FakeDockerClient(containers),
|
|
||||||
metrics,
|
|
||||||
tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(SandboxError) as excinfo:
|
|
||||||
runtime.stop('container-123')
|
|
||||||
|
|
||||||
assert str(excinfo.value) == 'sandbox_stop_failed'
|
|
||||||
_find_increment_call(
|
|
||||||
metrics,
|
|
||||||
'sandbox.runtime.error.total',
|
|
||||||
attrs={'operation': 'stop', 'error.type': 'DockerException'},
|
|
||||||
)
|
|
||||||
duration_call = _find_record_call(
|
|
||||||
metrics,
|
|
||||||
'sandbox.runtime.stop.duration_ms',
|
|
||||||
attrs={'operation': 'stop', 'result': 'error'},
|
|
||||||
)
|
|
||||||
assert duration_call[1] >= 0
|
|
||||||
span = _find_span(
|
|
||||||
tracer,
|
|
||||||
'adapter.docker.stop_sandbox',
|
|
||||||
{'container.id': 'container-123'},
|
|
||||||
{
|
|
||||||
'session.id': str(SESSION_ID),
|
|
||||||
'chat.id': str(CHAT_ID),
|
|
||||||
'sandbox.result': 'error',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
cause = excinfo.value.__cause__
|
|
||||||
assert isinstance(cause, DockerException)
|
|
||||||
assert cause in span.errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_stop_records_observability_on_success(tmp_path: Path) -> None:
|
|
||||||
config = build_config(tmp_path)
|
|
||||||
containers = FakeContainers()
|
|
||||||
container = FakeListedContainer(
|
|
||||||
'container-123',
|
|
||||||
labels={
|
|
||||||
'session_id': str(SESSION_ID),
|
|
||||||
'chat_id': str(CHAT_ID),
|
|
||||||
'expires_at': '2026-04-02T12:05:00+00:00',
|
|
||||||
},
|
|
||||||
created_at='2026-04-02T12:00:00Z',
|
|
||||||
)
|
|
||||||
containers.get_result = container
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
runtime = DockerSandboxRuntime(
|
|
||||||
config,
|
|
||||||
FakeDockerClient(containers),
|
|
||||||
metrics,
|
|
||||||
tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
runtime.stop('container-123')
|
|
||||||
|
|
||||||
assert container.stop_calls == 1
|
|
||||||
duration_call = _find_record_call(
|
|
||||||
metrics,
|
|
||||||
'sandbox.runtime.stop.duration_ms',
|
|
||||||
attrs={'operation': 'stop', 'result': 'stopped'},
|
|
||||||
)
|
|
||||||
assert duration_call[1] >= 0
|
|
||||||
span = _find_span(
|
|
||||||
tracer,
|
|
||||||
'adapter.docker.stop_sandbox',
|
|
||||||
{'container.id': 'container-123'},
|
|
||||||
{
|
|
||||||
'session.id': str(SESSION_ID),
|
|
||||||
'chat.id': str(CHAT_ID),
|
|
||||||
'sandbox.result': 'stopped',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert not span.errors
|
|
||||||
stop_error_calls = [
|
|
||||||
call
|
|
||||||
for call in metrics.increment_calls
|
|
||||||
if call[0] == 'sandbox.runtime.error.total'
|
|
||||||
and call[2] is not None
|
|
||||||
and call[2].get('operation') == 'stop'
|
|
||||||
]
|
|
||||||
assert stop_error_calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_list_active_sessions_reads_valid_labeled_containers(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
config = build_config(tmp_path)
|
|
||||||
containers = FakeContainers()
|
|
||||||
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
|
|
||||||
containers.list_result = [
|
|
||||||
FakeListedContainer(
|
|
||||||
'container-123',
|
|
||||||
labels={
|
|
||||||
'session_id': str(SESSION_ID),
|
|
||||||
'chat_id': str(CHAT_ID),
|
|
||||||
'expires_at': expires_at.isoformat(),
|
|
||||||
},
|
|
||||||
created_at='2026-04-02T12:00:00Z',
|
|
||||||
),
|
|
||||||
FakeListedContainer(
|
|
||||||
'container-bad',
|
|
||||||
labels={
|
|
||||||
'chat_id': str(CHAT_ID),
|
|
||||||
'expires_at': expires_at.isoformat(),
|
|
||||||
},
|
|
||||||
created_at='2026-04-02T12:01:00Z',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
runtime = build_runtime(config, containers)
|
|
||||||
|
|
||||||
sessions = runtime.list_active_sessions()
|
|
||||||
|
|
||||||
assert sessions == [
|
|
||||||
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=expires_at,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
assert containers.list_calls == [
|
|
||||||
{'filters': {'label': ['session_id', 'chat_id', 'expires_at']}}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_list_active_records_observability(tmp_path: Path) -> None:
|
|
||||||
config = build_config(tmp_path)
|
|
||||||
containers = FakeContainers()
|
|
||||||
expires_at = datetime(2026, 4, 2, 12, 5, tzinfo=UTC)
|
|
||||||
containers.list_result = [
|
|
||||||
FakeListedContainer(
|
|
||||||
'container-123',
|
|
||||||
labels={
|
|
||||||
'session_id': str(SESSION_ID),
|
|
||||||
'chat_id': str(CHAT_ID),
|
|
||||||
'expires_at': expires_at.isoformat(),
|
|
||||||
},
|
|
||||||
created_at='2026-04-02T12:00:00Z',
|
|
||||||
),
|
|
||||||
FakeListedContainer(
|
|
||||||
'container-bad',
|
|
||||||
labels={
|
|
||||||
'chat_id': str(CHAT_ID),
|
|
||||||
'expires_at': expires_at.isoformat(),
|
|
||||||
},
|
|
||||||
created_at='2026-04-02T12:01:00Z',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
runtime = DockerSandboxRuntime(
|
|
||||||
config,
|
|
||||||
FakeDockerClient(containers),
|
|
||||||
metrics,
|
|
||||||
tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
sessions = runtime.list_active_sessions()
|
|
||||||
|
|
||||||
assert len(sessions) == 1
|
|
||||||
duration_call = _find_record_call(
|
|
||||||
metrics,
|
|
||||||
'sandbox.runtime.list_active.duration_ms',
|
|
||||||
attrs={'operation': 'list_active', 'result': 'listed'},
|
|
||||||
)
|
|
||||||
assert duration_call[1] >= 0
|
|
||||||
span = _find_span(
|
|
||||||
tracer,
|
|
||||||
'adapter.docker.list_active_sandboxes',
|
|
||||||
span_attrs={
|
|
||||||
'sandbox.container_count': 2,
|
|
||||||
'sandbox.active_count': 1,
|
|
||||||
'sandbox.result': 'listed',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert not span.errors
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_list_active_error_records_observability(tmp_path: Path) -> None:
|
|
||||||
config = build_config(tmp_path)
|
|
||||||
containers = FakeContainers()
|
|
||||||
containers.list_error = DockerException('boom')
|
|
||||||
metrics = RecordingMetrics()
|
|
||||||
tracer = RecordingTracer()
|
|
||||||
runtime = DockerSandboxRuntime(
|
|
||||||
config,
|
|
||||||
FakeDockerClient(containers),
|
|
||||||
metrics,
|
|
||||||
tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(SandboxError) as excinfo:
|
|
||||||
runtime.list_active_sessions()
|
|
||||||
|
|
||||||
assert str(excinfo.value) == 'sandbox_list_failed'
|
|
||||||
_find_increment_call(
|
|
||||||
metrics,
|
|
||||||
'sandbox.runtime.error.total',
|
|
||||||
attrs={'operation': 'list_active', 'error.type': 'DockerException'},
|
|
||||||
)
|
|
||||||
duration_call = _find_record_call(
|
|
||||||
metrics,
|
|
||||||
'sandbox.runtime.list_active.duration_ms',
|
|
||||||
attrs={'operation': 'list_active', 'result': 'error'},
|
|
||||||
)
|
|
||||||
assert duration_call[1] >= 0
|
|
||||||
span = _find_span(
|
|
||||||
tracer,
|
|
||||||
'adapter.docker.list_active_sandboxes',
|
|
||||||
span_attrs={'sandbox.result': 'error'},
|
|
||||||
)
|
|
||||||
assert isinstance(excinfo.value.__cause__, DockerException)
|
|
||||||
assert excinfo.value in span.errors
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
import threading
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from repository.sandbox_lock import ProcessLocalSandboxLifecycleLocker
|
|
||||||
|
|
||||||
CHAT_ID = UUID('77777777-7777-7777-7777-777777777777')
|
|
||||||
|
|
||||||
|
|
||||||
class LockRace:
|
|
||||||
def __init__(self, locker: ProcessLocalSandboxLifecycleLocker) -> None:
|
|
||||||
self.locker = locker
|
|
||||||
self.entered_first = threading.Event()
|
|
||||||
self.second_requested = threading.Event()
|
|
||||||
self.second_entered = threading.Event()
|
|
||||||
self.release_first = threading.Event()
|
|
||||||
self.release_second = threading.Event()
|
|
||||||
self.errors: list[Exception] = []
|
|
||||||
self.order: list[str] = []
|
|
||||||
self.first_entry: object | None = None
|
|
||||||
|
|
||||||
def run_first(self) -> None:
|
|
||||||
try:
|
|
||||||
with self.locker.lock(CHAT_ID):
|
|
||||||
self.first_entry = self.locker._locks_by_chat_id[CHAT_ID]
|
|
||||||
self.order.append('first_entered')
|
|
||||||
self.entered_first.set()
|
|
||||||
assert self.release_first.wait(timeout=1)
|
|
||||||
self.order.append('first_releasing')
|
|
||||||
except Exception as exc:
|
|
||||||
self.errors.append(exc)
|
|
||||||
|
|
||||||
def run_second(self) -> None:
|
|
||||||
try:
|
|
||||||
assert self.entered_first.wait(timeout=1)
|
|
||||||
context = self.locker.lock(CHAT_ID)
|
|
||||||
self.second_requested.set()
|
|
||||||
|
|
||||||
with context:
|
|
||||||
self.order.append('second_entered')
|
|
||||||
self.second_entered.set()
|
|
||||||
assert self.release_second.wait(timeout=1)
|
|
||||||
self.order.append('second_releasing')
|
|
||||||
except Exception as exc:
|
|
||||||
self.errors.append(exc)
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_local_sandbox_lifecycle_locker_evicts_idle_lock() -> None:
|
|
||||||
locker = ProcessLocalSandboxLifecycleLocker()
|
|
||||||
|
|
||||||
with locker.lock(CHAT_ID):
|
|
||||||
assert CHAT_ID in locker._locks_by_chat_id
|
|
||||||
assert len(locker._locks_by_chat_id) == 1
|
|
||||||
|
|
||||||
assert CHAT_ID not in locker._locks_by_chat_id
|
|
||||||
assert len(locker._locks_by_chat_id) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_local_sandbox_lifecycle_locker_keeps_shared_lock_for_waiters() -> None:
|
|
||||||
locker = ProcessLocalSandboxLifecycleLocker()
|
|
||||||
race = LockRace(locker)
|
|
||||||
first_thread = threading.Thread(target=race.run_first)
|
|
||||||
second_thread = threading.Thread(target=race.run_second)
|
|
||||||
|
|
||||||
first_thread.start()
|
|
||||||
assert race.entered_first.wait(timeout=1)
|
|
||||||
|
|
||||||
second_thread.start()
|
|
||||||
assert race.second_requested.wait(timeout=1)
|
|
||||||
assert len(locker._locks_by_chat_id) == 1
|
|
||||||
assert locker._locks_by_chat_id[CHAT_ID] is race.first_entry
|
|
||||||
assert not race.second_entered.wait(timeout=0.1)
|
|
||||||
|
|
||||||
race.release_first.set()
|
|
||||||
assert race.second_entered.wait(timeout=1)
|
|
||||||
assert len(locker._locks_by_chat_id) == 1
|
|
||||||
assert locker._locks_by_chat_id[CHAT_ID] is race.first_entry
|
|
||||||
|
|
||||||
race.release_second.set()
|
|
||||||
|
|
||||||
first_thread.join(timeout=1)
|
|
||||||
second_thread.join(timeout=1)
|
|
||||||
|
|
||||||
assert not first_thread.is_alive()
|
|
||||||
assert not second_thread.is_alive()
|
|
||||||
assert race.errors == []
|
|
||||||
assert race.order == [
|
|
||||||
'first_entered',
|
|
||||||
'first_releasing',
|
|
||||||
'second_entered',
|
|
||||||
'second_releasing',
|
|
||||||
]
|
|
||||||
assert CHAT_ID not in locker._locks_by_chat_id
|
|
||||||
assert len(locker._locks_by_chat_id) == 0
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,7 @@
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from datetime import datetime
|
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import Protocol, TypeAlias
|
from typing import Protocol, TypeAlias
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from domain.sandbox import SandboxSession
|
|
||||||
from domain.user import User
|
from domain.user import User
|
||||||
|
|
||||||
AttrValue: TypeAlias = str | int | float | bool
|
AttrValue: TypeAlias = str | int | float | bool
|
||||||
|
|
@ -19,50 +16,6 @@ class UserRepository(Protocol):
|
||||||
def save(self, user: User) -> None: ...
|
def save(self, user: User) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class SandboxSessionRepository(Protocol):
|
|
||||||
def get_active_by_chat_id(self, chat_id: UUID) -> SandboxSession | None: ...
|
|
||||||
|
|
||||||
def list_expired(self, now: datetime) -> list[SandboxSession]: ...
|
|
||||||
|
|
||||||
def count_active(self) -> int: ...
|
|
||||||
|
|
||||||
def save(self, session: SandboxSession) -> None: ...
|
|
||||||
|
|
||||||
def delete(self, session_id: UUID) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class LockContext(Protocol):
|
|
||||||
def __enter__(self) -> None: ...
|
|
||||||
|
|
||||||
def __exit__(
|
|
||||||
self,
|
|
||||||
exc_type: type[BaseException] | None,
|
|
||||||
exc: BaseException | None,
|
|
||||||
traceback: TracebackType | None,
|
|
||||||
) -> bool | None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class SandboxLifecycleLocker(Protocol):
|
|
||||||
def lock(self, chat_id: UUID) -> LockContext: ...
|
|
||||||
|
|
||||||
|
|
||||||
class SandboxRuntime(Protocol):
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session_id: UUID,
|
|
||||||
chat_id: UUID,
|
|
||||||
created_at: datetime,
|
|
||||||
expires_at: datetime,
|
|
||||||
) -> SandboxSession: ...
|
|
||||||
|
|
||||||
def stop(self, container_id: str) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class Clock(Protocol):
|
|
||||||
def now(self) -> datetime: ...
|
|
||||||
|
|
||||||
|
|
||||||
class Logger(Protocol):
|
class Logger(Protocol):
|
||||||
def debug(self, message: str, attrs: Attrs | None = None) -> None: ...
|
def debug(self, message: str, attrs: Attrs | None = None) -> None: ...
|
||||||
|
|
||||||
|
|
@ -88,13 +41,6 @@ class Metrics(Protocol):
|
||||||
attrs: Attrs | None = None,
|
attrs: Attrs | None = None,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
def set(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
value: int | float,
|
|
||||||
attrs: Attrs | None = None,
|
|
||||||
) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class Span(Protocol):
|
class Span(Protocol):
|
||||||
def set_attribute(self, name: str, value: AttrValue) -> None: ...
|
def set_attribute(self, name: str, value: AttrValue) -> None: ...
|
||||||
|
|
|
||||||
|
|
@ -1,303 +0,0 @@
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from domain.sandbox import SandboxSession
|
|
||||||
from usecase.interface import (
|
|
||||||
Clock,
|
|
||||||
Logger,
|
|
||||||
Metrics,
|
|
||||||
SandboxLifecycleLocker,
|
|
||||||
SandboxRuntime,
|
|
||||||
SandboxSessionRepository,
|
|
||||||
Tracer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CreateSandboxCommand:
|
|
||||||
chat_id: UUID
|
|
||||||
|
|
||||||
|
|
||||||
class CreateSandbox:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
repository: SandboxSessionRepository,
|
|
||||||
locker: SandboxLifecycleLocker,
|
|
||||||
runtime: SandboxRuntime,
|
|
||||||
clock: Clock,
|
|
||||||
logger: Logger,
|
|
||||||
metrics: Metrics,
|
|
||||||
tracer: Tracer,
|
|
||||||
ttl: timedelta,
|
|
||||||
) -> None:
|
|
||||||
self._repository = repository
|
|
||||||
self._locker = locker
|
|
||||||
self._runtime = runtime
|
|
||||||
self._clock = clock
|
|
||||||
self._logger = logger
|
|
||||||
self._metrics = metrics
|
|
||||||
self._tracer = tracer
|
|
||||||
self._ttl = ttl
|
|
||||||
|
|
||||||
def execute(self, command: CreateSandboxCommand) -> SandboxSession:
|
|
||||||
chat_id = command.chat_id
|
|
||||||
|
|
||||||
with self._tracer.start_span(
|
|
||||||
'usecase.create_sandbox',
|
|
||||||
attrs={'chat.id': str(chat_id)},
|
|
||||||
) as span:
|
|
||||||
try:
|
|
||||||
with self._locker.lock(chat_id):
|
|
||||||
session = self._repository.get_active_by_chat_id(chat_id)
|
|
||||||
now = self._clock.now()
|
|
||||||
|
|
||||||
if session is not None and session.expires_at > now:
|
|
||||||
span.set_attribute('session.id', str(session.session_id))
|
|
||||||
span.set_attribute('container.id', session.container_id)
|
|
||||||
span.set_attribute('sandbox.result', 'reused')
|
|
||||||
self._metrics.increment(
|
|
||||||
'sandbox.create.total',
|
|
||||||
attrs=_result_metric_attrs('reused'),
|
|
||||||
)
|
|
||||||
self._logger.info(
|
|
||||||
'sandbox_reused',
|
|
||||||
attrs=_sandbox_attrs(session),
|
|
||||||
)
|
|
||||||
return session
|
|
||||||
|
|
||||||
result = 'created'
|
|
||||||
new_session_id: UUID | None = None
|
|
||||||
if session is not None:
|
|
||||||
result = 'replaced'
|
|
||||||
new_session_id = _new_session_id()
|
|
||||||
span.set_attribute(
|
|
||||||
'sandbox.previous_session.id',
|
|
||||||
str(session.session_id),
|
|
||||||
)
|
|
||||||
span.set_attribute(
|
|
||||||
'sandbox.previous_container.id',
|
|
||||||
session.container_id,
|
|
||||||
)
|
|
||||||
span.set_attribute(
|
|
||||||
'sandbox.new_session.id',
|
|
||||||
str(new_session_id),
|
|
||||||
)
|
|
||||||
self._logger.info(
|
|
||||||
'sandbox_replaced',
|
|
||||||
attrs=_sandbox_attrs(session),
|
|
||||||
)
|
|
||||||
self._runtime.stop(session.container_id)
|
|
||||||
self._repository.delete(session.session_id)
|
|
||||||
_set_active_count(self._metrics, self._repository)
|
|
||||||
|
|
||||||
created_at = self._clock.now()
|
|
||||||
expires_at = created_at + self._ttl
|
|
||||||
if new_session_id is None:
|
|
||||||
new_session_id = _new_session_id()
|
|
||||||
span.set_attribute('session.id', str(new_session_id))
|
|
||||||
new_session = self._runtime.create(
|
|
||||||
session_id=new_session_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
created_at=created_at,
|
|
||||||
expires_at=expires_at,
|
|
||||||
)
|
|
||||||
if result == 'replaced':
|
|
||||||
span.set_attribute(
|
|
||||||
'sandbox.new_container.id',
|
|
||||||
new_session.container_id,
|
|
||||||
)
|
|
||||||
self._save_created_session(new_session)
|
|
||||||
_set_active_count(self._metrics, self._repository)
|
|
||||||
if result == 'replaced':
|
|
||||||
span.set_attribute('session.id', str(new_session.session_id))
|
|
||||||
span.set_attribute('container.id', new_session.container_id)
|
|
||||||
span.set_attribute('sandbox.result', result)
|
|
||||||
self._metrics.increment(
|
|
||||||
'sandbox.create.total',
|
|
||||||
attrs=_result_metric_attrs(result),
|
|
||||||
)
|
|
||||||
self._logger.info(
|
|
||||||
'sandbox_created',
|
|
||||||
attrs=_sandbox_attrs(new_session),
|
|
||||||
)
|
|
||||||
return new_session
|
|
||||||
except Exception as exc:
|
|
||||||
span.set_attribute('sandbox.result', 'error')
|
|
||||||
self._metrics.increment(
|
|
||||||
'sandbox.create.total',
|
|
||||||
attrs=_result_metric_attrs('error'),
|
|
||||||
)
|
|
||||||
span.record_error(exc)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _save_created_session(self, session: SandboxSession) -> None:
|
|
||||||
try:
|
|
||||||
self._repository.save(session)
|
|
||||||
except Exception as exc:
|
|
||||||
self._compensate_save_failure(session, exc)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _compensate_save_failure(
|
|
||||||
self,
|
|
||||||
session: SandboxSession,
|
|
||||||
error: Exception,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
self._runtime.stop(session.container_id)
|
|
||||||
except Exception as stop_error:
|
|
||||||
_set_active_count(self._metrics, self._repository)
|
|
||||||
raise error from stop_error
|
|
||||||
|
|
||||||
_set_active_count(self._metrics, self._repository)
|
|
||||||
|
|
||||||
|
|
||||||
class CleanupExpiredSandboxes:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
repository: SandboxSessionRepository,
|
|
||||||
locker: SandboxLifecycleLocker,
|
|
||||||
runtime: SandboxRuntime,
|
|
||||||
clock: Clock,
|
|
||||||
logger: Logger,
|
|
||||||
metrics: Metrics,
|
|
||||||
tracer: Tracer,
|
|
||||||
) -> None:
|
|
||||||
self._repository = repository
|
|
||||||
self._locker = locker
|
|
||||||
self._runtime = runtime
|
|
||||||
self._clock = clock
|
|
||||||
self._logger = logger
|
|
||||||
self._metrics = metrics
|
|
||||||
self._tracer = tracer
|
|
||||||
|
|
||||||
def execute(self) -> list[SandboxSession]:
|
|
||||||
cleaned_sessions: list[SandboxSession] = []
|
|
||||||
error_count = 0
|
|
||||||
|
|
||||||
with self._tracer.start_span(
|
|
||||||
'usecase.cleanup_expired_sandboxes',
|
|
||||||
) as span:
|
|
||||||
try:
|
|
||||||
expired_sessions = self._repository.list_expired(self._clock.now())
|
|
||||||
except Exception as exc:
|
|
||||||
span.set_attribute('sandbox.result', 'error')
|
|
||||||
self._metrics.increment(
|
|
||||||
'sandbox.cleanup.error.total',
|
|
||||||
attrs=_cleanup_error_metric_attrs(
|
|
||||||
type(exc).__name__,
|
|
||||||
'list_expired',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
span.record_error(exc)
|
|
||||||
raise
|
|
||||||
|
|
||||||
span.set_attribute('sandbox.expired_count', len(expired_sessions))
|
|
||||||
for session in expired_sessions:
|
|
||||||
with self._tracer.start_span(
|
|
||||||
'usecase.cleanup_expired_sandbox',
|
|
||||||
attrs=_sandbox_span_attrs(session),
|
|
||||||
) as cleanup_span:
|
|
||||||
try:
|
|
||||||
cleaned_session = self._cleanup_session(session)
|
|
||||||
except Exception as exc:
|
|
||||||
error_count += 1
|
|
||||||
cleanup_span.set_attribute('sandbox.result', 'error')
|
|
||||||
cleanup_span.record_error(exc)
|
|
||||||
self._metrics.increment(
|
|
||||||
'sandbox.cleanup.error.total',
|
|
||||||
attrs=_error_metric_attrs(type(exc).__name__),
|
|
||||||
)
|
|
||||||
attrs = _sandbox_attrs(session)
|
|
||||||
attrs['error'] = type(exc).__name__
|
|
||||||
self._logger.error(
|
|
||||||
'sandbox_clean_failed',
|
|
||||||
attrs=attrs,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if cleaned_session is None:
|
|
||||||
cleanup_span.set_attribute('sandbox.result', 'skipped')
|
|
||||||
continue
|
|
||||||
|
|
||||||
cleanup_span.set_attribute('sandbox.result', 'cleaned')
|
|
||||||
cleaned_sessions.append(cleaned_session)
|
|
||||||
self._metrics.increment(
|
|
||||||
'sandbox.cleanup.total',
|
|
||||||
attrs=_result_metric_attrs('cleaned'),
|
|
||||||
)
|
|
||||||
self._logger.info(
|
|
||||||
'sandbox_cleaned',
|
|
||||||
attrs=_sandbox_attrs(cleaned_session),
|
|
||||||
)
|
|
||||||
|
|
||||||
span.set_attribute('sandbox.cleaned_count', len(cleaned_sessions))
|
|
||||||
span.set_attribute('sandbox.error_count', error_count)
|
|
||||||
span.set_attribute(
|
|
||||||
'sandbox.result',
|
|
||||||
'completed' if error_count == 0 else 'completed_with_errors',
|
|
||||||
)
|
|
||||||
return cleaned_sessions
|
|
||||||
|
|
||||||
def _cleanup_session(self, session: SandboxSession) -> SandboxSession | None:
|
|
||||||
with self._locker.lock(session.chat_id):
|
|
||||||
current_session = self._repository.get_active_by_chat_id(session.chat_id)
|
|
||||||
now = self._clock.now()
|
|
||||||
if current_session is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if current_session.session_id != session.session_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if current_session.expires_at > now:
|
|
||||||
return None
|
|
||||||
|
|
||||||
self._runtime.stop(current_session.container_id)
|
|
||||||
self._repository.delete(current_session.session_id)
|
|
||||||
_set_active_count(self._metrics, self._repository)
|
|
||||||
return current_session
|
|
||||||
|
|
||||||
|
|
||||||
def _new_session_id() -> UUID:
|
|
||||||
return uuid4()
|
|
||||||
|
|
||||||
|
|
||||||
def _sandbox_attrs(session: SandboxSession) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
'chat_id': str(session.chat_id),
|
|
||||||
'session_id': str(session.session_id),
|
|
||||||
'container_id': session.container_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _sandbox_span_attrs(session: SandboxSession) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
'chat.id': str(session.chat_id),
|
|
||||||
'session.id': str(session.session_id),
|
|
||||||
'container.id': session.container_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _result_metric_attrs(result: str) -> dict[str, str]:
|
|
||||||
return {'result': result}
|
|
||||||
|
|
||||||
|
|
||||||
def _error_metric_attrs(error_type: str) -> dict[str, str]:
|
|
||||||
return {'error.type': error_type}
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_error_metric_attrs(
|
|
||||||
error_type: str,
|
|
||||||
reason: str,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
'error.type': error_type,
|
|
||||||
'reason': reason,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _set_active_count(
|
|
||||||
metrics: Metrics,
|
|
||||||
repository: SandboxSessionRepository,
|
|
||||||
) -> None:
|
|
||||||
metrics.set('sandbox.active.count', repository.count_active())
|
|
||||||
176
uv.lock
generated
176
uv.lock
generated
|
|
@ -50,51 +50,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cffi"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.6"
|
version = "3.4.6"
|
||||||
|
|
@ -173,73 +128,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cryptography"
|
|
||||||
version = "46.0.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "docker"
|
|
||||||
version = "7.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
|
||||||
{ name = "requests" },
|
|
||||||
{ name = "urllib3" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.135.1"
|
version = "0.135.1"
|
||||||
|
|
@ -359,7 +247,6 @@ name = "master"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "docker" },
|
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "opentelemetry-api" },
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||||
|
|
@ -375,13 +262,11 @@ dev = [
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "types-docker" },
|
|
||||||
{ name = "types-pyyaml" },
|
{ name = "types-pyyaml" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "docker", specifier = ">=7.1.0" },
|
|
||||||
{ name = "fastapi", specifier = ">=0.116.1" },
|
{ name = "fastapi", specifier = ">=0.116.1" },
|
||||||
{ name = "opentelemetry-api", specifier = ">=1.31.1" },
|
{ name = "opentelemetry-api", specifier = ">=1.31.1" },
|
||||||
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.31.1" },
|
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.31.1" },
|
||||||
|
|
@ -397,7 +282,6 @@ dev = [
|
||||||
{ name = "mypy", specifier = ">=1.18.2" },
|
{ name = "mypy", specifier = ">=1.18.2" },
|
||||||
{ name = "pytest", specifier = ">=8.4.2" },
|
{ name = "pytest", specifier = ">=8.4.2" },
|
||||||
{ name = "ruff", specifier = ">=0.13.1" },
|
{ name = "ruff", specifier = ">=0.13.1" },
|
||||||
{ name = "types-docker", specifier = ">=7.1.0.20260402" },
|
|
||||||
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
|
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -617,15 +501,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pycparser"
|
|
||||||
version = "3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
version = "2.12.5"
|
||||||
|
|
@ -728,19 +603,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pywin32"
|
|
||||||
version = "311"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
|
|
@ -829,32 +691,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "types-docker"
|
|
||||||
version = "7.1.0.20260402"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "types-paramiko" },
|
|
||||||
{ name = "types-requests" },
|
|
||||||
{ name = "urllib3" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/b6/e9dd7b51f5db0df219aed496e5f1fe4e4e17828cd9c354fc6a98a4454ea1/types_docker-7.1.0.20260402.tar.gz", hash = "sha256:2df72c6268a815f4ba28fe2556072a99b8acffe46370fa4d27c485355af2e37e", size = 32974, upload-time = "2026-04-02T04:22:54.318Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/8c/5056b3f6ba818dc9f677f9e7aa9044113657a8b67871ed30921d3712b2f2/types_docker-7.1.0.20260402-py3-none-any.whl", hash = "sha256:88055c13ad43a3d13415db42d180193e812e6a25d0b50710d6559e86cb9345e5", size = 47455, upload-time = "2026-04-02T04:22:53.426Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "types-paramiko"
|
|
||||||
version = "4.0.0.20260402"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "cryptography" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/91/63f4fa68a7d563ca54f62c013aeefb480a2e18a0ee0623a56119f1662bf2/types_paramiko-4.0.0.20260402.tar.gz", hash = "sha256:a9287bdb78cb67c8e79897dc98b761900968cd2de288f72cc298c1a25cde6a38", size = 29105, upload-time = "2026-04-02T04:20:26.809Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/5e/d650556433e9b745fb85676f4382b7ec0f35156696d946b9bf5ac470358b/types_paramiko-4.0.0.20260402-py3-none-any.whl", hash = "sha256:38ef646f54d5410012d8607b9f023a355ecaec12b749d0325bce5f16706f6183", size = 38816, upload-time = "2026-04-02T04:20:25.788Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-pyyaml"
|
name = "types-pyyaml"
|
||||||
version = "6.0.12.20250915"
|
version = "6.0.12.20250915"
|
||||||
|
|
@ -864,18 +700,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "types-requests"
|
|
||||||
version = "2.33.0.20260402"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "urllib3" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c1/7b/a06527d20af1441d813360b8e0ce152a75b7d8e4aab7c7d0a156f405d7ec/types_requests-2.33.0.20260402.tar.gz", hash = "sha256:1bdd3ada9b869741c5c4b887d2c8b4e38284a1449751823b5ebbccba3eefd9da", size = 23851, upload-time = "2026-04-02T04:19:55.942Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/65/3853bb6bac5ae789dc7e28781154705c27859eccc8e46282c3f36780f5f5/types_requests-2.33.0.20260402-py3-none-any.whl", hash = "sha256:c98372d7124dd5d10af815ee25c013897592ff92af27b27e22c98984102c3254", size = 20739, upload-time = "2026-04-02T04:19:54.955Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue