From 65003b61195361aac238f19c9bff0027ca58e09e Mon Sep 17 00:00:00 2001 From: Mikhail Verkovykh Date: Thu, 10 Sep 2026 18:01:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20skeleton=20=D0=BB=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=BD=D0=BE=D0=B9=202=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BC=D0=B5=D1=81=D1=81=D0=B5=D0=BD=D0=B4=D0=B6=D0=B5?= =?UTF-8?q?=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 20 + .env.example | 11 + .github/pull_request_template.md | 18 + .github/workflows/contracts.yml | 16 + .gitignore | 32 + CONTRIBUTING.md | 19 + COURSE.md | 53 + Dockerfile.example | 8 + MAINTAINERS.md | 38 + Makefile | 26 + README.md | 184 ++- REPORT.md | 37 + compose.example.yaml | 10 + contracts/README.md | 61 + contracts/examples.json | 58 + contracts/infrastructure.json | 31 + contracts/openapi.json | 2026 ++++++++++++++++++++++++++++++ evidence/README.md | 5 + examples/requests.http | 45 + infra/README.md | 7 + lab.json | 8 + requirements-dev.txt | 2 + scripts/check_contract.py | 54 + scripts/restart.example.sh | 10 + scripts/validate_openapi.py | 23 + tests/README.md | 57 + tests/client.py | 217 ++++ tests/load.py | 58 + tests/resilience.py | 129 ++ tests/smoke.py | 82 ++ 30 files changed, 3247 insertions(+), 98 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/contracts.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 COURSE.md create mode 100644 Dockerfile.example create mode 100644 MAINTAINERS.md create mode 100644 Makefile create mode 100644 REPORT.md create mode 100644 compose.example.yaml create mode 100644 contracts/README.md create mode 100644 contracts/examples.json create mode 100644 contracts/infrastructure.json create mode 100644 contracts/openapi.json create mode 100644 evidence/README.md create mode 100644 examples/requests.http create mode 100644 infra/README.md create mode 100644 lab.json create mode 100644 requirements-dev.txt create mode 100644 scripts/check_contract.py create mode 100755 scripts/restart.example.sh create mode 100644 scripts/validate_openapi.py create mode 100644 tests/README.md create mode 100644 tests/client.py create mode 100644 tests/load.py create mode 100644 tests/resilience.py create mode 100644 tests/smoke.py 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..714c807 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# 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 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..8ceefa1 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Лабораторная 2 + +Автор / группа: 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..7b7c352 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,38 @@ +# Памятка преподавателю — лабораторная 2 + +## Что подготовлено + +Язык реализации не задан. 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..9ecb223 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +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 + +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) diff --git a/README.md b/README.md index c164f2f..0e073ff 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,105 @@ -# mtusi2026lab2 +# Лабораторная 2. PostgreSQL, сессии и Redis/Valkey -*** -## С чего начать? -Для того, чтобы облегчить знакомство с сервисом GitFlic и первые шаги в нём, мы подготовили несколько рекомендаций. -Уже опытный пользователь? Отредактируйте данный **README** файл по своему усмотрению. -Не знаете что добавить в него? Перейдите в раздел `"Что должен содержать README файл"`, в котором описаны ключевые компоненты хорошего README файла. +Перенести состояние в постоянное хранилище и связать запросы с аутентифицированным пользователем. Подтверждённые сообщения и действующие сессии переживают перезапуск. -## Добавьте свои файлы -Если вы решили начать разработку проекта с создания репозитория в нашем сервисе, тогда клонируйте себе данный репозиторий следующим образом: +Это **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) -``` -git clone https://gitflic.ru/project/vmd/mtusi2026lab2.git -cd mtusi2026lab2 -**добавьте первые файлы вашего проекта** -git add . -git commit -m "Первый коммит" -git push -u origin master +## Откуда начинаем + +Перенесите собственный минимум lab1. Изменения API: password при регистрации, Basic для создания сессии, Bearer вместо X-User-Id для бизнес-запросов. + +**Архитектура:** Клиент → API → PostgreSQL; на 4 появляется Redis ИЛИ Valkey для сессий/кэша. + +## Что сделать + +1. Опишите таблицы, ограничения, индексы и транзакционные границы до написания запросов. Добавьте версионируемые миграции. +2. Перенесите пользователей, чаты, членство и сообщения в PostgreSQL. Не используйте SQLite как замену PostgreSQL в принимаемом стенде. +3. Реализуйте регистрацию с password KDF, вход через Basic, opaque Bearer-сессию, expiry и logout. Действующие сессии тоже должны сохраняться. +4. Подготовьте единый локальный запуск API и БД (Compose рекомендуется уже здесь), подключите именованные volumes. +5. Пройдите smoke и тест полного перезапуска. На 4 добавьте Redis/Valkey с конкретной ролью и повторите эксперимент отказа. + +## Оценка «3»: работающий минимум + +- Минимум lab1 сохранён с объявленным переходом на новую аутентификацию. Все обязательные данные и ещё действующие сессии переживают рестарт API и хранилищ без удаления volumes. +- PostgreSQL содержит пользователей, чаты, участников и сообщения. Есть первичные/внешние ключи, уникальность username и миграции с нуля. Составная операция создания чата выполняется атомарно. +- Basic применяется только на POST /auth/sessions, остальные endpoint требуют Bearer. Есть срок жизни сессии и отзыв; X-User-Id не даёт доступ. Пароли уже хешируются password KDF. +- make test и make test-persistence ACTION_SCRIPT=scripts/restart.sh проходят. Миграции повторно запускаются без дублирования данных. Secrets/volumes не попадают в Git. + +## Оценка «4»: уровень стажёра/джуна + +Весь уровень 3 **и все** пункты ниже: + +- Redis ИЛИ Valkey реально используется для сессий с TTL либо осмысленного кэша. Опишите источник истины, политику expiry и инвалидации. Если это единственное хранилище сессий, включите persistence, выдерживающее проверяемый рестарт; один TTL не сохраняет данные. +- Используются параметризованные запросы/безопасный ORM, ограниченный connection pool и таймауты. Проверены гонка регистрации одинакового username и rollback составной операции. +- Индекс истории обоснован запросом и EXPLAIN (ANALYZE, BUFFERS) на ≥10 000 сообщениях; пагинация не использует полный scan всей истории на каждую страницу. +- Есть автоматическая проверка expiry при коротком SESSION_TTL_SECONDS, logout, неверного пароля и недоступности Redis/БД. При потере сервиса приложение возвращает ограниченную ошибку/корректно обходит кэш, не принимает запрос от анонимного пользователя как доверенный. + +## Оценка «5»: исследование и доказанный результат + +Весь уровень 4 **и один законченный трек на выбор**. Приведите гипотезу, повторяемую методику, результаты и ограничения. + +**Трек 1. Согласованность кэша.** Покажите на конкурентном сценарии stale read или cache stampede, реализуйте защиту и измерьте число SQL-запросов/latency до и после. Докажите read-after-write из контракта и восстановление после рестарта кэша; одной установки Redis недостаточно. + +**Трек 2. План запроса и восстановление.** Исследуйте ≥100 000 сообщений с неравномерными размерами чатов, сравните ≥2 индекса/запроса и стоимость записи. Сделайте backup → восстановление в новое хранилище → сверку количества и выбранных сообщений. Обсудите время восстановления и границы потери данных. + +## Как начать и проверить + +```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. ``` -cd existing_folder -git init -git remote add origin https://gitflic.ru/project/vmd/mtusi2026lab2.git -git clone -**добавьте новые файлы** -git add . -git commit -m "Новый коммит" -git push -u origin master + + +Примеры запросов доступны в `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), остальные доказательства приведите в отчёте. -# Что должен содержать README файл +## Сценарий защиты +1. Применить миграции к пустому volume, создать пользователей, войти, отправить сообщение. +2. Запустить persistence-сценарий: он держит токен только в памяти тестового процесса; action script перезапускает API и все используемые хранилища, сохраняя volumes. +3. После готовности тот же токен читает те же данные; после logout — 401. Повторить с отдельным коротким TTL для проверки expiry. +4. Для 4: показать роль Redis/Valkey, план SQL и ошибку зависимости. Для 5: выполнить выбранный эксперимент. -Прежде всего, стоит понимать, что `README.md` — это краткая документация. Это первое, что видит человек, который открывает репозиторий. Поэтому здесь важно дать достаточно информации о проекте и рассказать, что он из себя представляет. -Ключевая информация, которую должен содержать README файл: +## Что должно быть в PR -## Название и описание -Название проекта должно быть простым и понятным (чаще всего это одно слово). -Описание должно описывать основные функции проекта, включая его особенности и назначение. -Если у вашего проекта есть альтернативные проекты, то в описании можно перечислить ключевые отличия, которые выделяют ваш проект на фоне всех остальных. +Исходники, Dockerfile, миграции, compose.yaml либо эквивалентный воспроизводимый запуск, scripts/restart.sh, собственные тесты и отчёт. -## Установка и настройка -Также в `README` файле рекомендуется перечислить необходимые инструкции для установки, -будь то использование пакетных менеджеров (например, `Homebrew` на MacOS или `apt` на Linux), -зависимости, которые могут понадобиться в ходе использования, а также шаги по их настройке. +Заполните `REPORT.md` и [шаблон PR](.github/pull_request_template.md), укажите целевую оценку/трек. Не меняйте обязательный контракт и публичные tests ради зелёного результата. Сохраняйте возможности предыдущих лабораторных в пределах [объявленных переходов](COURSE.md). -## Совместная разработка -Можно добавить информацию о том, как принять участие в разработке вашего проекта, как стать непосредственным участником, правила оформления pull-requests и т.д. +## Вопросы на защите -## Контакты -Ссылки на внешние ресурсы, такие как документация, блог, страница проекта в социальных сетях, сообщество проекта и т.д. +- Что подтверждает COMMIT и какие гарантии зависят от настройки durability? +- Где хранятся сессии и что будет после flush/restart Redis? +- Чем аутентификация отличается от доступа к конкретному чату? +- Почему hash пароля и быстрый checksum имеют разные задачи? -## Статус проекта -В данном разделе рекомендуется указывать, на какой стадии находится проект, активно разрабатывается или находится в стадии застоя. -Если же проект готов и во всю используется, можно указывать актуальную версию, а также последние изменения, которые были сделаны с момента предыдущего релиза. +## Первичные материалы -*** - -# Полезные ссылки - -*** - -## Работа с проектом - -- [ ] [Как создать проект](https://docs.gitflic.ru/project/project_create) -- [ ] [Как импортировать проект](https://docs.gitflic.ru/project/import_base) -- [ ] [Запросы на слияние](https://docs.gitflic.ru/project/merge_request) -- [ ] [Зеркалирование проекта](https://docs.gitflic.ru/project/mirror) -- [ ] [Импортировать проект с GitLab](https://docs.gitflic.ru/project/import) - -## Команды -- [ ] [Создание команды](https://docs.gitflic.ru/team/create) -- [ ] [Обзор команды](https://docs.gitflic.ru/team/view) -- [ ] [Настройка команды](https://docs.gitflic.ru/team/settings) - -## Реестр пакетов -- [ ] [Реестр пакетов](https://docs.gitflic.ru/registry/package) -- [ ] [PyPi](https://docs.gitflic.ru/registry/pypi_registry) -- [ ] [Generic](https://docs.gitflic.ru/registry/generic_registry) -- [ ] [Maven](https://docs.gitflic.ru/registry/maven_registry) -- [ ] [Docker](https://docs.gitflic.ru/registry/docker) - -## Компании -- [ ] [Создание компании](https://docs.gitflic.ru/company/create) -- [ ] [Обзор компании](https://docs.gitflic.ru/company/view) -- [ ] [Тарифы и оплата](https://docs.gitflic.ru/company/price) -- [ ] [Запуск агента компании](https://docs.gitflic.ru/company/saas_runner_setup) - -## CI/CD -- [ ] [Что такое GitFlic CI/CD](https://docs.gitflic.ru/cicd/introduction) -- [ ] [Задача (Job)](https://docs.gitflic.ru/cicd/job) -- [ ] [Конвейер (pipeline)](https://docs.gitflic.ru/cicd/pipeline) -- [ ] [Агенты](https://docs.gitflic.ru/cicd/agent) -- [ ] [Справочник для .yaml файла](https://docs.gitflic.ru/cicd/gitflic-ci-yaml) - -## API -- [ ] [Введение в GitFlic API](https://docs.gitflic.ru/api/intro) -- [ ] [Методы для администратора](https://docs.gitflic.ru/api/admin) -- [ ] [Получение access токена](https://docs.gitflic.ru/api/access-token) - - -## Панель администратора -- [ ] [Панель администратора](https://docs.gitflic.ru/admin_panel/intro) -- [ ] [Панель управления](https://docs.gitflic.ru/admin_panel/dashboard) -- [ ] [Настройка LDAP](https://docs.gitflic.ru/admin_panel/ldap) -- [ ] [Ключевые настройки](https://docs.gitflic.ru/admin_panel/settings) - -## Общая информация -- [ ] [Глоссарий](https://docs.gitflic.ru/common/gloss) -- [ ] [Права доступа ролей](https://docs.gitflic.ru/common/manage_roles) -- [ ] [Вебхуки](https://docs.gitflic.ru/common/webhook) \ No newline at end of file +- [PostgreSQL transactions](https://www.postgresql.org/docs/current/tutorial-transactions.html) +- [PostgreSQL EXPLAIN](https://www.postgresql.org/docs/current/using-explain.html) +- [Valkey persistence](https://valkey.io/topics/persistence/) +- [Redis persistence](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/) +- [Basic authentication](https://www.rfc-editor.org/info/rfc7617/) diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 0000000..e42f2b0 --- /dev/null +++ b/REPORT.md @@ -0,0 +1,37 @@ +# Отчёт — лабораторная 2 + +- Автор, группа: TODO +- Целевая оценка; выбранный трек 5, если нужен: TODO +- Commit/PR: TODO после публикации +- Использованные источники, библиотеки и помощь ИИ: TODO + +## Запуск из чистого clone + +TODO: версии runtime/образов, prerequisites, команды создания локальных secrets/.env, build, миграций, запуска, ожидания готовности, тестов и остановки. Указать необходимые действия оператора. Не вставлять секреты. Отдельно указать безопасную остановку и команду намеренного удаления учебных данных. + +## Архитектура и решения + +TODO: схема процессов/хранилищ, источник истины, транзакционные границы, ограничения. Что перенесено из lab1, что изменено в этой работе. Соответствие переменных из .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..52524b0 --- /dev/null +++ b/compose.example.yaml @@ -0,0 +1,10 @@ +# 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: 2 +x-required-roles: + - api + - postgres +# 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/examples.json b/contracts/examples.json new file mode 100644 index 0000000..eaa4fcd --- /dev/null +++ b/contracts/examples.json @@ -0,0 +1,58 @@ +{ + "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" + }, + "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" + } + ], + "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..1278ec4 --- /dev/null +++ b/contracts/infrastructure.json @@ -0,0 +1,31 @@ +{ + "lab": 2, + "kind": "requirements, not runnable deployment", + "required_for_grade_3": [ + { + "role": "api", + "count_min": 1, + "student_implements": true, + "persistent_local_state": false + }, + { + "role": "postgres", + "purpose": "users/chats/membership/messages/metadata", + "persistent_volume": 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/openapi.json b/contracts/openapi.json new file mode 100644 index 0000000..921d32d --- /dev/null +++ b/contracts/openapi.json @@ -0,0 +1,2026 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "MTUSI Messenger — лабораторная 2", + "version": "1.2.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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chat" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatPage" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chat" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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" + } + } + ], + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessagePage" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "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 + } + }, + "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 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "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", + "minLength": 1, + "maxLength": 4000 + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "chat_id", + "sender_id", + "text", + "created_at" + ], + "additionalProperties": false, + "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" + } + ] + }, + "CreateMessage": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + } + }, + "required": [ + "text" + ], + "additionalProperties": false, + "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" + } + ], + "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 + } + } + } +} 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..336c50e --- /dev/null +++ b/examples/requests.http @@ -0,0 +1,45 @@ +# 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}} +Content-Type: application/json + +{"text":"Привет, Боб!"} + +### Read first page +GET {{baseUrl}}/api/v1/chats/{{chatId}}/messages?limit=50 +Authorization: Bearer {{sessionToken}} 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/lab.json b/lab.json new file mode 100644 index 0000000..a618cd3 --- /dev/null +++ b/lab.json @@ -0,0 +1,8 @@ +{ + "number": 2, + "title": "PostgreSQL, сессии и Redis/Valkey", + "contract_version": "1.2.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/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,'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)