commit 470d9d6f1e4fe4f594e37c2ba59550ee5ca54fc1 Author: Mikhail Verkovykh Date: Thu Sep 10 18:01:36 2026 +0300 Подготовить skeleton лабораторной 6 по мессенджеру diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bc16e55 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.github +.env +.env.* +!.env.example +secrets +certs +volumes +data +backups +*.key +*.pem +*.p12 +*.pfx +*.jks +.venv +__pycache__ +node_modules +evidence +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b32655 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Copy to .env for your LOCAL synthetic test environment; never commit .env. +# These are logical interface suggestions; map actual framework names in REPORT.md. +APP_HOST=0.0.0.0 +APP_PORT=8080 +APP_ENV=lab +SESSION_TTL_SECONDS=3600 +# Choose credentials locally. File paths contain no secret values. +DATABASE_URL_FILE=/run/secrets/database_url +# Optional on 3, required use on 4 in lab2; Redis OR Valkey: +SESSION_STORE=postgres +CACHE_URL_FILE=/run/secrets/cache_url +API_REPLICAS=2 +IDEMPOTENCY_TTL_SECONDS=86400 +S3_ENDPOINT=http://s3:9000 +S3_REGION=us-east-1 +S3_BUCKET=messenger-attachments +S3_ACCESS_KEY_ID_FILE=/run/secrets/s3_access_key_id +S3_SECRET_ACCESS_KEY_FILE=/run/secrets/s3_secret_access_key +MAX_UPLOAD_BYTES=10485760 +MAX_IMAGE_PIXELS=40000000 +BROKER_URL_FILE=/run/secrets/broker_url +WORKER_CONCURRENCY=2 +JOB_MAX_ATTEMPTS=5 +JOB_TIMEOUT_SECONDS=60 +STAGING_RETENTION_SECONDS=86400 +OTEL_SERVICE_NAME=messenger-api +# Worker must set a distinct service.name. +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=lab diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..59c9f4e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Лабораторная 6 + +Автор / группа: TODO +Целевая оценка (3/4/5): TODO +Трек 5, если заявлен: TODO + +Что реализовано и что перенесено: TODO +Команды запуска из чистого clone: TODO +Результаты проверок и ссылка на REPORT.md: TODO +Ограничения / известные ошибки: TODO +Источники и использование ИИ: TODO + +- [ ] Весь обязательный минимум текущей и предыдущих работ сохранён с объявленными переходами. +- [ ] Контракт и публичные тесты не ослаблены. +- [ ] make check и make test выполнены; приложен реальный вывод. +- [ ] Сценарии отказов/безопасности своего уровня воспроизведены. +- [ ] Все секреты и приватные ключи находятся вне Git; отчёт не содержит tokens. +- [ ] README задания сохранён, REPORT.md содержит инструкцию именно моего решения. diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 0000000..11cf289 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,16 @@ +name: Skeleton contracts +on: [push, pull_request] +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -r requirements-dev.txt + - run: make check validate PYTHON=python +# This job validates the skeleton, not a student solution. +# Add build, readiness and make test in a separate application job. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5a7189 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Local/private state +.env +.env.* +!.env.example +secrets/ +certs/private/ +*.key +*.p12 +*.pfx +*.jks +*.pem +*.tfstate* +volumes/ +data/ +backups/ +# Runtime/build artifacts +.venv/ +__pycache__/ +*.py[cod] +node_modules/ +dist/ +build/ +target/ +bin/ +obj/ +coverage/ +*.log +.DS_Store +.idea/ +.vscode/ +# Keep small, sanitized evidence summaries in evidence/ under version control. +# Inspect git diff --cached: ignore rules are not a secret scanner. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ee5533d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,19 @@ +# Работа через fork и pull request + +1. После публикации преподавателем сделайте fork репозитория **текущей** лабораторной в своём аккаунте и clone своего fork. URL преподаватель выдаёт отдельно. +2. Создайте ветку `solution/-` от исходной ветки skeleton. Ветка преподавателя и файлы контракта остаются основой проверки. +3. Для lab2–7 перенесите из своей предыдущей лабы только исходники, миграции, собственные тесты, Dockerfile и нужные конфигурации. Не копируйте `.git`, `.env`, credentials, volumes, `README.md`, `COURSE.md`, публичные `tests/` и `contracts/` поверх новой версии. Сначала сравните изменения API в `COURSE.md`. +4. Реализуйте задание, добавьте свою инструкцию запуска и заполните `REPORT.md`. Публичные проверки дополняйте отдельными тестами, не ослабляйте assertions. +5. Выполните `make check`, запустите свой стенд, затем `make test`. Выполните сценарии отказов текущей лабы. Их запуск всегда явный: scaffold сам не останавливает контейнеры. +6. Commit и push делайте в свой fork. Откройте PR в исходный репозиторий преподавателя, заголовок: `ЛР N — Группа — Фамилия — на 3/4/5`. Выберите правильную базовую ветку и заполните шаблон. +7. После замечаний обновляйте тот же PR. Чужие решения не переносите без указания источника и согласованных правил курса. + +Платформа может называть PR «merge request» — процесс тот же. `.github/` содержит необязательные удобства для GitHub; проверки локально от платформы не зависят. Инициализация и публикация исходных Git-репозиториев выполняются преподавателем отдельно. + +## Правила изменения skeleton + +Не удаляйте обязательные endpoint, поля, рубрику и публичные проверки. Изменение контракта согласуйте отдельным комментарием/PR; обход тестов не является решением. Код приложения размещайте в `src/` либо в принятой для языка структуре. Собственные тесты — `student-tests/` или штатный каталог фреймворка. Ссылки на результаты проверки и команды запуска должны переживать clone на другой машине. + +## CI + +Готовый GitHub workflow проверяет целостность контракта и синтаксис Python-проверок. Он **не запускает отсутствующее приложение** и **не подтверждает оценку**. Студент добавляет сборку своего приложения, запуск/готовность стенда и `make test` в отдельный job. Не используйте `pull_request_target` для запуска кода из fork, не выдавайте CI секреты или доступ к общему Docker-host. diff --git a/COURSE.md b/COURSE.md new file mode 100644 index 0000000..b02172b --- /dev/null +++ b/COURSE.md @@ -0,0 +1,53 @@ +# Сквозной проект: мессенджер под нагрузкой + +Семь самостоятельных репозиториев содержат задания и проверяемые контракты одной системы. Вы переносите собственную реализацию между лабораторными. Язык, фреймворк и библиотеки выбираете сами. Web UI, WebSocket, редактирование/удаление сообщений, публичный поиск пользователей и Kubernetes не входят в обязательный минимум: достаточно REST API. + +| Лаба | Новая инженерная задача | Что появляется в системе | +| --- | --- | --- | +| 1 | Контракт, процессы и контейнер | In-memory API, Dockerfile, HTTP smoke | +| 2 | Сохранность и идентификация | PostgreSQL, Basic → сессия; Redis/Valkey на 4 | +| 3 | Горизонтальное масштабирование | Балансировщик, ≥2 API-реплики, общие сессии, идемпотентность | +| 4 | Бинарные данные | Приватное S3-совместимое хранилище и вложения | +| 5 | Фоновая работа | Брокер, worker, состояния вложений и преобразование изображений | +| 6 | Диагностика | OTel Collector, хранилища сигналов, Grafana | +| 7 | Защита и эксплуатация | Argon2id, TLS/mTLS, OpenBao, минимальные привилегии | + +## Как оценивать + +Оценки накопительные **внутри лабораторной**: 4 = весь уровень 3 + весь уровень 4; 5 = весь уровень 4 + **один** полностью выполненный трек 5 на выбор. Несколько недоделанных треков не заменяют один завершённый. Условие «весь уровень» включает реализацию, проверку и объяснение на защите. Красивые схемы и дополнительные технологии не компенсируют неработающий обязательный сценарий. + +Для начала следующей лабы достаточно обязательного минимума предыдущей. Сохраняются предыдущие обязательные возможности, с явно описанными переходами API ниже. Не требуется сначала получить 5 за все ранние лабы. Можно исправлять свою базу по ходу курса; такие исправления выделяйте в PR. Реализация на 3 работоспособна, но не претендует на промышленную готовность. Даже 5 в lab7 — учебная доказанная защита в заявленной модели угроз, а не сертификат полной безопасности. + +На 3 студент самостоятельно собирает рабочую систему. На 4 показывает практики, ожидаемые от стажёра/джуна в команде: воспроизводимость, диагностику, обработку ошибок и проверки отказов. На 5 формулирует гипотезу, ставит эксперимент, приводит измерения и обсуждает границы решения — это предмет конкретной похвалы магистранту. + +## Контракт и совместимость + +`contracts/openapi.json` — OpenAPI 3.1.0, `contracts/README.md` — нормативная семантика. Контракт каждого репозитория самодостаточен; соседние папки на машине преподавателя не нужны. `tests/` — публичные проверки, а не эталонное решение. Контракт, рубрика и публичные тесты имеют приоритет над случайным поведением библиотек. При противоречии создайте вопрос преподавателю; не подгоняйте тест под приложение. + +Три объявленных изменения обязательного API: + +1. **1 → 2:** регистрация требует `password`; `X-User-Id` заменяется Bearer. Basic используется только при создании сессии. +2. **2 → 3:** `Idempotency-Key` обязателен для отправки сообщения; ответы API содержат `X-Instance-Id`. +3. **4 → 5:** загрузка вложения возвращает `202` вместо `201`, состояние становится асинхронным. Клиент опрашивает метаданные. Старое текстовое сообщение остаётся валидным: `attachment_ids` по умолчанию `[]`. + +У lab7 меняется транспорт на HTTPS, бизнес-эндпоинты остаются прежними. Внутреннее mTLS не заменяет пользовательскую сессию. Новые возможности на 4/5 добавляйте обратно совместимо, в отдельном `contracts/extensions.openapi.json` или документе; обязательный контракт сохраняйте. + +## Общая предметная модель + +Пользователь (`username`, `display_name`) состоит в чатах. Список участников задаётся при создании чата и в обязательном API не изменяется. В чате отправляются сообщения, с lab4 — со ссылками на вложения этого чата. Сервер назначает UUID и время. Порядок истории — `(created_at, id)` по возрастанию. Для защиты достаточно показать взаимодействие нескольких API-клиентов; фронтенд не требуется. + +С lab2 данные PostgreSQL — источник истины. Сессии могут храниться в PostgreSQL или Redis/Valkey, если выполняют контракт срока жизни и перезапуска. Файлы с lab4 живут в S3, метаданные — в PostgreSQL. Redis и Valkey — альтернативы: оба одновременно не нужны. Аналогично выбирается одно S3-хранилище, один брокер и один основной путь оркестрации. + +## Воспроизводимость и честные измерения + +- Укажите версии runtime и контейнерных образов, аппаратную конфигурацию, CPU/RAM-лимиты, команду запуска и время прогрева. Для образов используйте явный тег версии или digest, не `latest`. +- Все тестовые пользователи и изображения синтетические. `make test` создаёт данные: запускайте его на учебном стенде. Не публикуйте пароли, токены, приватные ключи, персональные данные или подписанные URL. +- Минимальный отчёт о нагрузке: длительность, конкуренция/интенсивность, успешные RPS, доля ошибок, p50/p95/p99, CPU/RAM, размер набора данных. Универсального проходного RPS для разных ноутбуков нет. +- Сравнивайте одинаковые запросы при одинаковых ресурсах. Успешный `/health/live` не доказывает сохранность сообщений, а HTTP `202` — окончание обработки. +- Один Docker-host не обеспечивает отказоустойчивость к потере этого host. Несколько API-процессов не делают единственную БД или LB высокодоступными. Отдельно называйте, какие отказы выдерживает эксперимент. + +## Что сдавать + +Исходники, Dockerfile, нужные манифесты и конфигурации, миграции, собственные тесты, заполненный `REPORT.md`, сведения об использовании внешнего кода и ИИ. Уметь объяснить и изменить своё решение на защите обязательно. Приложите команды и текстовые результаты; скриншоты — дополнение, а не единственное доказательство. + +PR должен собираться из чистого clone по инструкции автора. Секреты создаются отдельно; тестовые fixtures и открытые конфигурации хранятся в репозитории. Перенос кода между лабами описан в [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/Dockerfile.example b/Dockerfile.example new file mode 100644 index 0000000..e333a12 --- /dev/null +++ b/Dockerfile.example @@ -0,0 +1,8 @@ +# Teaching outline, not a runnable Dockerfile. Implement your own Dockerfile. +# 1. Choose a runtime/toolchain version and pin the base image tag/digest. +# 2. Copy dependency manifests first; install from the language lockfile. +# 3. Copy source and build. For compiled languages use a separate runtime stage. +# 4. Run the HTTP process as PID 1; listen on 0.0.0.0:8080. +# 5. Add non-root, signals and healthcheck according to the current rubric. +# 6. Pass runtime config/secrets at startup, never COPY .env or secret files. +# This file deliberately has no FROM/CMD: selecting/implementing them is the task. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..48b24e7 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,38 @@ +# Памятка преподавателю — лабораторная 6 + +## Что подготовлено + +Язык реализации не задан. README содержит накопительные уровни 3/4/5, сценарий защиты и вопросы. В каждой лабе собственная полная версия OpenAPI, поэтому fork не зависит от соседних каталогов. Публичный HTTP smoke не содержит реализации сервера; Dockerfile.example и compose.example.yaml (если есть) — задания/ориентиры, не готовый deployment. + +## Перед публикацией + +1. Выберите GitHub/GitLab и организацию, опубликуйте каждый каталог как отдельный template/skeleton repository, сообщите студентам URL и правила именования PR/MR. Эти материалы не публикуют репозитории автоматически. +2. Задайте default branch, защиту исходных материалов и правила приёма. `.github/` уже содержит шаблон PR и CI контракта; в GitLab перенесите эквивалентные настройки, локальные Makefile-команды одинаковы. +3. Сообщите, индивидуальная работа или командная, сроки, правила использования стороннего кода/ИИ и ресурсы демонстрационного стенда. Эти организационные решения намеренно не выдуманы в задании. +4. Уточните ограничения площадки (например доступна ли виртуализация/несколько узлов). Для 5 предусмотрено по два трека: один можно выполнить без настоящего multi-node cloud. Универсальный проходной RPS не установлен. + +## Что проверять + +| Проверка | Что подтверждает | Чего не подтверждает | +| --- | --- | --- | +| make check | Структура файлов, JSON, локальные refs, синтаксис Python | Реализация/запуск API | +| make validate | Полная OpenAPI 3.1, JSON Schema и примеры | Поведение сервера | +| make test | Один сквозной контрактный сценарий текущей лабы | Все ошибки, persistence, HA, security | +| test-persistence (lab2+) | Данные/прежний токен после вызова restart hook | Реальный перезапуск, если hook — no-op | +| test-failover (lab3+) | Смена обслуживающей реплики, сохранность, retry без дубля | Несколько физических узлов, HA DB/LB | +| test-security (lab7) | Доверенный HTTPS и отказ недоверенного CA | Внутреннее mTLS/identity/ACL/OpenBao | +| Защита и тесты студента | Реальность инфраструктуры и критерии выбранного уровня | Неограниченная промышленная безопасность | + +Проверяйте реальные контейнеры/логи процессов и данные до/после отказа: одного X-Instance-Id недостаточно. Для 4 требуются все пункты 3+4, для 5 — все пункты 3+4 и один **завершённый** трек 5. Сравнивайте подтверждённые результаты со сформулированными критериями, а не количество установленных сервисов. + +## Приём PR + +Проверка запускается на изолированном учебном окружении с синтетическими secrets, не на общей production БД. Студенческие Dockerfile/hooks/CI — выполняемый код: обычный fork workflow без секретов и без доступа к общему Docker socket. Не запускайте PR через pull_request_target с привилегиями преподавателя. + +Исходные README/контракт/публичные tests сохраняются, реализация переносится студентом между лабами через собственные исходники и миграции. Исправление общей спецификации публикуйте отдельным изменением, объясняя влияние на все затронутые лабораторные. + +## Проверка этой версии skeleton + +На 2026-09-10 выполнены структурные проверки и полная валидация всех семи OpenAPI/JSON Schema, проверка локальных Markdown-ссылок/YAML и декодирование PNG fixture. HTTP-клиенты проверены на временном изолированном тестовом стенде: позитивный smoke 1–7, отрицательные варианты неправильного sender, pagination, ACL, logout, bytes и пустого API; также пути persistence/failover/load и внешнего TLS. + +Временный стенд не включён в студенческие каталоги и не является эталонным решением. Реальное приложение, Compose, PostgreSQL/S3/broker и mTLS/OpenBao в skeleton отсутствуют: их работоспособность не заявляется. Docker CLI в среде подготовки не был доступен. Это проверка материалов и тестовых драйверов, не приёмка будущих решений студентов. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5d86d17 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +PYTHON ?= python3 +BASE_URL ?= http://localhost:8080 +CA_FILE ?= +ACTION_SCRIPT ?= +TLS_ARGS = $(if $(CA_FILE),--ca-file "$(CA_FILE)",) + +.PHONY: help check validate test load test-persistence test-failover + +help: + @echo "check: offline structure; validate: full OpenAPI (requirements-dev.txt); test: running API; load: synthetic writes" + +check: + $(PYTHON) scripts/check_contract.py + +validate: + $(PYTHON) scripts/validate_openapi.py + +test: + $(PYTHON) tests/smoke.py --base-url "$(BASE_URL)" $(TLS_ARGS) + +load: + $(PYTHON) tests/load.py --base-url "$(BASE_URL)" $(TLS_ARGS) + +test-persistence: + @test -n "$(ACTION_SCRIPT)" || (echo "Set ACTION_SCRIPT to your implemented executable restart hook"; exit 2) + $(PYTHON) tests/resilience.py --mode persistence --action-script "$(ACTION_SCRIPT)" --base-url "$(BASE_URL)" $(TLS_ARGS) + +test-failover: + @test -n "$(ACTION_SCRIPT)" || (echo "Set ACTION_SCRIPT to your implemented executable stop-one hook"; exit 2) + $(PYTHON) tests/resilience.py --mode failover --action-script "$(ACTION_SCRIPT)" --base-url "$(BASE_URL)" $(TLS_ARGS) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a607238 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Лабораторная 6. Наблюдаемость: OpenTelemetry и Grafana + +Научиться находить причину деградации через метрики, логи и трассы всей системы, включая фоновую обработку. + +Это **skeleton**, а не готовый сервер. Здесь есть задание, контракт и публичные проверки. Реализацию приложения, Dockerfile и требуемую инфраструктуру пишет студент. Язык и framework свободные; UI не обязателен. + +[Карта курса](COURSE.md) · [Процесс fork/PR](CONTRIBUTING.md) · [OpenAPI](contracts/openapi.json) · [Семантика API](contracts/README.md) · [Проверки](tests/README.md) · [Отчёт](REPORT.md) · [Примеры JSON](contracts/examples.json) · [Памятка преподавателю](MAINTAINERS.md) + +## Откуда начинаем + +Перенесите минимум lab5. Бизнес-API не меняется. Должны оставаться API-реплики, БД, S3, broker и worker. + +**Архитектура:** API/worker → OTLP → OTel Collector → хранилища metrics/logs/traces → Grafana; queue context связывает API и worker. + +## Что сделать + +1. Выберите хранилища: например Prometheus для метрик, Loki для логов, Tempo для трасс. Разрешена другая совместимая комбинация с Grafana; Grafana сама не хранит все три сигнала. +2. Добавьте SDK-инструментацию API и worker и отдельный Collector в общий Compose/Swarm; настройте receivers/processors/exporters и хранение данных. +3. Реализуйте набор сигналов contracts/observability.md и импортируемые dashboard/datasources. Не используйте user_id/chat_id/message_id как labels метрик. +4. Проверьте связь запроса, структурного лога и обработки задания. Секреты, тела сообщений и изображения не должны попадать в telemetry. +5. Создайте нагрузку, внедрите два ограниченных отказа и найдите их причину только с помощью сохранённых наблюдений; приложите доказательства. + +## Оценка «3»: работающий минимум + +- Весь минимум lab5 работает. Collector, хранилища трёх сигналов и Grafana включены в общий воспроизводимый стенд; конфиги и provisioning лежат в Git, persistent volumes сохраняют нужную историю. +- API и worker отдают OTLP в Collector. В Grafana видны: RPS/errors/duration API, успешные/ошибочные jobs и duration worker; доступны структурные логи и реальные трассы API и worker. +- Есть импортируемый dashboard, а не только screenshot. Сохраняются низкокардинальные атрибуты и идентификаторы trace/span в логах; содержимое сообщений, пароли/токены не экспортируются. +- На контролируемом запросе можно найти trace и связанный лог; на job — worker trace и лог. make test проходит при включённой телеметрии. Объяснено назначение каждого хранилища. + +## Оценка «4»: уровень стажёра/джуна + +Весь уровень 3 **и все** пункты ниже: + +- W3C trace context переносится API → broker → worker; видны producer/consumer spans с корректной связью parent или link. По одному upload найдена цепочка до S3/DB результата, включая retry. +- Dashboard показывает p50/p95/p99, долю ошибок, queue depth/возраст старейшей job, DB pool и saturation worker. Приведены запросы и единицы измерения; percentile вычисляется из histogram, не средних. +- Задан проверяемый SLI/SLO и окно; сохранены alert rules минимум для высокого error rate и растущего queue lag. Alerts проверены искусственным отказом; доставка наружу не нужна, достаточно локального состояния firing/resolved. +- Collector имеет memory limiter/batch и ограниченную буферизацию/поведение при недоступном backend. Остановка telemetry-хранилища не ломает сообщения и не вызывает бесконечный рост RAM. Отчёт содержит разбор двух инцидентов и шаги диагностики. + +## Оценка «5»: исследование и доказанный результат + +Весь уровень 4 **и один законченный трек на выбор**. Приведите гипотезу, повторяемую методику, результаты и ограничения. + +**Трек 1. Цена наблюдаемости.** Сравните telemetry off/on и ≥2 sampling/aggregation настройки при одинаковой нагрузке: latency, CPU/RAM, объём ingest, потери spans. Объясните, какие ошибки и редкие медленные запросы теряются; предложите и проверьте сбалансированную конфигурацию. + +**Трек 2. SLO и обнаружение инцидентов.** Обоснуйте SLO для API и времени до ready, реализуйте burn-rate alerts с несколькими окнами, воспроизведите краткий всплеск и длительную деградацию. Измерьте время обнаружения/восстановления, ложные срабатывания и покажите runbook, по которому другой студент находит причину. + +## Как начать и проверить + +```sh +# Работает прямо в skeleton, Python 3.9+; приложение не запускается: +make check + +# После реализации запустите свой сервер/стенд по REPORT.md. +# По умолчанию тест использует http://localhost:8080. +make test +``` + +`.env.example`, `compose.example.yaml` и `contracts/infrastructure.json` описывают настройки и роли компонентов. **compose.example.yaml — список ролей с пустым services, не запускаемый стенд.** Реализуйте свой `compose.yaml`/`stack.yaml`, закрепите версии образов, заполните локальный `.env` и опишите bootstrap/миграции. Все зависимости поднимаются из этого репозитория; ссылка «у меня БД уже установлена» не заменяет воспроизводимость. + +```sh +# Compose-вариант после реализации: +docker compose --env-file .env config --quiet +docker compose --env-file .env up -d --build +# Дождитесь /health/ready; затем make test. +# Для Swarm укажите эквивалентные build/push/deploy-команды в REPORT.md. +``` + + +Примеры запросов доступны в `examples/requests.http`, примеры JSON — в `contracts/examples.json`. Для полной проверки схемы (по желанию локально; CI делает её автоматически): + +```sh +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements-dev.txt +make validate PYTHON=.venv/bin/python +``` + +Smoke не выставляет оценку автоматически. Проверку сохранности/отказов запускайте явно по [tests/README.md](tests/README.md), остальные доказательства приведите в отчёте. + +## Сценарий защиты + +1. С нуля поднять стенд, импортировать provisioning без ручного набора dashboard, запустить smoke и нагрузку. +2. Показать свежие метрики, одну трассу и соответствующий структурный лог API и worker; проверить, что это данные своего приложения. +3. Для 4: пройти от upload до worker в одном trace/связанных traces, остановить worker или ограничить DB, увидеть firing и затем resolved. +4. Остановить telemetry backend, проверить сохранение работы API и ограниченное поведение exporter. Для 5: повторить эксперимент. + +## Что должно быть в PR + +Collector configuration, конфиги backend, Grafana provisioning/dashboards, alert rules по уровню, схема сигналов и отчёт инцидентов. + +Заполните `REPORT.md` и [шаблон PR](.github/pull_request_template.md), укажите целевую оценку/трек. Не меняйте обязательный контракт и публичные tests ради зелёного результата. Сохраняйте возможности предыдущих лабораторных в пределах [объявленных переходов](COURSE.md). + +## Вопросы на защите + +- Чем лог, метрика и trace отвечают на разные вопросы об одном инциденте? +- Почему chat_id в labels может исчерпать хранилище? +- Как очередь переносит причинную связь между процессами? +- Почему среднее latency не заменяет p99 и почему нельзя усреднять p99 реплик? + +## Первичные материалы + +- [OTel Collector configuration](https://opentelemetry.io/docs/collector/configuration/) +- [OTel propagation](https://opentelemetry.io/docs/concepts/context-propagation/) +- [W3C Trace Context](https://www.w3.org/TR/trace-context/) +- [Prometheus histograms](https://prometheus.io/docs/practices/histograms/) +- [Grafana provisioning](https://grafana.com/docs/grafana/latest/administration/provisioning/) diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 0000000..b97ae42 --- /dev/null +++ b/REPORT.md @@ -0,0 +1,37 @@ +# Отчёт — лабораторная 6 + +- Автор, группа: TODO +- Целевая оценка; выбранный трек 5, если нужен: TODO +- Commit/PR: TODO после публикации +- Использованные источники, библиотеки и помощь ИИ: TODO + +## Запуск из чистого clone + +TODO: версии runtime/образов, prerequisites, команды создания локальных secrets/.env, build, миграций, запуска, ожидания готовности, тестов и остановки. Указать необходимые действия оператора. Не вставлять секреты. Отдельно указать безопасную остановку и команду намеренного удаления учебных данных. + +## Архитектура и решения + +TODO: схема процессов/хранилищ, источник истины, транзакционные границы, ограничения. Что перенесено из lab5, что изменено в этой работе. Соответствие переменных из .env.example реальной конфигурации. + +## Доказательства критериев + +| Критерий README | Команда/сценарий | Наблюдаемый результат | Файл доказательства | +| --- | --- | --- | --- | +| Публичный smoke | make test | TODO | TODO | +| Остальные пункты уровня 3 | TODO | TODO | TODO | +| Все пункты уровня 4, если заявлен | TODO | TODO | TODO | +| Выбранный трек 5, если заявлен | TODO | TODO | TODO | + +TODO: добавить отдельную строку для каждого заявленного критерия. PASS без команды и наблюдения не является доказательством. Проверка инфраструктурного hook требует подтверждения, какие реальные процессы/хранилища перезапущены. + +## Эксперименты и отказы + +TODO: гипотеза, hardware/лимиты, версии, объём данных, профиль нагрузки, concurrency/arrival rate, длительность/прогрев, повторы, успешные RPS, ошибки, p50/p95/p99, CPU/RAM. Сохранить raw summary без tokens и объяснить ограничения измерения. + +| Воздействие | Что ожидаем | Что наблюдали | Время восстановления / потеря данных | +| --- | --- | --- | --- | +| TODO | TODO | TODO | TODO | + +## Ограничения и следующие шаги + +TODO: какие отказы выдерживаются и какие нет; что не реализовано; где учебное упрощение; какое улучшение подтверждено, а какое пока гипотеза. diff --git a/compose.example.yaml b/compose.example.yaml new file mode 100644 index 0000000..dfa1cfb --- /dev/null +++ b/compose.example.yaml @@ -0,0 +1,17 @@ +# Topology worksheet, deliberately NOT a runnable stack (no application supplied). +# Implement compose.yaml or stack.yaml; do not submit this empty file as deployment. +# Service names below are logical roles, not imposed container names. +x-lab-number: 6 +x-required-roles: + - api + - postgres + - load_balancer + - s3 + - broker + - worker + - otel_collector + - telemetry_storage + - grafana +# TODO: implement real images/builds, network wiring, volumes, healthchecks, +# credentials and bootstrap. Details: contracts/infrastructure.json and README.md. +services: {} diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..8c58b32 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,61 @@ +# Семантика API + +Этот текст дополняет [openapi.json](openapi.json). Обязательный API соответствует уровню 3 текущей лабораторной. Факультативный crop или rate limiting не становятся обязательными только из-за наличия допустимого имени варианта или кода ошибки в схеме. + +## Формат и ошибки + +- JSON — UTF-8, `Content-Type: application/json`. UUID — строка; время — RFC 3339 UTC с `Z`, назначается сервером. Часы не считаются уникальным ID. +- Каждый ответ API, включая ошибки и health, содержит непустой `X-Request-Id`. Тело ошибки: `{"error":{"code":"invalid_request","message":"...","request_id":"..."}}`; `request_id` совпадает с заголовком. Сообщение ошибки не раскрывает stack trace, SQL, credentials или наличие чужого объекта. +- Обязательные строки не состоят только из пробельных символов. `username` строго `[a-z0-9_]{3,32}`, уникален; `display_name` — 1–100 Unicode code points. JSON с лишними полями или неверными типами — `400`; malformed JSON также `400`. +- `400 invalid_request` — неверные поля, UUID, cursor, limit; `401 unauthorized` — нет действительной идентификации/сессии; `404 not_found` — объекта нет или он чужой; `409 conflict` — username занят, повторный ключ с другим сообщением, объект ещё не готов; `413 payload_too_large`; `415 unsupported_media_type`; `429 rate_limited`; `503 unavailable`. Неожиданное исключение — `500 internal_error` без внутренних деталей. +- Сначала аутентификация, затем доступ к конкретному ресурсу. Любой аутентифицированный участник чата может читать его историю и вложения и писать в него; посторонний получает `404`. Списки содержат только доступные объекты. Не используйте `403` для раскрытия существования чужого чата. +- `GET /health/live` проверяет только жизнь процесса, возвращает `200 {"status":"ok"}`. `GET /health/ready` возвращает 200, если приложение может безопасно обслуживать обязательный синхронный путь; иначе 503 в общем формате ошибки. Какие зависимости критичны, зафиксируйте в отчёте; недоступность Grafana не должна останавливать API. + +## Пользователи, чаты и сообщения + +- `POST /users` публичен; возвращает `201 User`. Пароль/его hash и сессионные данные никогда не входят в User. +- `POST /chats` принимает title и явный список существующих `member_ids`, включая вызывающего. Дубликаты, неизвестные участники и отсутствие вызывающего — 400. Чат создаётся атомарно. GET списка чатов сортируется по `(created_at, id)` по возрастанию. +- Отправитель сообщения выводится из текущей идентификации, поле `sender_id` в запросе не принимается. Текст — до 4000 Unicode code points. Нельзя отправить пустой/пробельный текст без вложений. +- История использует keyset cursor: `(created_at, id)`, по возрастанию; `limit` — целое 1–100, по умолчанию 50. Ответ `{"items":[...],"next_cursor":null}`. Непустой cursor — непрозрачная для клиента строка; последний элемент предыдущей страницы не повторяется. Cursor привязан к ресурсу/запросу: от другого чата — 400. Изменять `limit` при продолжении можно. +- `next_cursor` равен null, если на момент чтения следующей записи нет. Гарантируется отсутствие повторов при обходе неизменной истории; snapshot изоляция между несколькими HTTP-запросами не требуется. При конкурентных вставках опишите выбранную семантику и ограничения часов. +- Подтверждённое сообщение видно следующему чтению через любую API-реплику. Допускается кэш, но он не должен возвращать устаревшую историю после успешной записи. Удаление и изменение участников в обязательный API не входят. + +## Идентификация и сессии + +**Lab1:** защищённые endpoint требуют `X-User-Id: `; отсутствие/неизвестный ID — 401. Это учебный выбор пользователя: любой клиент может выдать себя за другого. + +**Lab2–7:** `POST /auth/sessions` принимает только `Authorization: Basic `, без JSON body. Username ограничен ASCII; пароль кодируется UTF-8, 12–128 code points, двоеточие в пароле допустимо. Неверные credentials — одинаковый 401 и `WWW-Authenticate: Basic realm="messenger", charset="UTF-8"`. + +Вход возвращает `201 {"token":"...","token_type":"Bearer","expires_at":"...Z","user":{...}}`. Токен — непрозрачное случайное значение, не username/UUID и не обязательный JWT; энтропия не менее 256 бит. Храните серверную сессию с expiry; TTL задаётся `SESSION_TTL_SECONDS`, по умолчанию 3600 (в тесте истечения можно 2). В обычном smoke TTL должен быть не менее 300. + +Все защищённые endpoint принимают только `Authorization: Bearer `. Истёкший/отозванный/неизвестный токен — 401 с `WWW-Authenticate: Bearer realm="messenger"`. Basic на бизнес-endpoint — 401. `X-User-Id` с lab2 игнорируется: он не меняет владельца сессии и сам по себе не даёт доступ. `DELETE /auth/sessions/current` отзывает текущий токен и возвращает 204 без тела; повтор с отозванным токеном — 401. + +Пользователи, чаты, сообщения и ещё действительные сессии переживают перезапуск приложения и используемых хранилищ с сохранёнными volumes. TTL не продлевается из-за рестарта. Пароли уже с lab2 хешируются библиотечным password KDF (Argon2id, scrypt или bcrypt с учётом ограничений библиотеки), не SHA-256 и не plaintext. Если bcrypt не поддерживает весь диапазон UTF-8 паролей без усечения, выберите другую библиотечную схему. В lab7 обязателен Argon2id. + +Basic — это кодирование, не шифрование. HTTP lab1–6 допускается только для локального изолированного стенда с синтетическими данными; для удалённой демонстрации нужен TLS. Lab7 использует HTTPS обязательно. См. [RFC 7617](https://www.rfc-editor.org/info/rfc7617/). + +## Балансировка и повторная отправка (с lab3) + +- Каждый ответ, созданный API, содержит `X-Instance-Id`; значение стабильно для жизни процесса. Ошибки соединения, созданные самим LB, могут не иметь этого заголовка. В lab7 используйте случайный alias реплики без раскрытия hostname/IP. +- Для POST сообщения обязателен `Idempotency-Key` (ASCII `[A-Za-z0-9._:-]{1,128}`). Нет/неверный ключ — 400. Область уникальности: текущий пользователь + HTTP-метод + путь чата + ключ. +- При повторе того же валидного JSON в течение 24 часов сервер возвращает тот же код 201 и то же Message. Порядок ключей JSON и отсутствующий `attachment_ids` вместо `[]` семантически равны. Другой body при том же ключе — 409. Разные пользователи/чаты могут использовать одинаковый ключ независимо. +- Одновременные повторы через разные реплики создают ровно одно сообщение. Повтор снова проверяет действительность сессии и доступ. Транзиентные 5xx не фиксируются как окончательный результат; побочный эффект и запись результата должны быть атомарны. +- Ключ обязателен только для сообщений. Самостоятельный retry загрузки файла или создания чата может создать ещё один объект; не добавляйте прозрачные повторы этих POST без расширения контракта. + +## Вложения (с lab4) + +- `POST /chats/{chat_id}/attachments?filename=photo.png`: сырые bytes одного PNG/JPEG; `Content-Type: image/png` или `image/jpeg`. Не multipart. Максимум **10 MiB = 10 485 760 байт**. MIME проверяется по содержимому; заведомо повреждённые/чужие форматы — 415, превышение bytes — 413. Не более 40 миллионов декодированных пикселей (превышение — 413). Устанавливайте лимит до полной распаковки изображения. +- `filename` — отображаемое имя (1–255 code points). Не используйте его как путь/ключ S3. Имя с `/`, `\`, NUL или управляющими символами — 400. Bucket приватен; object key генерирует сервер. Оригинал возвращается побайтно, его lowercase SHA-256 и размер соответствуют исходному файлу. +- Lab4 возвращает `201 Attachment` со `status=ready`, `variants=[]`, `error_code=null` только после сохранения оригинала и метаданных. Lab5–7 возвращают `202 Attachment` со снимком `status=queued`; готовность определяется последующим GET метаданных. Быстрый worker может закончить до первого GET — это нормально. +- Файл заранее привязан к чату. В POST сообщения добавляется `attachment_ids` (0–10 уникальных ID, по умолчанию []). Можно отправить `text=""` с хотя бы одним вложением. Все вложения должны принадлежать этому чату; чужое/неизвестное — 404. Любой участник может ссылаться на доступное вложение чата. Failed-вложение прикреплять нельзя (409); queued/processing с lab5 можно. +- `GET /attachments/{id}` и `/content` доступны только участникам чата. Download возвращает байты с истинным Content-Type, через авторизованный API, **без redirect** в обязательном контракте. Приватный bucket сам по себе не заменяет эту проверку. `variant` по умолчанию original. Для queued/processing content — 409; для failed — 409. Отсутствующий вариант ready-объекта — 404. +- В ready-метаданных `error_code=null`. В failed — краткий машинный код (например `invalid_image`, `processing_failed`, `retry_exhausted`), без stack trace. `size_bytes`, `sha256`, `content_type` всегда описывают оригинал; у вариантов отдельные поля. Публичный API не раскрывает ключи бакета, внутренние адреса и credentials. +- Прямой upload/download по временной подписи — факультативное расширение. Его дополняют ограничение срока/размера, уникальный ключ и подтверждение реально загруженного объекта; он не заменяет стандартный endpoint. Особенности подписанных URL: [S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). + +## Фоновая обработка (с lab5) + +До 202 все байты приняты в **устойчивый staging**, а намерение обработать файл надёжно сохранено. HTTP-передача клиента не исчезает и не становится мгновенной. Staging — приватный S3-prefix либо общий persistent volume, доступный worker; локальная память/API ephemeral disk не подходит. Worker асинхронно переносит/публикует оригинал в конечный S3-prefix и создаёт варианты; если staging уже в S3, опишите эту границу честно. + +Обязательный thumbnail: JPEG, изображение вписано в 256×256 с сохранением пропорций, без увеличения маленьких исходников, EXIF orientation применяется; alpha компонуется на белый. Размеры округляются вниз, минимум 1 px. Fixture 320×200 должен дать 256×160. Статус ready ставится после сохранения оригинала и thumbnail. Crop на 4: JPEG, центрированный квадрат 128×128 (для маленьких исходников допускается увеличение). Optimized на 4: WebP, вписан в 1280×1280 без увеличения, параметры качества и удаления EXIF документируются; не обещайте уменьшение каждого маленького файла. + +Допустимые состояния и события описаны в `jobs.schema.json` и `attachment-states.md`. Доставка — at least once; обработка идемпотентна, повторы не размножают варианты. Финальные ошибки наблюдаемы, бесконечный немой retry недопустим. Обязательный smoke ждёт ready до 60 секунд; это бюджет локального smoke для одного файла при свободном worker, не универсальный production SLA. В очередь идут ссылки/ID, не бинарное содержимое. diff --git a/contracts/attachment-states.md b/contracts/attachment-states.md new file mode 100644 index 0000000..9e0eb16 --- /dev/null +++ b/contracts/attachment-states.md @@ -0,0 +1,21 @@ +# Состояния вложения и договор между producer/worker + +Сообщение broker соответствует [jobs.schema.json](jobs.schema.json). Можно применять native headers брокера для trace context, но семантика carrier сохраняется; для проверки покажите отображение полей. `job_id` идентифицирует одну логическую работу и не меняется при redelivery; `attempt` начинается с 1 и отражает управляемую попытку обработки (broker redelivery сам по себе не создаёт новый job_id). Ключ эффекта — `(attachment_id, pipeline_version, variant)`. + +| Переход | Условие | Что устойчиво сохранено | +| --- | --- | --- | +| нет → queued | Принимаем upload; только после durable handoff отвечаем 202 | Полный проверенный оригинал в staging, метаданные, намерение обработки | +| queued → processing | Worker захватил/арендовал работу | Попытка, lease/deadline или эквивалентная защита | +| processing → queued | Временная ошибка и остались попытки | Причина без секретов, время следующей попытки | +| processing → ready | Все обязательные объекты записаны и результат атомарно опубликован | Оригинал, thumbnail (на 4 ещё crop/optimized), метаданные/checksums | +| processing → failed | Ошибка постоянная или попытки исчерпаны | error_code и запись, доступная оператору | +| processing → queued | Истёк lease после смерти worker | Восстановленная работа, без дублирования эффектов | +| failed → queued | Явный операторский replay, доступен на 4 | Audit, новая попытка, прежний логический эффект защищён от дубля | + +Повторная доставка уже ready-задачи — no-op с ack после проверки завершённого эффекта. Конкурентные попытки не перезаписывают более новый pipeline_version. Основной курс использует pipeline_version=1; версионирование нескольких pipeline — трек 5. Заявляйте готовность только когда все обещанные уровнем варианты доступны. Нельзя откатывать ready в processing из-за старого дубля. + +**Окна отказа для защиты:** после staging до записи задания; после COMMIT до publish; после publish до confirm; после записи S3 до COMMIT результата; после COMMIT результата до ack. На 3 покажите сохранность успешно принятой работы и повторную доставку; на 4 автоматическое восстановление разрыва DB/broker (outbox/эквивалент) и зависшего processing. + +Staging не удаляется до безопасной публикации результатов. Неуспешные/непривязанные исходники имеют документированный retention и cleanup; он не удаляет текущую работу. Broker принимает только ID/служебные данные, не произвольный URL для скачивания и не путь из клиентского filename. Worker берёт доверенные storage metadata из БД. + +Ack подтверждает устойчивую обработку, publisher confirm — приём брокером; это разные границы. См. [RabbitMQ reliability](https://www.rabbitmq.com/docs/reliability). At-least-once + идемпотентный эффект не означает exactly-once доставку. diff --git a/contracts/examples.json b/contracts/examples.json new file mode 100644 index 0000000..060abd0 --- /dev/null +++ b/contracts/examples.json @@ -0,0 +1,60 @@ +{ + "CreateUser": { + "username": "alice", + "display_name": "Алиса", + "password": "Example-only-not-a-real-secret-123" + }, + "User": { + "id": "11111111-1111-4111-8111-111111111111", + "username": "alice", + "display_name": "Алиса", + "created_at": "2026-09-01T12:00:00Z" + }, + "CreateChat": { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ] + }, + "Chat": { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ], + "id": "33333333-3333-4333-8333-333333333333", + "created_at": "2026-09-01T12:00:00Z" + }, + "CreateMessage": { + "text": "Привет, Боб!" + }, + "Message": { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z", + "attachment_ids": [] + }, + "MessagePage": { + "items": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z", + "attachment_ids": [] + } + ], + "next_cursor": null + }, + "Error": { + "error": { + "code": "not_found", + "message": "Объект не найден", + "request_id": "example-request-id" + } + } +} diff --git a/contracts/infrastructure.json b/contracts/infrastructure.json new file mode 100644 index 0000000..05794a8 --- /dev/null +++ b/contracts/infrastructure.json @@ -0,0 +1,88 @@ +{ + "lab": 6, + "kind": "requirements, not runnable deployment", + "required_for_grade_3": [ + { + "role": "api", + "count_min": 2, + "student_implements": true, + "persistent_local_state": false + }, + { + "role": "postgres", + "purpose": "users/chats/membership/messages/metadata", + "persistent_volume": true + }, + { + "role": "load_balancer", + "public_entrypoint": true, + "backend_readiness": true + }, + { + "role": "s3", + "choose_one": [ + "MinIO", + "SeaweedFS", + "RustFS" + ], + "private_bucket": true, + "persistent_volume": true + }, + { + "role": "broker", + "choose_one": [ + "RabbitMQ", + "Redis Streams", + "Valkey Streams", + "NATS JetStream", + "documented durable equivalent" + ], + "persistent_volume": true, + "delivery": "at-least-once" + }, + { + "role": "worker", + "separate_process": true + }, + { + "role": "otel_collector", + "signals": [ + "metrics", + "logs", + "traces" + ] + }, + { + "role": "telemetry_storage", + "signals": [ + "metrics", + "logs", + "traces" + ], + "possible_combination": [ + "Prometheus", + "Loki", + "Tempo" + ], + "persistent_volume": true + }, + { + "role": "grafana", + "provisioning_in_git": true + } + ], + "optional_components": [ + { + "role": "redis_or_valkey", + "required_for_lab2_grade4": true, + "note": "Choose one; if only store of sessions, configure durable persistence." + } + ], + "rules": [ + "Choose Compose or Swarm for lab3+; do not need both.", + "Never publish internal API replica ports in bypass of LB from lab3.", + "Versions/digests must be fixed in implementation.", + "Do not remove volumes in restart/failover checks.", + "Document service names, healthchecks, bootstrap/migrations and credentials." + ] +} diff --git a/contracts/jobs.schema.json b/contracts/jobs.schema.json new file mode 100644 index 0000000..2e9aa48 --- /dev/null +++ b/contracts/jobs.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:mtusi:messenger:attachment-job:v1", + "title": "Durable attachment processing job v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "job_id", + "type", + "attachment_id", + "pipeline_version", + "attempt", + "created_at" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "job_id": { + "type": "string", + "format": "uuid" + }, + "type": { + "const": "attachment.process.v1" + }, + "attachment_id": { + "type": "string", + "format": "uuid" + }, + "pipeline_version": { + "type": "integer", + "minimum": 1 + }, + "attempt": { + "type": "integer", + "minimum": 1 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "traceparent": { + "type": "string", + "pattern": "^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$" + }, + "tracestate": { + "type": "string", + "maxLength": 512 + } + }, + "examples": [ + { + "schema_version": 1, + "job_id": "11111111-1111-4111-8111-111111111111", + "type": "attachment.process.v1", + "attachment_id": "22222222-2222-4222-8222-222222222222", + "pipeline_version": 1, + "attempt": 1, + "created_at": "2026-09-01T12:00:00Z" + } + ] +} diff --git a/contracts/observability.md b/contracts/observability.md new file mode 100644 index 0000000..12159b2 --- /dev/null +++ b/contracts/observability.md @@ -0,0 +1,28 @@ +# Контракт наблюдаемости + +Ниже логические сигналы. Допустимы стандартные semantic conventions выбранного SDK и их native metric names: зафиксируйте соответствие в `REPORT.md`, dashboard обязан ссылаться на реально существующие имена/единицы. Переименование SDK не повод дублировать одну метрику двумя инструментами. Версии SDK, semantic conventions, Collector distribution и backend закрепляются в решении. + +| Сигнал | Минимальные поля/измерения | Уровень | +| --- | --- | --- | +| HTTP requests | method, route template, status class; counter | 3 | +| HTTP duration | histogram в секундах или документированное преобразование из ms | 3 | +| Jobs processed / failed | job type, outcome, counter | 3 | +| Job processing duration | job type, outcome, histogram | 3 | +| API и worker logs | timestamp, severity, service.name, event, trace_id, span_id при активном span | 3 | +| API и worker traces | реальные server/consumer spans, duration, outcome; не искусственный статический trace | 3 | +| Queue depth / oldest job age | queue name, количество / секунды | 4 | +| Pool/saturation | DB pool used/waiting, worker busy/capacity, process/container CPU/RAM | 4 | +| Сквозная связь upload → publish → consume → S3/DB | W3C traceparent/tracestate; parent-child или link по выбранной семантике | 4 | +| End-to-end time to ready | от durable acceptance до публикации ready, histogram | 4 | + +Resource attributes: `service.name` (messenger-api / messenger-worker), `service.version`, `service.instance.id`, `deployment.environment.name=lab`. Список имён может отличаться, если явно сопоставлен. `X-Request-Id` коррелирует HTTP-ошибку с логом; trace_id — не замена всех request IDs. + +**Не labels метрик:** user_id, chat_id, message_id, attachment_id, request_id, raw URL, filename, текст. Для маршрута используйте `/api/v1/chats/{chat_id}/messages`, не реальный UUID. В логах/трассах допустимы ограниченные технические ID для учебных синтетических данных, если обоснована необходимость; пароли, Authorization, session token, body сообщения, картинка, private key и signed URL запрещены. + +Для HTTP error ratio заранее определите numerator/denominator: например 5xx/все бизнес-запросы, исключая health; не смешивайте неверный пароль клиента с отказом БД. p95 агрегируется из histogram buckets всех нужных реплик, а не средних p95. Границы buckets должны соответствовать ожидаемым задержкам. Backlog — не queue processing latency. + +В Git находятся Collector config, backend configs, datasources/dashboard provisioning, dashboard JSON и на 4 alert rules. У alerts есть условие, окно, порог, `for` при необходимости, описание и локальный runbook. Отправка сообщений в почту/мессенджер не требуется. + +Минимальные контролируемые инциденты для 4: (1) остановить worker и увидеть рост возраста задач; (2) сделать DB медленной/недоступной и отличить её от медленного HTTP handler по trace/метрикам. После восстановления backlog уменьшается, alerts resolved. Третий сценарий — недоступность backend telemetry: SDK/Collector ограничивают RAM/очередь, API сохраняет работу, потери telemetry считаются и объясняются. + +Collector — получатель/обработчик/экспортёр, не долговременная БД. Grafana — интерфейс и запросы к источникам. Логи можно отправлять OTLP в совместимый backend; не закладывайте удалённый/устаревший exporter без проверки текущей сборки. См. [Collector configuration](https://opentelemetry.io/docs/collector/configuration/), [Trace Context](https://www.w3.org/TR/trace-context/) и [histograms](https://prometheus.io/docs/practices/histograms/). diff --git a/contracts/openapi.json b/contracts/openapi.json new file mode 100644 index 0000000..fb24908 --- /dev/null +++ b/contracts/openapi.json @@ -0,0 +1,3512 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "MTUSI Messenger — лабораторная 6", + "version": "1.6.0", + "description": "Нормативный минимум на 3. Семантика: contracts/README.md. Расширения на 4/5 не должны менять этот API. Наличие кода ошибки в схеме не требует реализовать факультативную причину его выдачи." + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "Локальный учебный стенд" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/health/live": { + "get": { + "operationId": "liveness", + "summary": "Процесс жив", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [] + } + }, + "/health/ready": { + "get": { + "operationId": "readiness", + "summary": "Готов принимать трафик", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [] + } + }, + "/api/v1/users": { + "post": { + "operationId": "registerUser", + "summary": "Создать пользователя", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUser" + } + } + } + } + } + }, + "/api/v1/users/me": { + "get": { + "operationId": "currentUser", + "summary": "Текущий пользователь", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/chats": { + "post": { + "operationId": "createChat", + "summary": "Создать чат; вызывающий должен входить в member_ids", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chat" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateChat" + } + } + } + } + }, + "get": { + "operationId": "listChats", + "summary": "Чаты текущего пользователя", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatPage" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "minLength": 1 + } + } + ] + } + }, + "/api/v1/chats/{chat_id}": { + "get": { + "operationId": "getChat", + "summary": "Получить доступный чат", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chat" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ] + } + }, + "/api/v1/chats/{chat_id}/messages": { + "post": { + "operationId": "createMessage", + "summary": "Отправить сообщение", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "description": "Обязателен с lab3. Область: пользователь + метод + путь + ключ; хранить результат не менее 24 ч. Повтор того же JSON возвращает исходный 201 и то же сообщение, другой JSON — 409." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMessage" + } + } + } + } + }, + "get": { + "operationId": "listMessages", + "summary": "Сообщения по (created_at, id) по возрастанию", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessagePage" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "minLength": 1 + } + } + ] + } + }, + "/api/v1/auth/sessions": { + "post": { + "operationId": "createSession", + "summary": "Вход: Basic username:password; выдаёт сессию", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] + } + }, + "/api/v1/auth/sessions/current": { + "delete": { + "operationId": "revokeSession", + "summary": "Отозвать текущую сессию", + "responses": { + "204": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/chats/{chat_id}/attachments": { + "post": { + "operationId": "uploadAttachment", + "summary": "Принять один PNG/JPEG, до 10 MiB", + "responses": { + "202": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Attachment" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Превышен лимит", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "Неподдерживаемый тип", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "filename", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": true, + "description": "Сырые байты изображения, НЕ multipart и НЕ JSON/base64.", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "/api/v1/attachments/{attachment_id}": { + "get": { + "operationId": "getAttachment", + "summary": "Метаданные вложения, доступ участникам чата", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Attachment" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "attachment_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ] + } + }, + "/api/v1/attachments/{attachment_id}/content": { + "get": { + "operationId": "downloadAttachment", + "summary": "Скачать байты через авторизованный API", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/webp": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "attachment_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "variant", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "original", + "thumbnail", + "crop", + "optimized" + ], + "default": "original" + } + } + ] + } + } + }, + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "opaque session token" + } + }, + "schemas": { + "Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "invalid_request", + "unauthorized", + "not_found", + "conflict", + "payload_too_large", + "unsupported_media_type", + "rate_limited", + "unavailable", + "internal_error" + ] + }, + "message": { + "type": "string", + "minLength": 1 + }, + "request_id": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "code", + "message", + "request_id" + ], + "additionalProperties": false + } + }, + "required": [ + "error" + ], + "additionalProperties": false, + "examples": [ + { + "error": { + "code": "not_found", + "message": "Объект не найден", + "request_id": "example-request-id" + } + } + ] + }, + "Health": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string", + "pattern": "^[a-z0-9_]{3,32}$" + }, + "display_name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "username", + "display_name", + "created_at" + ], + "additionalProperties": false, + "examples": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "username": "alice", + "display_name": "Алиса", + "created_at": "2026-09-01T12:00:00Z" + } + ] + }, + "CreateUser": { + "type": "object", + "properties": { + "username": { + "type": "string", + "pattern": "^[a-z0-9_]{3,32}$" + }, + "display_name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "password": { + "type": "string", + "minLength": 12, + "maxLength": 128, + "writeOnly": true + } + }, + "required": [ + "username", + "display_name", + "password" + ], + "additionalProperties": false, + "examples": [ + { + "username": "alice", + "display_name": "Алиса", + "password": "Example-only-not-a-real-secret-123" + } + ] + }, + "Chat": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "member_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 100, + "uniqueItems": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "title", + "member_ids", + "created_at" + ], + "additionalProperties": false, + "examples": [ + { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ], + "id": "33333333-3333-4333-8333-333333333333", + "created_at": "2026-09-01T12:00:00Z" + } + ] + }, + "CreateChat": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "member_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 100, + "uniqueItems": true + } + }, + "required": [ + "title", + "member_ids" + ], + "additionalProperties": false, + "examples": [ + { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ] + } + ] + }, + "Message": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "chat_id": { + "type": "string", + "format": "uuid" + }, + "sender_id": { + "type": "string", + "format": "uuid" + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "attachment_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 10, + "uniqueItems": true + } + }, + "required": [ + "id", + "chat_id", + "sender_id", + "text", + "created_at", + "attachment_ids" + ], + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "text": { + "minLength": 1 + } + } + }, + { + "properties": { + "attachment_ids": { + "minItems": 1 + } + } + } + ], + "examples": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z", + "attachment_ids": [] + } + ] + }, + "CreateMessage": { + "type": "object", + "properties": { + "text": { + "type": "string", + "maxLength": 4000 + }, + "attachment_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 10, + "uniqueItems": true, + "default": [] + } + }, + "required": [ + "text" + ], + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "text": { + "minLength": 1 + } + } + }, + { + "properties": { + "attachment_ids": { + "minItems": 1 + } + }, + "required": [ + "attachment_ids" + ] + } + ], + "examples": [ + { + "text": "Привет, Боб!" + } + ] + }, + "ChatPage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Chat" + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items", + "next_cursor" + ], + "additionalProperties": false + }, + "MessagePage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items", + "next_cursor" + ], + "additionalProperties": false, + "examples": [ + { + "items": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z", + "attachment_ids": [] + } + ], + "next_cursor": null + } + ] + }, + "Session": { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 32 + }, + "token_type": { + "type": "string", + "const": "Bearer" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "$ref": "#/components/schemas/User" + } + }, + "required": [ + "token", + "token_type", + "expires_at", + "user" + ], + "additionalProperties": false + }, + "Variant": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "thumbnail", + "crop", + "optimized" + ] + }, + "content_type": { + "type": "string", + "enum": [ + "image/jpeg", + "image/png", + "image/webp" + ] + }, + "size_bytes": { + "type": "integer", + "minimum": 1 + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "name", + "content_type", + "size_bytes", + "width", + "height", + "sha256" + ], + "additionalProperties": false + }, + "Attachment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "chat_id": { + "type": "string", + "format": "uuid" + }, + "owner_id": { + "type": "string", + "format": "uuid" + }, + "filename": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "content_type": { + "type": "string", + "enum": [ + "image/png", + "image/jpeg" + ] + }, + "size_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 10485760 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "processing", + "ready", + "failed" + ] + }, + "variants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Variant" + } + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "chat_id", + "owner_id", + "filename", + "content_type", + "size_bytes", + "sha256", + "status", + "variants", + "error_code", + "created_at", + "updated_at" + ], + "additionalProperties": false + } + } + } +} diff --git a/evidence/README.md b/evidence/README.md new file mode 100644 index 0000000..8df7001 --- /dev/null +++ b/evidence/README.md @@ -0,0 +1,5 @@ +# Доказательства + +Храните небольшие обезличенные результаты: команды и stdout проверок, JSON/CSV агрегатов нагрузки, EXPLAIN, dashboard JSON, текст разбора отказа. Укажите дату, commit, аппаратные лимиты и профиль нагрузки. + +Не добавляйте .env, session tokens, cookies, приватные ключи, credentials, реальные сообщения, raw dumps и signed URLs. Для скриншотов скрывайте секреты. Большие логи/бинарные traces сохраняйте отдельно по правилам преподавателя; одного скриншота без методики недостаточно. diff --git a/examples/requests.http b/examples/requests.http new file mode 100644 index 0000000..052497b --- /dev/null +++ b/examples/requests.http @@ -0,0 +1,53 @@ +# Optional HTTP-client worksheet; no extension is required by the course. +# Replace IDs/token locally from real responses; do not commit filled-in credentials. +# Canonical API: contracts/openapi.json and contracts/README.md. +@baseUrl = http://localhost:8080 +@aliceId = REPLACE_WITH_CREATED_ALICE_UUID +@bobId = REPLACE_WITH_CREATED_BOB_UUID +@chatId = REPLACE_WITH_CREATED_CHAT_UUID +@sessionToken = REPLACE_LOCALLY_DO_NOT_COMMIT + +### Liveness +GET {{baseUrl}}/health/live + +### Register Alice (repeat with username bob/display_name Боб) +POST {{baseUrl}}/api/v1/users +Content-Type: application/json + +{ + "username": "alice", + "display_name": "Алиса", + "password": "Example-only-not-a-real-secret-123" +} + +### Create session: generate the Base64 value locally, do not commit credentials +# Base64 of UTF-8 username:password; only this endpoint accepts Basic. +POST {{baseUrl}}/api/v1/auth/sessions +Authorization: Basic REPLACE_WITH_LOCAL_BASE64_CREDENTIAL + + +### Create chat; both UUIDs must be real registered users +POST {{baseUrl}}/api/v1/chats +Authorization: Bearer {{sessionToken}} +Content-Type: application/json + +{"title":"Проект по ВНП","member_ids":["{{aliceId}}","{{bobId}}"]} + +### Send message +POST {{baseUrl}}/api/v1/chats/{{chatId}}/messages +Authorization: Bearer {{sessionToken}} +Idempotency-Key: example-message-001 +Content-Type: application/json + +{"text":"Привет, Боб!"} + +### Read first page +GET {{baseUrl}}/api/v1/chats/{{chatId}}/messages?limit=50 +Authorization: Bearer {{sessionToken}} + +### Binary upload; HTTP file-client syntax for reading local fixture +POST {{baseUrl}}/api/v1/chats/{{chatId}}/attachments?filename=sample.png +Authorization: Bearer {{sessionToken}} +Content-Type: image/png + +< ../tests/fixtures/sample.png diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..5a39e86 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,7 @@ +# Инфраструктура студента + +Здесь разместите конфигурации выбранных сервисов; основную топологию — в корневом compose.yaml/stack.yaml. `compose.example.yaml` — только worksheet ролей и намеренно не запускается. `contracts/infrastructure.json` задаёт минимальные роли, не готовые образы/пароли. + +В REPORT.md сопоставьте роль → service name → версия/digest → health/readiness → volume → сеть/порт → способ передачи credentials. Приведите bootstrap (schema, bucket, broker topology, а с lab6 provisioning) и безопасное повторение этих команд. + +Приложение строится из исходников. Не используйте заранее существующий образ преподавательского сервера вместо своей реализации. Restart hook перезапускает все соответствующие stores с volumes; failover hook останавливает только заданную API-реплику. diff --git a/infra/otel/README.md b/infra/otel/README.md new file mode 100644 index 0000000..c46fe89 --- /dev/null +++ b/infra/otel/README.md @@ -0,0 +1,7 @@ +# Конфигурация наблюдаемости — реализовать + +Добавьте `collector.yaml` с OTLP receivers, processors и exporters выбранной версии Collector. Укажите явные bind endpoints, внутренние сети, memory limits/batch и поведение при backpressure по уровню. Сопоставьте все три pipeline с реально развёрнутыми backend. + +Добавьте конфигурации storage, Grafana provisioning/datasources, provisioning/dashboards и импортируемые dashboard JSON. На 4 — alert rules и runbooks. Пустой dashboard или debug exporter, который только печатает данные в stdout, не заменяют хранилища/доказательства из `contracts/observability.md`. + +Не публикуйте OTLP/metrics/административные ports в общую сеть без необходимости. В lab7 реализуйте TLS/mTLS по trust matrix; экспорт Authorization и message bodies запрещён. diff --git a/lab.json b/lab.json new file mode 100644 index 0000000..b365b06 --- /dev/null +++ b/lab.json @@ -0,0 +1,8 @@ +{ + "number": 6, + "title": "Наблюдаемость: OpenTelemetry и Grafana", + "contract_version": "1.6.0", + "default_base_url": "http://localhost:8080", + "grading": "3; 4 includes 3; 5 includes 4 plus one completed research track", + "scaffold_has_application": false +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6b103dd --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +# Only for full OpenAPI/JSON Schema validation; HTTP tests use stdlib. +openapi-spec-validator==0.7.2 diff --git a/scripts/check_contract.py b/scripts/check_contract.py new file mode 100644 index 0000000..dcb34df --- /dev/null +++ b/scripts/check_contract.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Offline structural checks; full schema validation is a separate target.""" +import ast +import json +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] + +def main(): + lab = json.loads((ROOT/'lab.json').read_text())['number'] + spec = json.loads((ROOT/'contracts/openapi.json').read_text()) + assert spec['openapi']=='3.1.0' + assert spec['info']['version']=='1.%d.0'%lab + operation_ids = set() + def walk(value): + if isinstance(value,dict): + if '$ref' in value: + pointer = value['$ref'] + assert pointer.startswith('#/'), 'unexpected external reference' + target = spec + for part in pointer[2:].split('/'): + target = target[part.replace('~1','/').replace('~0','~')] + for v in value.values(): + walk(v) + elif isinstance(value,list): + for v in value: + walk(v) + walk(spec) + for route,methods in spec['paths'].items(): + placeholders = set(re.findall(r'\{([^}]+)\}',route)) + for method,op in methods.items(): + assert op['operationId'] not in operation_ids, 'duplicate operationId' + operation_ids.add(op['operationId']) + params = {p['name'] for p in op.get('parameters',[]) if p['in']=='path' and p.get('required')} + assert placeholders==params, 'path parameter mismatch: '+route + for response in op['responses'].values(): + assert 'X-Request-Id' in response['headers'] + if lab>=3: + assert 'X-Instance-Id' in response['headers'] + assert ('/api/v1/auth/sessions' in spec['paths']) == (lab>=2) + assert ('/api/v1/attachments/{attachment_id}' in spec['paths']) == (lab>=4) + for file in list((ROOT/'tests').glob('*.py'))+list((ROOT/'scripts').glob('*.py')): + ast.parse(file.read_text(),filename=str(file)) + for file in ROOT.rglob('*.json'): + if any(part in {'.git','.venv','node_modules','vendor','output'} for part in file.parts): + continue + json.loads(file.read_text()) + for required in ('README.md','COURSE.md','CONTRIBUTING.md','REPORT.md','contracts/README.md','tests/smoke.py','tests/README.md'): + assert (ROOT/required).is_file(), 'missing '+required + print('PASS: offline skeleton structure, local refs and Python syntax; application NOT tested.') + +if __name__=='__main__': + main() diff --git a/scripts/restart.example.sh b/scripts/restart.example.sh new file mode 100755 index 0000000..967e951 --- /dev/null +++ b/scripts/restart.example.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +# TODO: copy to restart.sh and implement using YOUR compose/stack services. +# Restart API and ALL used state stores (DB, sessions, S3, broker as applicable). +# Preserve persistent volumes and schema/data; never down -v / flush / re-seed. +# Allow operator unseal if required in lab7; do not print credentials. +# Exit 0 only after the intended action; test waits for API readiness separately. +printf '%s +' 'TODO: implement restart.sh for your infrastructure' >&2 +exit 2 diff --git a/scripts/stop-one.example.sh b/scripts/stop-one.example.sh new file mode 100755 index 0000000..ae149e8 --- /dev/null +++ b/scripts/stop-one.example.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +# TODO: copy to stop-one.sh, map TARGET_INSTANCE_ID to ONE API container/task. +# Stop that instance and leave it stopped; no stop of DB/LB/volumes. +# Return quickly (within 10 s); the test sends requests during this script. +# Restore the replica explicitly after the test. Never print credentials. +: "${TARGET_INSTANCE_ID:?test provides target API instance alias}" +printf '%s +' 'TODO: implement stop-one.sh for your infrastructure' >&2 +exit 2 diff --git a/scripts/validate_openapi.py b/scripts/validate_openapi.py new file mode 100644 index 0000000..1dfe4a9 --- /dev/null +++ b/scripts/validate_openapi.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Full OpenAPI and JSON Schema checks; install requirements-dev.txt first.""" +import json +from pathlib import Path +from openapi_spec_validator import validate +from jsonschema import Draft202012Validator + +root = Path(__file__).resolve().parents[1] +spec = json.loads((root/'contracts/openapi.json').read_text()) +validate(spec) +from referencing import Registry, Resource +# The base URI resolves local #/components references inside examples. +resource = dict(spec, **{'$schema': 'https://json-schema.org/draft/2020-12/schema'}) +registry = Registry().with_resource('urn:mtusi:openapi', Resource.from_contents(resource)) +for name, example in json.loads((root/'contracts/examples.json').read_text()).items(): + schema = {'$ref': 'urn:mtusi:openapi#/components/schemas/' + name} + Draft202012Validator(schema, registry=registry).validate(example) +for path in (root/'contracts').glob('*.schema.json'): + schema = json.loads(path.read_text()) + Draft202012Validator.check_schema(schema) + for example in schema.get('examples',[]): + Draft202012Validator(schema).validate(example) +print('PASS: full OpenAPI 3.1 and JSON Schema validation; application NOT tested.') diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..0dfa3f8 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,57 @@ +# Публичные проверки + +Python **3.9+**, стандартная библиотека. Сервер уже должен быть запущен; проверки не создают готовое приложение и сами не управляют Docker. `make check` можно выполнить на исходном skeleton, `make test` — только после реализации API. Полная OpenAPI-валидация отдельно: `make validate` после установки `requirements-dev.txt` в venv. + +```sh +make check +make test BASE_URL=http://localhost:8080 +# без make: +python3 tests/smoke.py --base-url http://localhost:8080 +``` + +`smoke.py` выполняет **один сквозной сценарий**, автоматически выбирая профиль из `lab.json`: health → три пользователя → вход с lab2 → чат → сообщения → пагинация → ошибки доступа → идемпотентность с lab3 → файл с lab4 → thumbnail с lab5 → logout. Он проверяет JSON по используемому подмножеству схемы, коды/headers, Unicode, SHA-256 оригинала и фактические JPEG-размеры thumbnail. Это публичный минимум, не полный fuzz/security/load suite. + +Каждый запуск создаёт уникальных синтетических пользователей, чат и сообщения. Данные автоматически не удаляются: в API курса нет delete. Для чистого повтора используйте отдельный тестовый стенд/volume и осознанный сброс своего окружения. Session tokens хранятся только в памяти процесса и не печатаются/не сохраняются. + +## Проверка сохранности, lab2+ + +Скопируйте `scripts/restart.example.sh` в `scripts/restart.sh`, реализуйте перезапуск **API и всех используемых хранилищ** с сохранением volumes и задайте executable bit. Скрипт может опираться на ваш compose.yaml или stack.yaml. Он не должен удалять volumes, повторно seed-ить БД или менять тестовые данные. + +```sh +chmod +x scripts/restart.sh +make test-persistence ACTION_SCRIPT=scripts/restart.sh +``` + +Тест создаёт данные и сессию, запускает указанный файл без shell interpolation, ожидает готовности и проверяет те же данные **с прежним токеном**. В lab4+ также проверяется исходный файл. Hook может работать до 90 секунд, готовность затем ожидается до 90 секунд. TTL сессии для этого теста — минимум 300 секунд. Успех не доказывает, что hook действительно перезапустил БД: приложите команды/состояния контейнеров до и после. + +## Проверка отказа, lab3+ + +Реализуйте `scripts/stop-one.sh` на основе примера. `TARGET_INSTANCE_ID` в окружении содержит alias обслужившей тест API-реплики: сопоставьте его своему контейнеру/Swarm task и остановите именно его. Скрипт должен быстро вернуть 0 и **оставить эту реплику остановленной**; восстановление выполняйте отдельно. Swarm может создать новую task с другим ID — это допустимо. + +```sh +chmod +x scripts/stop-one.sh +make test-failover ACTION_SCRIPT=scripts/stop-one.sh +``` + +Перед отказом тест наблюдает ≥2 API-реплики; 15 секунд отправляет сообщения параллельно выполнению hook, повторяет неопределённый POST с тем же ключом, проверяет восстановление записи за ≤10 секунд, отсутствие остановленной реплики в последних ответах, сохранность подтверждённых сообщений и отсутствие дублей. Ошибки переходного периода учитываются. Затем восстановите реплику и повторите для другой. Скрипт — проверка одного отказа API, не гарантия HA БД/LB/host. Вывод hook скрыт, чтобы не утекли секреты; отлаживайте его отдельно с безопасным выводом. + +## Нагрузка + +```sh +python3 tests/load.py --duration 30 --concurrency 4 --output evidence/load.json +``` + +Это простой **closed-loop** генератор POST сообщений; он ограничен производительностью клиента и страдает coordinated omission. Он выдаёт successful RPS, ошибки и p50/p95/p99 **только успешных запросов**. Во время нагрузки нет прозрачных retry; timeout мог скрыть совершённую запись, поэтому это throughput HTTP-подтверждений, не точный счётчик COMMIT. Изменение размера истории входит в профиль. Для серьёзного исследования используйте k6/Locust/wrk либо свой обоснованный генератор, а не выводите максимальную пропускную способность из одного запуска этого скрипта. Для нагрузки pipeline изображений нужен отдельный сценарий студента. + +## HTTPS, lab7 + +```sh +make test BASE_URL=https://localhost:8443 CA_FILE=/absolute/path/to/ca.crt +make test-security BASE_URL=https://localhost:8443 CA_FILE=/absolute/path/to/ca.crt +``` + +Все скрипты принимают `--ca-file`/`CA_FILE` и проверяют серверный сертификат и hostname. `--insecure` отсутствует. TLS-тест сначала проверяет рабочее доверенное соединение, затем намеренно пустой trust store и именно ошибку проверки сертификата; network timeout не засчитывается как правильный отказ. **Внутреннее mTLS, identity/authorization, OpenBao policy и hardening проверяют отдельные тесты студента** по trust matrix. Клиентский сертификат внешнему учебному REST-клиенту не требуется: mTLS находится на внутренних связях. + +## Что ещё остаётся доказать + +Smoke не доказывает persistence, число реальных контейнеров, durability брокера, outbox, отсутствие утечек/уязвимостей, полноту telemetry или выполнение уровня 4/5. Конкурентные/нагрузочные/негативные проверки своего решения добавляйте отдельно. Список защиты текущей лабы находится в README.md. Проверки рассчитаны на localhost-стенд, не на production. diff --git a/tests/client.py b/tests/client.py new file mode 100644 index 0000000..f0499a2 --- /dev/null +++ b/tests/client.py @@ -0,0 +1,217 @@ +"""HTTP helpers for public black-box tests; Python 3.9+, standard library only.""" +import base64 +import datetime as dt +import hashlib +import json +import os +from pathlib import Path +import re +import ssl +import time +import urllib.error +import urllib.request +import uuid + +ROOT = Path(__file__).resolve().parents[1] +LAB = json.loads((ROOT / 'lab.json').read_text())['number'] +SPEC = json.loads((ROOT / 'contracts/openapi.json').read_text()) + +class Failure(AssertionError): + pass + +def check(condition, message): + if not condition: + raise Failure(message) + +def timestamp(value): + check(isinstance(value, str) and value.endswith('Z'), 'timestamp must be UTC ending in Z') + try: + return dt.datetime.fromisoformat(value[:-1] + '+00:00') + except ValueError as exc: + raise Failure('invalid RFC3339 timestamp') from exc + +def validate(value, schema, where='response'): + """Validate the JSON Schema subset used by the supplied contract, not arbitrary OpenAPI.""" + if '$ref' in schema: + target = SPEC + for part in schema['$ref'].split('/')[1:]: + target = target[part] + return validate(value, target, where) + kinds = schema.get('type', []) + if isinstance(kinds, str): + kinds = [kinds] + matches = {'null':value is None, 'boolean':isinstance(value,bool), 'integer':isinstance(value,int) and not isinstance(value,bool), 'number':isinstance(value,(int,float)) and not isinstance(value,bool), 'string':isinstance(value,str), 'array':isinstance(value,list), 'object':isinstance(value,dict)} + if kinds: + check(any(matches.get(k,False) for k in kinds), where + ': incorrect type') + if 'enum' in schema: + check(value in schema['enum'], where + ': invalid enum') + if 'const' in schema: + check(value == schema['const'], where + ': invalid const') + if isinstance(value,dict): + props = schema.get('properties',{}) + check(all(k in value for k in schema.get('required',[])), where + ': missing required fields') + if schema.get('additionalProperties') is False: + check(not set(value).difference(props), where + ': unexpected fields') + for key, sub in props.items(): + if key in value: + validate(value[key], sub, where + '.' + key) + if isinstance(value,list): + check(len(value) >= schema.get('minItems',0), where + ': too few items') + check(len(value) <= schema.get('maxItems',float('inf')), where + ': too many items') + if schema.get('uniqueItems'): + encoded = [json.dumps(x,sort_keys=True) for x in value] + check(len(encoded)==len(set(encoded)), where + ': duplicate items') + if 'items' in schema: + for item in value: + validate(item, schema['items'], where + '[]') + if isinstance(value,str): + check(len(value) >= schema.get('minLength',0), where + ': too short') + check(len(value) <= schema.get('maxLength',float('inf')), where + ': too long') + if 'pattern' in schema: + check(re.search(schema['pattern'], value) is not None, where + ': pattern mismatch') + if schema.get('format') == 'uuid': + try: + uuid.UUID(value) + except ValueError as exc: + raise Failure(where + ': invalid UUID') from exc + if schema.get('format') == 'date-time': + timestamp(value) + if isinstance(value,(int,float)) and not isinstance(value,bool): + check(value >= schema.get('minimum',-float('inf')), where + ': below minimum') + check(value <= schema.get('maximum',float('inf')), where + ': above maximum') + if 'anyOf' in schema: + for alternative in schema['anyOf']: + try: + validate(value, alternative, where) + break + except Failure: + pass + else: + raise Failure(where + ': no anyOf branch matches') + +def shape(value, name): + validate(value, SPEC['components']['schemas'][name]) + return value + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + +class Client: + def __init__(self, base_url=None, ca_file=None, timeout=10): + self.base = (base_url or os.environ.get('BASE_URL') or ('https://localhost:8443' if LAB==7 else 'http://localhost:8080')).rstrip('/') + check(self.base.startswith(('http://','https://')), 'BASE_URL must be HTTP(S)') + check(LAB != 7 or self.base.startswith('https://'), 'lab7 requires HTTPS') + self.timeout = timeout + context = ssl.create_default_context(cafile=ca_file or os.environ.get('CA_FILE') or None) + self.opener = urllib.request.build_opener(NoRedirect(), urllib.request.HTTPSHandler(context=context)) + + def call(self, method, path, expected=200, body=None, actor=None, headers=None, raw=None, schema=None): + h = {'Accept':'application/json'} + if actor: + h['X-User-Id' if LAB==1 else 'Authorization'] = actor['id'] if LAB==1 else 'Bearer ' + actor['token'] + if body is not None: + raw = json.dumps(body,ensure_ascii=False).encode('utf-8') + h['Content-Type'] = 'application/json' + h.update(headers or {}) + req = urllib.request.Request(self.base + path, data=raw, headers=h, method=method) + start = time.monotonic() + try: + response = self.opener.open(req, timeout=self.timeout) + except urllib.error.HTTPError as error: + response = error + except (urllib.error.URLError,TimeoutError,OSError) as exc: + # Do not print URL query, headers, request body, or credentials. + raise Failure(method + ' ' + path.split('?')[0] + ': connection/TLS failure (' + type(exc).__name__ + ')') from None + with response: + status = response.code + rh = response.headers + data = response.read(16*1024*1024 + 1) + check(len(data) <= 16*1024*1024, 'response exceeds test safety limit') + allowed = [expected] if isinstance(expected,int) else expected + check(status in allowed, method + ' ' + path.split('?')[0] + ': expected ' + str(allowed) + ', received ' + str(status)) + check(bool(rh.get('X-Request-Id')), 'missing X-Request-Id') + if LAB>=3: + check(bool(rh.get('X-Instance-Id')), 'missing X-Instance-Id') + payload = None + if schema or status>=400: + check(rh.get_content_type()=='application/json', 'expected application/json') + try: + payload = json.loads(data) + except (ValueError,UnicodeError): + raise Failure('invalid response JSON') from None + shape(payload, 'Error' if status>=400 else schema) + if status>=400: + check(payload['error']['request_id']==rh.get('X-Request-Id'),'error request_id differs from header') + if status==401 and LAB>=2: + scheme = 'Basic' if path=='/api/v1/auth/sessions' else 'Bearer' + check(rh.get('WWW-Authenticate','').lower().startswith(scheme.lower()), 'incorrect WWW-Authenticate challenge') + if status==204: + check(not data, '204 must not contain body') + return {'status':status,'headers':rh,'json':payload,'bytes':data,'elapsed':time.monotonic()-start} + + def user(self, label): + username = 'u_' + label + '_' + uuid.uuid4().hex[:12] + password = 'T3st-' + uuid.uuid4().hex + body = {'username':username,'display_name':'Студент ' + label} + if LAB>=2: + body['password'] = password + user = self.call('POST','/api/v1/users',201,body=body,schema='User')['json'] + check(user['username']==username,'username changed') + actor = {'id':user['id'],'user':user,'registration':body} + if LAB>=2: + credential = base64.b64encode((username+':'+password).encode()).decode() + session = self.call('POST','/api/v1/auth/sessions',201,headers={'Authorization':'Basic '+credential},schema='Session')['json'] + check(session['user']==user,'session user mismatch') + check(timestamp(session['expires_at']) > dt.datetime.now(dt.timezone.utc),'session already expired') + actor['token'] = session['token'] + return actor + + def bootstrap(self): + self.call('GET','/health/live',schema='Health') + self.call('GET','/health/ready',schema='Health') + alice, bob, eve = (self.user(label) for label in ('alice','bob','eve')) + chat = self.call('POST','/api/v1/chats',201,actor=alice,body={'title':'Контрактный тест','member_ids':[alice['id'],bob['id']]},schema='Chat')['json'] + check(set(chat['member_ids'])=={alice['id'],bob['id']},'chat member mismatch') + return alice,bob,eve,chat + + def message(self, actor, chat, text, key=None, attachments=None): + body = {'text':text} + if attachments is not None: + body['attachment_ids'] = attachments + headers = {'Idempotency-Key':key or uuid.uuid4().hex} if LAB>=3 else {} + return self.call('POST','/api/v1/chats/'+chat['id']+'/messages',201,actor=actor,body=body,headers=headers,schema='Message') + + def attachment(self, actor, chat): + content = (ROOT / 'tests/fixtures/sample.png').read_bytes() + att = self.call('POST','/api/v1/chats/'+chat['id']+'/attachments?filename=sample.png',201 if LAB==4 else 202,actor=actor,raw=content,headers={'Content-Type':'image/png'},schema='Attachment')['json'] + check(att['status']==('ready' if LAB==4 else 'queued'),'incorrect upload status') + check(att['chat_id']==chat['id'] and att['owner_id']==actor['id'],'attachment ownership mismatch') + check(att['sha256']==hashlib.sha256(content).hexdigest() and att['size_bytes']==len(content),'original checksum/size mismatch') + return att,content + + def wait_ready(self, actor, att, wait_seconds=60): + deadline = time.monotonic()+wait_seconds + while time.monotonic()=2 and i+length<=len(data),'invalid JPEG segment length') + if marker in sof: + check(length>=8,'invalid JPEG SOF') + return (int.from_bytes(data[i+5:i+7],'big'),int.from_bytes(data[i+3:i+5],'big')) + i += length + raise Failure('JPEG dimensions not found') diff --git a/tests/load.py b/tests/load.py new file mode 100644 index 0000000..ad81d59 --- /dev/null +++ b/tests/load.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Small closed-loop HTTP write benchmark; not an open-loop capacity proof.""" +from concurrent.futures import ThreadPoolExecutor +import json +import math +from pathlib import Path +import threading +import time +from client import Client, Failure, LAB, arguments, check, run + + +def main(): + def configure(p): + p.add_argument('--duration',type=float,default=30) + p.add_argument('--concurrency',type=int,default=4) + p.add_argument('--output',default=None,help='Optional JSON metrics, no session tokens') + args = arguments('Synthetic POST message load, closed loop; creates users/messages',configure) + check(1<=args.duration<=3600,'duration must be 1..3600 seconds') + check(1<=args.concurrency<=128,'concurrency must be 1..128') + c = Client(args.base_url,args.ca_file) + alice,bob,eve,chat = c.bootstrap() + barrier = threading.Barrier(args.concurrency) + def worker(worker_id): + local = Client(args.base_url,args.ca_file,timeout=5) + barrier.wait() + deadline = time.monotonic()+args.duration + latencies,errors = [],0 + index = 0 + while time.monotonic()=2,'persistence requires lab2+') + check(args.mode!='failover' or LAB>=3,'failover requires lab3+') + script = Path(args.action_script).expanduser().resolve() + check(script.is_file() and os.access(script,os.X_OK),'action script must exist and be executable (chmod +x)') + check('.example.' not in script.name,'copy and implement the example hook first') + c = Client(args.base_url,args.ca_file,timeout=2) + alice,bob,eve,chat = c.bootstrap() + sent = c.message(alice,chat,'Сохранить при отказе') + original = sent['json'] + target = sent['headers'].get('X-Instance-Id','') + attachment = content = None + if LAB>=4: + attachment,content = c.attachment(alice,chat) + if LAB>=5: + attachment = c.wait_ready(alice,attachment) + env = dict(os.environ) + env['TARGET_INSTANCE_ID'] = target + observed = set() + if args.mode=='failover': + for _ in range(40): + observed.add(c.call('GET','/api/v1/users/me',actor=alice,schema='User')['headers']['X-Instance-Id']) + check(len(observed)>=2,'less than two API instances observed before failure; verify LB/session affinity') + print('Executing explicit '+args.mode+' hook; hook output suppressed to avoid leaking secrets.') + # No shell interpolation and no Docker commands in this test. + proc = subprocess.Popen([str(script)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + started = time.monotonic() + successes,failures,after_instances = [],0,[] + try: + if args.mode=='persistence': + try: + code = proc.wait(timeout=90) + except subprocess.TimeoutExpired: + raise Failure('restart hook exceeded 90 seconds') from None + check(code==0,'restart hook failed (inspect your script locally)') + deadline = time.monotonic()+90 + while True: + try: + c.call('GET','/health/ready',schema='Health') + c.call('GET','/api/v1/users/me',actor=alice,schema='User') + break + except Failure: + check(time.monotonic()=5,'too few successful writes after stop') + check(target not in after_instances[-5:],'target replica still serves requests; hook must leave it stopped') + print(json.dumps({'observed_instances_before':len(observed),'successful_writes':len(successes),'failed_attempts':failures,'first_recovery_seconds':round(first_recovery,3)},ensure_ascii=False)) + # Existing token, no re-login. Read all confirmations and reject duplicates. + messages = history(c,bob,chat) + ids = [m['id'] for m in messages] + check(len(ids)==len(set(ids)),'duplicated message IDs in history') + check(any(m==original for m in messages),'confirmed pre-failure message lost or altered') + for msg in successes: + check(any(m==msg for m in messages),'confirmed in-flight message lost or altered') + # Bodies are unique in this scenario: an uncertain request may exist once, never twice. + texts = [m['text'] for m in messages] + check(len(texts)==len(set(texts)),'uncertain retry created duplicate message') + if attachment: + c.call('GET','/api/v1/attachments/'+attachment['id'],actor=bob,schema='Attachment') + downloaded = c.call('GET','/api/v1/attachments/'+attachment['id']+'/content',actor=bob) + check(downloaded['bytes']==content,'attachment lost or changed across failure') + print('PASS: '+args.mode+' preserved confirmed data and existing session. Hook scope needs operator evidence.') + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + +if __name__=='__main__': + run(main) diff --git a/tests/smoke.py b/tests/smoke.py new file mode 100644 index 0000000..4cbfa55 --- /dev/null +++ b/tests/smoke.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""One complete public acceptance scenario, cumulative by lab number.""" +import base64 +import hashlib +import uuid +from client import Client, LAB, arguments, check, run, timestamp + + +def main(): + args = arguments('Messenger public smoke; creates synthetic users and messages') + c = Client(args.base_url,args.ca_file) + alice,bob,eve,chat = c.bootstrap() + path = '/api/v1/chats/'+chat['id'] + c.call('POST','/api/v1/users',409,body=alice['registration']) + c.call('GET','/api/v1/users/me',401) + c.call('GET',path,404,actor=eve) + me = c.call('GET','/api/v1/users/me',actor=alice,schema='User')['json'] + check(me==alice['user'],'current user mismatch') + listed = c.call('GET','/api/v1/chats?limit=100',actor=bob,schema='ChatPage')['json'] + check(any(x['id']==chat['id'] for x in listed['items']),'member cannot list chat') + hidden = c.call('GET','/api/v1/chats?limit=100',actor=eve,schema='ChatPage')['json'] + check(all(x['id']!=chat['id'] for x in hidden['items']),'chat leaks to outsider') + key = uuid.uuid4().hex + first = c.message(alice,chat,'Привет, мир 👋',key=key)['json'] + second = c.message(bob,chat,'Сообщение Боба')['json'] + check(first['sender_id']==alice['id'] and second['sender_id']==bob['id'],'sender was not derived from actor') + check(first['chat_id']==second['chat_id']==chat['id'],'wrong chat in message') + check(first['text']=='Привет, мир 👋' and second['text']=='Сообщение Боба','message text changed') + page1 = c.call('GET',path+'/messages?limit=1',actor=bob,schema='MessagePage')['json'] + check(len(page1['items'])==1 and page1['next_cursor'],'first page must have one item and cursor') + from urllib.parse import quote + page2 = c.call('GET',path+'/messages?limit=1&cursor='+quote(page1['next_cursor'],safe=''),actor=alice,schema='MessagePage')['json'] + check(len(page2['items'])==1 and page2['next_cursor'] is None,'second page must finish history') + ordered = sorted([first,second],key=lambda x:(timestamp(x['created_at']),uuid.UUID(x['id']).int)) + check(page1['items']+page2['items']==ordered,'pagination lost, duplicated or reordered a message') + h = {'Idempotency-Key':uuid.uuid4().hex} if LAB>=3 else {} + c.call('POST',path+'/messages',400,actor=alice,body={'text':''},headers=h) + c.call('POST',path+'/messages',404,actor=eve,body={'text':'чужое'},headers=h) + c.call('GET',path+'/messages?limit=0',400,actor=alice) + if LAB>=2: + bad = base64.b64encode((alice['registration']['username']+':wrong-password').encode()).decode() + c.call('POST','/api/v1/auth/sessions',401,headers={'Authorization':'Basic '+bad}) + good = base64.b64encode((alice['registration']['username']+':'+alice['registration']['password']).encode()).decode() + c.call('GET','/api/v1/users/me',401,headers={'Authorization':'Basic '+good}) + c.call('GET','/api/v1/users/me',401,headers={'X-User-Id':alice['id']}) + me = c.call('GET','/api/v1/users/me',actor=alice,headers={'X-User-Id':eve['id']},schema='User')['json'] + check(me['id']==alice['id'],'X-User-Id overrode bearer identity') + if LAB>=3: + replay = c.message(alice,chat,first['text'],key=key)['json'] + check(replay==first,'idempotency replay differs') + c.call('POST',path+'/messages',409,actor=alice,body={'text':'другой body'},headers={'Idempotency-Key':key}) + c.call('POST',path+'/messages',400,actor=alice,body={'text':'без ключа'}) + if LAB>=4: + att,content = c.attachment(alice,chat) + # A queued attachment must already be linkable to its own chat. + attached = c.message(alice,chat,'',attachments=[att['id']])['json'] + check(attached['attachment_ids']==[att['id']],'message lost attachment') + ready = c.wait_ready(bob,att) if LAB>=5 else att + check(ready['error_code'] is None,'ready attachment has an error') + download = c.call('GET','/api/v1/attachments/'+att['id']+'/content',actor=bob) + check(download['bytes']==content,'download differs from original') + check(download['headers'].get_content_type()=='image/png','original content type mismatch') + for suffix in ('','/content'): + c.call('GET','/api/v1/attachments/'+att['id']+suffix,404,actor=eve) + if LAB>=5: + variants = [x for x in ready['variants'] if x['name']=='thumbnail'] + check(len(variants)==1,'expected exactly one thumbnail') + v = variants[0] + check((v['width'],v['height'])==(256,160),'thumbnail metadata has wrong dimensions') + thumb = c.call('GET','/api/v1/attachments/'+att['id']+'/content?variant=thumbnail',actor=bob) + check(thumb['headers'].get_content_type()=='image/jpeg','thumbnail content type mismatch') + check(len(thumb['bytes'])==v['size_bytes'] and hashlib.sha256(thumb['bytes']).hexdigest()==v['sha256'],'thumbnail checksum mismatch') + from image_probe import jpeg_size + check(jpeg_size(thumb['bytes'])==(256,160),'actual JPEG dimensions mismatch') + if LAB>=2: + c.call('DELETE','/api/v1/auth/sessions/current',204,actor=alice) + c.call('GET','/api/v1/users/me',401,actor=alice) + c.call('DELETE','/api/v1/auth/sessions/current',401,actor=alice) + print('PASS: lab%d public API scenario; infrastructure and grading evidence remain separate.' % LAB) + +if __name__=='__main__': + run(main)