Подготовить skeleton лабораторной 4 по мессенджеру
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copy to .env for your LOCAL synthetic test environment; never commit .env.
|
||||
# These are logical interface suggestions; map actual framework names in REPORT.md.
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8080
|
||||
APP_ENV=lab
|
||||
SESSION_TTL_SECONDS=3600
|
||||
# Choose credentials locally. File paths contain no secret values.
|
||||
DATABASE_URL_FILE=/run/secrets/database_url
|
||||
# Optional on 3, required use on 4 in lab2; Redis OR Valkey:
|
||||
SESSION_STORE=postgres
|
||||
CACHE_URL_FILE=/run/secrets/cache_url
|
||||
API_REPLICAS=2
|
||||
IDEMPOTENCY_TTL_SECONDS=86400
|
||||
S3_ENDPOINT=http://s3:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=messenger-attachments
|
||||
S3_ACCESS_KEY_ID_FILE=/run/secrets/s3_access_key_id
|
||||
S3_SECRET_ACCESS_KEY_FILE=/run/secrets/s3_secret_access_key
|
||||
MAX_UPLOAD_BYTES=10485760
|
||||
MAX_IMAGE_PIXELS=40000000
|
||||
@@ -0,0 +1,18 @@
|
||||
## Лабораторная 4
|
||||
|
||||
Автор / группа: TODO
|
||||
Целевая оценка (3/4/5): TODO
|
||||
Трек 5, если заявлен: TODO
|
||||
|
||||
Что реализовано и что перенесено: TODO
|
||||
Команды запуска из чистого clone: TODO
|
||||
Результаты проверок и ссылка на REPORT.md: TODO
|
||||
Ограничения / известные ошибки: TODO
|
||||
Источники и использование ИИ: TODO
|
||||
|
||||
- [ ] Весь обязательный минимум текущей и предыдущих работ сохранён с объявленными переходами.
|
||||
- [ ] Контракт и публичные тесты не ослаблены.
|
||||
- [ ] make check и make test выполнены; приложен реальный вывод.
|
||||
- [ ] Сценарии отказов/безопасности своего уровня воспроизведены.
|
||||
- [ ] Все секреты и приватные ключи находятся вне Git; отчёт не содержит tokens.
|
||||
- [ ] README задания сохранён, REPORT.md содержит инструкцию именно моего решения.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Памятка преподавателю — лабораторная 4
|
||||
|
||||
## Что подготовлено
|
||||
|
||||
Язык реализации не задан. 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 в среде подготовки не был доступен. Это проверка материалов и тестовых драйверов, не приёмка будущих решений студентов.
|
||||
@@ -0,0 +1,30 @@
|
||||
PYTHON ?= python3
|
||||
BASE_URL ?= http://localhost:8080
|
||||
CA_FILE ?=
|
||||
ACTION_SCRIPT ?=
|
||||
TLS_ARGS = $(if $(CA_FILE),--ca-file "$(CA_FILE)",)
|
||||
|
||||
.PHONY: help check validate test load test-persistence test-failover
|
||||
|
||||
help:
|
||||
@echo "check: offline structure; validate: full OpenAPI (requirements-dev.txt); test: running API; load: synthetic writes"
|
||||
|
||||
check:
|
||||
$(PYTHON) scripts/check_contract.py
|
||||
|
||||
validate:
|
||||
$(PYTHON) scripts/validate_openapi.py
|
||||
|
||||
test:
|
||||
$(PYTHON) tests/smoke.py --base-url "$(BASE_URL)" $(TLS_ARGS)
|
||||
|
||||
load:
|
||||
$(PYTHON) tests/load.py --base-url "$(BASE_URL)" $(TLS_ARGS)
|
||||
|
||||
test-persistence:
|
||||
@test -n "$(ACTION_SCRIPT)" || (echo "Set ACTION_SCRIPT to your implemented executable restart hook"; exit 2)
|
||||
$(PYTHON) tests/resilience.py --mode persistence --action-script "$(ACTION_SCRIPT)" --base-url "$(BASE_URL)" $(TLS_ARGS)
|
||||
|
||||
test-failover:
|
||||
@test -n "$(ACTION_SCRIPT)" || (echo "Set ACTION_SCRIPT to your implemented executable stop-one hook"; exit 2)
|
||||
$(PYTHON) tests/resilience.py --mode failover --action-script "$(ACTION_SCRIPT)" --base-url "$(BASE_URL)" $(TLS_ARGS)
|
||||
@@ -0,0 +1,105 @@
|
||||
# Лабораторная 4. S3-хранилище и вложения
|
||||
|
||||
Разделить метаданные и бинарное содержимое, добавить изображения в сообщения и сохранить доступ к ним при переключении API-реплики.
|
||||
|
||||
Это **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)
|
||||
|
||||
## Откуда начинаем
|
||||
|
||||
Перенесите минимум lab3. Дополняются поля сообщения и endpoint вложений; прежние текстовые POST остаются валидными.
|
||||
|
||||
**Архитектура:** Клиент → LB → API × 2+ → PostgreSQL (метаданные) + приватное S3-хранилище (байты).
|
||||
|
||||
## Что сделать
|
||||
|
||||
1. Выберите один S3-совместимый сервер: MinIO, SeaweedFS или RustFS. Закрепите проверенную версию образа и опишите создание приватного bucket.
|
||||
2. Спроектируйте Attachment и связь с чатом/сообщением. S3 object key назначает сервер, filename используется только для отображения.
|
||||
3. Реализуйте синхронный binary upload PNG/JPEG с ограничениями, сохранение метаданных и авторизованный download через API.
|
||||
4. Включите S3 и persistent volumes в общий Compose/Swarm. Проверьте, что другая API-реплика скачивает файл после остановки загрузившей.
|
||||
5. Проверьте недоступность S3, чужой чат и restart всего стенда.
|
||||
|
||||
## Оценка «3»: работающий минимум
|
||||
|
||||
- Все обязательные возможности lab3 сохранены. S3-сервер находится в общем стенде; метаданные вложений живут в PostgreSQL, байты — в приватном bucket, не в БД или локальном каталоге API.
|
||||
- POST вложения возвращает 201 ready после сохранения; оригинал скачивается побайтно. Message с attachment_ids читается участниками чата. Публичный smoke проверяет checksum и отсутствие доступа у постороннего.
|
||||
- Принимаются реальные PNG/JPEG ≤10 MiB и ≤40 млн пикселей; MIME проверяется по содержимому, опасные filename отклоняются, лимиты применяются до неограниченного чтения/декодирования.
|
||||
- Upload → остановка обслужившей API-реплики → download через другую возвращает тот же файл. Restart с сохранением volumes сохраняет метаданные и оригинал.
|
||||
|
||||
## Оценка «4»: уровень стажёра/джуна
|
||||
|
||||
Весь уровень 3 **и все** пункты ниже:
|
||||
|
||||
- Тесты покрывают слишком большой файл, повреждённое изображение, подмену MIME, чужое attachment_id и привязку файла другого чата. Пользовательская ошибка не превращается в 500.
|
||||
- Прерывание загрузки и отказ между записью S3/БД не оставляют вечных неучтённых объектов: есть документированный механизм компенсации/сверки/очистки с безопасным grace period.
|
||||
- Лимиты/таймауты и потоковая обработка не позволяют одной загрузке занять неограниченную RAM. Проведён эксперимент ≥10 одновременных загрузок с измерением пика памяти.
|
||||
- Bucket bootstrap идемпотентен; API использует отдельные S3 credentials с ограниченными правами. Административная консоль не открыта в общей сети. Есть проверка private bucket вне API.
|
||||
|
||||
## Оценка «5»: исследование и доказанный результат
|
||||
|
||||
Весь уровень 4 **и один законченный трек на выбор**. Приведите гипотезу, повторяемую методику, результаты и ограничения.
|
||||
|
||||
**Трек 1. Прямой upload.** Добавьте отдельный безопасный presigned-upload flow: короткая подпись, уникальный ключ, проверка принадлежности, размера/checksum при complete, обработка незавершённой загрузки. Сохраните обязательный proxy endpoint. Сравните трафик и RAM API при ≥2 размерах файлов, покажите негативные сценарии.
|
||||
|
||||
**Трек 2. Согласованность двух хранилищ.** Разработайте повторяемый reconciler для orphan/missing объектов и метаданных. Внедрите ошибки на обеих сторонах, покажите обнаружение и безопасное исправление с dry-run/повторным запуском. Докажите, что свежие и используемые вложения не удаляются; измерьте стоимость обхода.
|
||||
|
||||
## Как начать и проверить
|
||||
|
||||
```sh
|
||||
# Работает прямо в skeleton, Python 3.9+; приложение не запускается:
|
||||
make check
|
||||
|
||||
# После реализации запустите свой сервер/стенд по REPORT.md.
|
||||
# По умолчанию тест использует http://localhost:8080.
|
||||
make test
|
||||
```
|
||||
|
||||
`.env.example`, `compose.example.yaml` и `contracts/infrastructure.json` описывают настройки и роли компонентов. **compose.example.yaml — список ролей с пустым services, не запускаемый стенд.** Реализуйте свой `compose.yaml`/`stack.yaml`, закрепите версии образов, заполните локальный `.env` и опишите bootstrap/миграции. Все зависимости поднимаются из этого репозитория; ссылка «у меня БД уже установлена» не заменяет воспроизводимость.
|
||||
|
||||
```sh
|
||||
# Compose-вариант после реализации:
|
||||
docker compose --env-file .env config --quiet
|
||||
docker compose --env-file .env up -d --build
|
||||
# Дождитесь /health/ready; затем make test.
|
||||
# Для Swarm укажите эквивалентные build/push/deploy-команды в REPORT.md.
|
||||
```
|
||||
|
||||
|
||||
Примеры запросов доступны в `examples/requests.http`, примеры JSON — в `contracts/examples.json`. Для полной проверки схемы (по желанию локально; CI делает её автоматически):
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install -r requirements-dev.txt
|
||||
make validate PYTHON=.venv/bin/python
|
||||
```
|
||||
|
||||
Smoke не выставляет оценку автоматически. Проверку сохранности/отказов запускайте явно по [tests/README.md](tests/README.md), остальные доказательства приведите в отчёте.
|
||||
|
||||
## Сценарий защиты
|
||||
|
||||
1. Загрузить fixture, отправить message с attachment_ids, скачать как второй участник и сравнить SHA-256.
|
||||
2. С третьим пользователем получить 404 для метаданных и bytes; direct unauthenticated S3 GET не отдаёт объект.
|
||||
3. Выполнить persistence и failover: общий тест начиная с lab4 сохраняет и повторно читает оригинал через API.
|
||||
4. Показать, что на API нет обязательного локального файла оригинала. Для 4/5 — отказы и эксперименты.
|
||||
|
||||
## Что должно быть в PR
|
||||
|
||||
Дополненный стенд, настройка bucket/политики, миграции Attachment, тесты файлов, описание согласованности БД/S3 и результаты.
|
||||
|
||||
Заполните `REPORT.md` и [шаблон PR](.github/pull_request_template.md), укажите целевую оценку/трек. Не меняйте обязательный контракт и публичные tests ради зелёного результата. Сохраняйте возможности предыдущих лабораторных в пределах [объявленных переходов](COURSE.md).
|
||||
|
||||
## Вопросы на защите
|
||||
|
||||
- Почему файл в Docker volume одной API-реплики не равен объектному хранилищу?
|
||||
- Когда можно честно вернуть 201?
|
||||
- Что произойдёт, если S3 PUT прошёл, а COMMIT в PostgreSQL — нет?
|
||||
- Почему случайный object key и приватный bucket не заменяют ACL приложения?
|
||||
|
||||
## Первичные материалы
|
||||
|
||||
- [S3 object uploads](https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html)
|
||||
- [Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html)
|
||||
- [MinIO](https://github.com/minio/minio)
|
||||
- [SeaweedFS](https://github.com/seaweedfs/seaweedfs)
|
||||
- [RustFS](https://docs.rustfs.com/)
|
||||
@@ -0,0 +1,37 @@
|
||||
# Отчёт — лабораторная 4
|
||||
|
||||
- Автор, группа: TODO
|
||||
- Целевая оценка; выбранный трек 5, если нужен: TODO
|
||||
- Commit/PR: TODO после публикации
|
||||
- Использованные источники, библиотеки и помощь ИИ: TODO
|
||||
|
||||
## Запуск из чистого clone
|
||||
|
||||
TODO: версии runtime/образов, prerequisites, команды создания локальных secrets/.env, build, миграций, запуска, ожидания готовности, тестов и остановки. Указать необходимые действия оператора. Не вставлять секреты. Отдельно указать безопасную остановку и команду намеренного удаления учебных данных.
|
||||
|
||||
## Архитектура и решения
|
||||
|
||||
TODO: схема процессов/хранилищ, источник истины, транзакционные границы, ограничения. Что перенесено из lab3, что изменено в этой работе. Соответствие переменных из .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: какие отказы выдерживаются и какие нет; что не реализовано; где учебное упрощение; какое улучшение подтверждено, а какое пока гипотеза.
|
||||
@@ -0,0 +1,12 @@
|
||||
# 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: 4
|
||||
x-required-roles:
|
||||
- api
|
||||
- postgres
|
||||
- load_balancer
|
||||
- s3
|
||||
# TODO: implement real images/builds, network wiring, volumes, healthchecks,
|
||||
# credentials and bootstrap. Details: contracts/infrastructure.json and README.md.
|
||||
services: {}
|
||||
@@ -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: <UUID существующего пользователя>`; отсутствие/неизвестный ID — 401. Это учебный выбор пользователя: любой клиент может выдать себя за другого.
|
||||
|
||||
**Lab2–7:** `POST /auth/sessions` принимает только `Authorization: Basic <base64(username:password)>`, без 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 <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, не бинарное содержимое.
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"CreateUser": {
|
||||
"username": "alice",
|
||||
"display_name": "Алиса",
|
||||
"password": "Example-only-not-a-real-secret-123"
|
||||
},
|
||||
"User": {
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"username": "alice",
|
||||
"display_name": "Алиса",
|
||||
"created_at": "2026-09-01T12:00:00Z"
|
||||
},
|
||||
"CreateChat": {
|
||||
"title": "Проект по ВНП",
|
||||
"member_ids": [
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"22222222-2222-4222-8222-222222222222"
|
||||
]
|
||||
},
|
||||
"Chat": {
|
||||
"title": "Проект по ВНП",
|
||||
"member_ids": [
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"22222222-2222-4222-8222-222222222222"
|
||||
],
|
||||
"id": "33333333-3333-4333-8333-333333333333",
|
||||
"created_at": "2026-09-01T12:00:00Z"
|
||||
},
|
||||
"CreateMessage": {
|
||||
"text": "Привет, Боб!"
|
||||
},
|
||||
"Message": {
|
||||
"id": "44444444-4444-4444-8444-444444444444",
|
||||
"chat_id": "33333333-3333-4333-8333-333333333333",
|
||||
"sender_id": "11111111-1111-4111-8111-111111111111",
|
||||
"text": "Привет, Боб!",
|
||||
"created_at": "2026-09-01T12:00:00Z",
|
||||
"attachment_ids": []
|
||||
},
|
||||
"MessagePage": {
|
||||
"items": [
|
||||
{
|
||||
"id": "44444444-4444-4444-8444-444444444444",
|
||||
"chat_id": "33333333-3333-4333-8333-333333333333",
|
||||
"sender_id": "11111111-1111-4111-8111-111111111111",
|
||||
"text": "Привет, Боб!",
|
||||
"created_at": "2026-09-01T12:00:00Z",
|
||||
"attachment_ids": []
|
||||
}
|
||||
],
|
||||
"next_cursor": null
|
||||
},
|
||||
"Error": {
|
||||
"error": {
|
||||
"code": "not_found",
|
||||
"message": "Объект не найден",
|
||||
"request_id": "example-request-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"lab": 4,
|
||||
"kind": "requirements, not runnable deployment",
|
||||
"required_for_grade_3": [
|
||||
{
|
||||
"role": "api",
|
||||
"count_min": 2,
|
||||
"student_implements": true,
|
||||
"persistent_local_state": false
|
||||
},
|
||||
{
|
||||
"role": "postgres",
|
||||
"purpose": "users/chats/membership/messages/metadata",
|
||||
"persistent_volume": true
|
||||
},
|
||||
{
|
||||
"role": "load_balancer",
|
||||
"public_entrypoint": true,
|
||||
"backend_readiness": true
|
||||
},
|
||||
{
|
||||
"role": "s3",
|
||||
"choose_one": [
|
||||
"MinIO",
|
||||
"SeaweedFS",
|
||||
"RustFS"
|
||||
],
|
||||
"private_bucket": true,
|
||||
"persistent_volume": true
|
||||
}
|
||||
],
|
||||
"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
@@ -0,0 +1,5 @@
|
||||
# Доказательства
|
||||
|
||||
Храните небольшие обезличенные результаты: команды и stdout проверок, JSON/CSV агрегатов нагрузки, EXPLAIN, dashboard JSON, текст разбора отказа. Укажите дату, commit, аппаратные лимиты и профиль нагрузки.
|
||||
|
||||
Не добавляйте .env, session tokens, cookies, приватные ключи, credentials, реальные сообщения, raw dumps и signed URLs. Для скриншотов скрывайте секреты. Большие логи/бинарные traces сохраняйте отдельно по правилам преподавателя; одного скриншота без методики недостаточно.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Optional HTTP-client worksheet; no extension is required by the course.
|
||||
# Replace IDs/token locally from real responses; do not commit filled-in credentials.
|
||||
# Canonical API: contracts/openapi.json and contracts/README.md.
|
||||
@baseUrl = http://localhost:8080
|
||||
@aliceId = REPLACE_WITH_CREATED_ALICE_UUID
|
||||
@bobId = REPLACE_WITH_CREATED_BOB_UUID
|
||||
@chatId = REPLACE_WITH_CREATED_CHAT_UUID
|
||||
@sessionToken = REPLACE_LOCALLY_DO_NOT_COMMIT
|
||||
|
||||
### Liveness
|
||||
GET {{baseUrl}}/health/live
|
||||
|
||||
### Register Alice (repeat with username bob/display_name Боб)
|
||||
POST {{baseUrl}}/api/v1/users
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "alice",
|
||||
"display_name": "Алиса",
|
||||
"password": "Example-only-not-a-real-secret-123"
|
||||
}
|
||||
|
||||
### Create session: generate the Base64 value locally, do not commit credentials
|
||||
# Base64 of UTF-8 username:password; only this endpoint accepts Basic.
|
||||
POST {{baseUrl}}/api/v1/auth/sessions
|
||||
Authorization: Basic REPLACE_WITH_LOCAL_BASE64_CREDENTIAL
|
||||
|
||||
|
||||
### Create chat; both UUIDs must be real registered users
|
||||
POST {{baseUrl}}/api/v1/chats
|
||||
Authorization: Bearer {{sessionToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{"title":"Проект по ВНП","member_ids":["{{aliceId}}","{{bobId}}"]}
|
||||
|
||||
### Send message
|
||||
POST {{baseUrl}}/api/v1/chats/{{chatId}}/messages
|
||||
Authorization: Bearer {{sessionToken}}
|
||||
Idempotency-Key: example-message-001
|
||||
Content-Type: application/json
|
||||
|
||||
{"text":"Привет, Боб!"}
|
||||
|
||||
### Read first page
|
||||
GET {{baseUrl}}/api/v1/chats/{{chatId}}/messages?limit=50
|
||||
Authorization: Bearer {{sessionToken}}
|
||||
|
||||
### Binary upload; HTTP file-client syntax for reading local fixture
|
||||
POST {{baseUrl}}/api/v1/chats/{{chatId}}/attachments?filename=sample.png
|
||||
Authorization: Bearer {{sessionToken}}
|
||||
Content-Type: image/png
|
||||
|
||||
< ../tests/fixtures/sample.png
|
||||
@@ -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-реплику.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"number": 4,
|
||||
"title": "S3-хранилище и вложения",
|
||||
"contract_version": "1.4.0",
|
||||
"default_base_url": "http://localhost:8080",
|
||||
"grading": "3; 4 includes 3; 5 includes 4 plus one completed research track",
|
||||
"scaffold_has_application": false
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Only for full OpenAPI/JSON Schema validation; HTTP tests use stdlib.
|
||||
openapi-spec-validator==0.7.2
|
||||
@@ -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()
|
||||
Executable
+10
@@ -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
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# TODO: copy to stop-one.sh, map TARGET_INSTANCE_ID to ONE API container/task.
|
||||
# Stop that instance and leave it stopped; no stop of DB/LB/volumes.
|
||||
# Return quickly (within 10 s); the test sends requests during this script.
|
||||
# Restore the replica explicitly after the test. Never print credentials.
|
||||
: "${TARGET_INSTANCE_ID:?test provides target API instance alias}"
|
||||
printf '%s
|
||||
' 'TODO: implement stop-one.sh for your infrastructure' >&2
|
||||
exit 2
|
||||
@@ -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.')
|
||||
@@ -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
@@ -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)
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# Синтетический fixture
|
||||
|
||||
sample.png: RGB PNG 320×200, создан программно специально для курса, без персональных данных и EXIF. Можно свободно использовать и менять в собственных тестах; исходный публичный fixture сохраните.
|
||||
|
||||
- Размер: 111817 байт.
|
||||
- SHA-256: `99ea7533c441f79cd630d15ce4d58d1ec4e06f6f0efb3b77a5c088305cd1bd75`.
|
||||
- Обязательный thumbnail с lab5: JPEG 256×160.
|
||||
|
||||
Проверяющий читает SHA-256 оригинала, metadata вариантов и реальные размеры JPEG, а не только JSON-обещание сервера. Повреждённые, oversized и decompression-bomb fixtures для собственного negative suite создавайте отдельно; не храните огромные файлы в Git.
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,26 @@
|
||||
"""Read JPEG SOF dimensions without external libraries; not a full image decoder."""
|
||||
from client import Failure, check
|
||||
|
||||
def jpeg_size(data):
|
||||
check(data[:2]==b'\xff\xd8','thumbnail is not JPEG')
|
||||
i = 2
|
||||
sof = {0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf}
|
||||
while i < len(data):
|
||||
check(data[i]==0xff,'invalid JPEG marker')
|
||||
while i<len(data) and data[i]==0xff:
|
||||
i += 1
|
||||
check(i<len(data),'truncated JPEG marker')
|
||||
marker = data[i]
|
||||
i += 1
|
||||
if marker in {0xd9,0xda}:
|
||||
break
|
||||
if marker in {0x01,0xd8} or 0xd0<=marker<=0xd7:
|
||||
continue
|
||||
check(i+2<=len(data),'truncated JPEG segment')
|
||||
length = int.from_bytes(data[i:i+2],'big')
|
||||
check(length>=2 and i+length<=len(data),'invalid JPEG segment length')
|
||||
if marker in sof:
|
||||
check(length>=8,'invalid JPEG SOF')
|
||||
return (int.from_bytes(data[i+5:i+7],'big'),int.from_bytes(data[i+3:i+5],'big'))
|
||||
i += length
|
||||
raise Failure('JPEG dimensions not found')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user