Подготовить skeleton лабораторной 2 по мессенджеру

This commit is contained in:
Mikhail Verkovykh
2026-09-10 18:01:35 +03:00
parent 4c14ccbadd
commit 65003b6119
30 changed files with 3247 additions and 98 deletions
+20
View File
@@ -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
+11
View File
@@ -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
+18
View File
@@ -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 содержит инструкцию именно моего решения.
+16
View File
@@ -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.
+32
View File
@@ -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.
+19
View File
@@ -0,0 +1,19 @@
# Работа через fork и pull request
1. После публикации преподавателем сделайте fork репозитория **текущей** лабораторной в своём аккаунте и clone своего fork. URL преподаватель выдаёт отдельно.
2. Создайте ветку `solution/<group>-<surname>` от исходной ветки 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.
+53
View File
@@ -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).
+8
View File
@@ -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.
+38
View File
@@ -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 в среде подготовки не был доступен. Это проверка материалов и тестовых драйверов, не приёмка будущих решений студентов.
+26
View File
@@ -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)
+86 -98
View File
@@ -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 Перенесите собственный минимум lab1. Изменения API: password при регистрации, Basic для создания сессии, Bearer вместо X-User-Id для бизнес-запросов.
**добавьте первые файлы вашего проекта**
git add . **Архитектура:** Клиент → API → PostgreSQL; на 4 появляется Redis ИЛИ Valkey для сессий/кэша.
git commit -m "Первый коммит"
git push -u origin master ## Что сделать
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 Примеры запросов доступны в `examples/requests.http`, примеры JSON — в `contracts/examples.json`. Для полной проверки схемы (по желанию локально; CI делает её автоматически):
git clone
**добавьте новые файлы** ```sh
git add . python3 -m venv .venv
git commit -m "Новый коммит" .venv/bin/python -m pip install -r requirements-dev.txt
git push -u origin master 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` — это краткая документация. Это первое, что видит человек, который открывает репозиторий. Поэтому здесь важно дать достаточно информации о проекте и рассказать, что он из себя представляет. ## Что должно быть в PR
Ключевая информация, которую должен содержать README файл:
## Название и описание Исходники, Dockerfile, миграции, compose.yaml либо эквивалентный воспроизводимый запуск, scripts/restart.sh, собственные тесты и отчёт.
Название проекта должно быть простым и понятным (чаще всего это одно слово).
Описание должно описывать основные функции проекта, включая его особенности и назначение.
Если у вашего проекта есть альтернативные проекты, то в описании можно перечислить ключевые отличия, которые выделяют ваш проект на фоне всех остальных.
## Установка и настройка Заполните `REPORT.md` и [шаблон PR](.github/pull_request_template.md), укажите целевую оценку/трек. Не меняйте обязательный контракт и публичные tests ради зелёного результата. Сохраняйте возможности предыдущих лабораторных в пределах [объявленных переходов](COURSE.md).
Также в `README` файле рекомендуется перечислить необходимые инструкции для установки,
будь то использование пакетных менеджеров (например, `Homebrew` на MacOS или `apt` на Linux),
зависимости, которые могут понадобиться в ходе использования, а также шаги по их настройке.
## Совместная разработка ## Вопросы на защите
Можно добавить информацию о том, как принять участие в разработке вашего проекта, как стать непосредственным участником, правила оформления pull-requests и т.д.
## Контакты - Что подтверждает COMMIT и какие гарантии зависят от настройки durability?
Ссылки на внешние ресурсы, такие как документация, блог, страница проекта в социальных сетях, сообщество проекта и т.д. - Где хранятся сессии и что будет после flush/restart Redis?
- Чем аутентификация отличается от доступа к конкретному чату?
- Почему hash пароля и быстрый checksum имеют разные задачи?
## Статус проекта ## Первичные материалы
В данном разделе рекомендуется указывать, на какой стадии находится проект, активно разрабатывается или находится в стадии застоя.
Если же проект готов и во всю используется, можно указывать актуальную версию, а также последние изменения, которые были сделаны с момента предыдущего релиза.
*** - [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/)
## Работа с проектом
- [ ] [Как создать проект](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)
+37
View File
@@ -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: какие отказы выдерживаются и какие нет; что не реализовано; где учебное упрощение; какое улучшение подтверждено, а какое пока гипотеза.
+10
View File
@@ -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: {}
+61
View File
@@ -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` — 1100 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: <UUID существующего пользователя>`; отсутствие/неизвестный ID — 401. Это учебный выбор пользователя: любой клиент может выдать себя за другого.
**Lab27:** `POST /auth/sessions` принимает только `Authorization: Basic <base64(username:password)>`, без JSON body. Username ограничен ASCII; пароль кодируется UTF-8, 12128 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 <token>`. Истёкший/отозванный/неизвестный токен — 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, не бинарное содержимое.
+58
View File
@@ -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"
}
}
}
+31
View File
@@ -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."
]
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# Доказательства
Храните небольшие обезличенные результаты: команды и stdout проверок, JSON/CSV агрегатов нагрузки, EXPLAIN, dashboard JSON, текст разбора отказа. Укажите дату, commit, аппаратные лимиты и профиль нагрузки.
Не добавляйте .env, session tokens, cookies, приватные ключи, credentials, реальные сообщения, raw dumps и signed URLs. Для скриншотов скрывайте секреты. Большие логи/бинарные traces сохраняйте отдельно по правилам преподавателя; одного скриншота без методики недостаточно.
+45
View File
@@ -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}}
+7
View File
@@ -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-реплику.
+8
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
# Only for full OpenAPI/JSON Schema validation; HTTP tests use stdlib.
openapi-spec-validator==0.7.2
+54
View File
@@ -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()
+10
View File
@@ -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
+23
View File
@@ -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.')
+57
View File
@@ -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.
+217
View File
@@ -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()<deadline:
att = self.call('GET','/api/v1/attachments/'+att['id'],actor=actor,schema='Attachment')['json']
check(att['status']!='failed','attachment reached failed')
if att['status']=='ready':
return att
time.sleep(0.3)
raise Failure('attachment did not become ready within test deadline')
def arguments(description, configure=None):
import argparse
p = argparse.ArgumentParser(description=description)
p.add_argument('--base-url', default=None)
p.add_argument('--ca-file', default=None)
if configure:
configure(p)
return p.parse_args()
def run(main):
try:
main()
except (Failure,ValueError,OSError) as exc:
print('FAIL:', str(exc))
raise SystemExit(1)
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Small closed-loop HTTP write benchmark; not an open-loop capacity proof."""
from concurrent.futures import ThreadPoolExecutor
import json
import math
from pathlib import Path
import threading
import time
from client import Client, Failure, LAB, arguments, check, run
def main():
def configure(p):
p.add_argument('--duration',type=float,default=30)
p.add_argument('--concurrency',type=int,default=4)
p.add_argument('--output',default=None,help='Optional JSON metrics, no session tokens')
args = arguments('Synthetic POST message load, closed loop; creates users/messages',configure)
check(1<=args.duration<=3600,'duration must be 1..3600 seconds')
check(1<=args.concurrency<=128,'concurrency must be 1..128')
c = Client(args.base_url,args.ca_file)
alice,bob,eve,chat = c.bootstrap()
barrier = threading.Barrier(args.concurrency)
def worker(worker_id):
local = Client(args.base_url,args.ca_file,timeout=5)
barrier.wait()
deadline = time.monotonic()+args.duration
latencies,errors = [],0
index = 0
while time.monotonic()<deadline:
start = time.monotonic()
try:
local.message(alice,chat,'load-%s-%s' % (worker_id,index))
latencies.append(time.monotonic()-start)
except Failure:
errors += 1
index += 1
return latencies,errors
started = time.monotonic()
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
results = list(pool.map(worker,range(args.concurrency)))
elapsed = time.monotonic()-started
latencies = sorted(x for values,_ in results for x in values)
errors = sum(n for _,n in results)
total = len(latencies)+errors
def percentile(p):
return round(1000*latencies[max(0,math.ceil(p*len(latencies))-1)],3) if latencies else None
report = {'lab':LAB,'workload':'closed-loop POST messages; no automatic retries','requested_duration_seconds':args.duration,'elapsed_seconds':round(elapsed,3),'concurrency':args.concurrency,'successful_requests':len(latencies),'failed_requests':errors,'error_ratio':round(errors/total,6) if total else None,'successful_rps':round(len(latencies)/elapsed,3),'successful_latency_ms':{'p50':percentile(.50),'p95':percentile(.95),'p99':percentile(.99)},'note':'Latency percentiles cover successes only. Timeouts/errors are counted separately; closed-loop has coordinated omission and client overhead.'}
text = json.dumps(report,ensure_ascii=False,indent=2)
if args.output:
dest = Path(args.output)
dest.parent.mkdir(parents=True,exist_ok=True)
dest.write_text(text+'\n')
print(text)
check(bool(latencies),'no successful writes')
# Errors are reported, not treated as a universal load-test threshold.
if __name__=='__main__':
run(main)
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Explicit operator-authored hook; tokens remain in memory, volumes are never removed by this test."""
import json
import os
from pathlib import Path
import subprocess
import time
import uuid
from urllib.parse import quote
from client import Client, Failure, LAB, arguments, check, run
def history(c, actor, chat):
path = '/api/v1/chats/'+chat['id']+'/messages?limit=100'
result = []
seen = set()
for _ in range(1000):
page = c.call('GET',path,actor=actor,schema='MessagePage')['json']
result.extend(page['items'])
cursor = page['next_cursor']
if cursor is None:
return result
check(cursor not in seen,'cursor cycle')
seen.add(cursor)
path = '/api/v1/chats/'+chat['id']+'/messages?limit=100&cursor='+quote(cursor,safe='')
raise Failure('history pagination exceeded safety bound')
def main():
def configure(p):
p.add_argument('--mode', choices=['persistence','failover'], required=True)
p.add_argument('--action-script',required=True,help='Executable script authored by student; restarts stack or stops ONE API replica')
args = arguments('Explicit restart/failover scenario; modifies only synthetic API data',configure)
check(LAB>=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()<deadline,'stack/session did not recover within 90 seconds')
time.sleep(0.5)
else:
first_recovery = None
key = uuid.uuid4().hex
text = 'Запись во время отказа ' + key
while time.monotonic()-started < 15:
code = proc.poll()
check(code is None or code==0,'stop-one hook failed (inspect your script locally)')
check(code is not None or time.monotonic()-started<10,'stop-one hook must return within 10 seconds')
try:
response = c.message(alice,chat,text,key=key)
msg = response['json']
if code==0:
if first_recovery is None:
first_recovery = time.monotonic()-started
after_instances.append(response['headers']['X-Instance-Id'])
successes.append(msg)
key = uuid.uuid4().hex
text = 'Запись во время отказа ' + key
except Failure:
failures += 1
# Keep SAME key/body after an uncertain POST result.
time.sleep(0.1)
check(proc.poll()==0,'stop-one hook did not finish successfully')
check(first_recovery is not None and first_recovery<=10,'writes did not recover within 10 seconds from hook start')
check(len(after_instances)>=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)
+82
View File
@@ -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)