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

This commit is contained in:
Mikhail Verkovykh
2026-09-10 18:01:36 +03:00
commit 470d9d6f1e
38 changed files with 5013 additions and 0 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
+30
View File
@@ -0,0 +1,30 @@
# Copy to .env for your LOCAL synthetic test environment; never commit .env.
# These are logical interface suggestions; map actual framework names in REPORT.md.
APP_HOST=0.0.0.0
APP_PORT=8080
APP_ENV=lab
SESSION_TTL_SECONDS=3600
# Choose credentials locally. File paths contain no secret values.
DATABASE_URL_FILE=/run/secrets/database_url
# Optional on 3, required use on 4 in lab2; Redis OR Valkey:
SESSION_STORE=postgres
CACHE_URL_FILE=/run/secrets/cache_url
API_REPLICAS=2
IDEMPOTENCY_TTL_SECONDS=86400
S3_ENDPOINT=http://s3:9000
S3_REGION=us-east-1
S3_BUCKET=messenger-attachments
S3_ACCESS_KEY_ID_FILE=/run/secrets/s3_access_key_id
S3_SECRET_ACCESS_KEY_FILE=/run/secrets/s3_secret_access_key
MAX_UPLOAD_BYTES=10485760
MAX_IMAGE_PIXELS=40000000
BROKER_URL_FILE=/run/secrets/broker_url
WORKER_CONCURRENCY=2
JOB_MAX_ATTEMPTS=5
JOB_TIMEOUT_SECONDS=60
STAGING_RETENTION_SECONDS=86400
OTEL_SERVICE_NAME=messenger-api
# Worker must set a distinct service.name.
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=lab
+18
View File
@@ -0,0 +1,18 @@
## Лабораторная 6
Автор / группа: TODO
Целевая оценка (3/4/5): TODO
Трек 5, если заявлен: TODO
Что реализовано и что перенесено: TODO
Команды запуска из чистого clone: TODO
Результаты проверок и ссылка на REPORT.md: TODO
Ограничения / известные ошибки: TODO
Источники и использование ИИ: TODO
- [ ] Весь обязательный минимум текущей и предыдущих работ сохранён с объявленными переходами.
- [ ] Контракт и публичные тесты не ослаблены.
- [ ] make check и make test выполнены; приложен реальный вывод.
- [ ] Сценарии отказов/безопасности своего уровня воспроизведены.
- [ ] Все секреты и приватные ключи находятся вне Git; отчёт не содержит tokens.
- [ ] README задания сохранён, REPORT.md содержит инструкцию именно моего решения.
+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 @@
# Памятка преподавателю — лабораторная 6
## Что подготовлено
Язык реализации не задан. README содержит накопительные уровни 3/4/5, сценарий защиты и вопросы. В каждой лабе собственная полная версия OpenAPI, поэтому fork не зависит от соседних каталогов. Публичный HTTP smoke не содержит реализации сервера; Dockerfile.example и compose.example.yaml (если есть) — задания/ориентиры, не готовый deployment.
## Перед публикацией
1. Выберите GitHub/GitLab и организацию, опубликуйте каждый каталог как отдельный template/skeleton repository, сообщите студентам URL и правила именования PR/MR. Эти материалы не публикуют репозитории автоматически.
2. Задайте default branch, защиту исходных материалов и правила приёма. `.github/` уже содержит шаблон PR и CI контракта; в GitLab перенесите эквивалентные настройки, локальные Makefile-команды одинаковы.
3. Сообщите, индивидуальная работа или командная, сроки, правила использования стороннего кода/ИИ и ресурсы демонстрационного стенда. Эти организационные решения намеренно не выдуманы в задании.
4. Уточните ограничения площадки (например доступна ли виртуализация/несколько узлов). Для 5 предусмотрено по два трека: один можно выполнить без настоящего multi-node cloud. Универсальный проходной RPS не установлен.
## Что проверять
| Проверка | Что подтверждает | Чего не подтверждает |
| --- | --- | --- |
| make check | Структура файлов, JSON, локальные refs, синтаксис Python | Реализация/запуск API |
| make validate | Полная OpenAPI 3.1, JSON Schema и примеры | Поведение сервера |
| make test | Один сквозной контрактный сценарий текущей лабы | Все ошибки, persistence, HA, security |
| test-persistence (lab2+) | Данные/прежний токен после вызова restart hook | Реальный перезапуск, если hook — no-op |
| test-failover (lab3+) | Смена обслуживающей реплики, сохранность, retry без дубля | Несколько физических узлов, HA DB/LB |
| test-security (lab7) | Доверенный HTTPS и отказ недоверенного CA | Внутреннее mTLS/identity/ACL/OpenBao |
| Защита и тесты студента | Реальность инфраструктуры и критерии выбранного уровня | Неограниченная промышленная безопасность |
Проверяйте реальные контейнеры/логи процессов и данные до/после отказа: одного X-Instance-Id недостаточно. Для 4 требуются все пункты 3+4, для 5 — все пункты 3+4 и один **завершённый** трек 5. Сравнивайте подтверждённые результаты со сформулированными критериями, а не количество установленных сервисов.
## Приём PR
Проверка запускается на изолированном учебном окружении с синтетическими secrets, не на общей production БД. Студенческие Dockerfile/hooks/CI — выполняемый код: обычный fork workflow без секретов и без доступа к общему Docker socket. Не запускайте PR через pull_request_target с привилегиями преподавателя.
Исходные README/контракт/публичные tests сохраняются, реализация переносится студентом между лабами через собственные исходники и миграции. Исправление общей спецификации публикуйте отдельным изменением, объясняя влияние на все затронутые лабораторные.
## Проверка этой версии skeleton
На 2026-09-10 выполнены структурные проверки и полная валидация всех семи OpenAPI/JSON Schema, проверка локальных Markdown-ссылок/YAML и декодирование PNG fixture. HTTP-клиенты проверены на временном изолированном тестовом стенде: позитивный smoke 1–7, отрицательные варианты неправильного sender, pagination, ACL, logout, bytes и пустого API; также пути persistence/failover/load и внешнего TLS.
Временный стенд не включён в студенческие каталоги и не является эталонным решением. Реальное приложение, Compose, PostgreSQL/S3/broker и mTLS/OpenBao в skeleton отсутствуют: их работоспособность не заявляется. Docker CLI в среде подготовки не был доступен. Это проверка материалов и тестовых драйверов, не приёмка будущих решений студентов.
+30
View File
@@ -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)
+105
View File
@@ -0,0 +1,105 @@
# Лабораторная 6. Наблюдаемость: OpenTelemetry и Grafana
Научиться находить причину деградации через метрики, логи и трассы всей системы, включая фоновую обработку.
Это **skeleton**, а не готовый сервер. Здесь есть задание, контракт и публичные проверки. Реализацию приложения, Dockerfile и требуемую инфраструктуру пишет студент. Язык и framework свободные; UI не обязателен.
[Карта курса](COURSE.md) · [Процесс fork/PR](CONTRIBUTING.md) · [OpenAPI](contracts/openapi.json) · [Семантика API](contracts/README.md) · [Проверки](tests/README.md) · [Отчёт](REPORT.md) · [Примеры JSON](contracts/examples.json) · [Памятка преподавателю](MAINTAINERS.md)
## Откуда начинаем
Перенесите минимум lab5. Бизнес-API не меняется. Должны оставаться API-реплики, БД, S3, broker и worker.
**Архитектура:** API/worker → OTLP → OTel Collector → хранилища metrics/logs/traces → Grafana; queue context связывает API и worker.
## Что сделать
1. Выберите хранилища: например Prometheus для метрик, Loki для логов, Tempo для трасс. Разрешена другая совместимая комбинация с Grafana; Grafana сама не хранит все три сигнала.
2. Добавьте SDK-инструментацию API и worker и отдельный Collector в общий Compose/Swarm; настройте receivers/processors/exporters и хранение данных.
3. Реализуйте набор сигналов contracts/observability.md и импортируемые dashboard/datasources. Не используйте user_id/chat_id/message_id как labels метрик.
4. Проверьте связь запроса, структурного лога и обработки задания. Секреты, тела сообщений и изображения не должны попадать в telemetry.
5. Создайте нагрузку, внедрите два ограниченных отказа и найдите их причину только с помощью сохранённых наблюдений; приложите доказательства.
## Оценка «3»: работающий минимум
- Весь минимум lab5 работает. Collector, хранилища трёх сигналов и Grafana включены в общий воспроизводимый стенд; конфиги и provisioning лежат в Git, persistent volumes сохраняют нужную историю.
- API и worker отдают OTLP в Collector. В Grafana видны: RPS/errors/duration API, успешные/ошибочные jobs и duration worker; доступны структурные логи и реальные трассы API и worker.
- Есть импортируемый dashboard, а не только screenshot. Сохраняются низкокардинальные атрибуты и идентификаторы trace/span в логах; содержимое сообщений, пароли/токены не экспортируются.
- На контролируемом запросе можно найти trace и связанный лог; на job — worker trace и лог. make test проходит при включённой телеметрии. Объяснено назначение каждого хранилища.
## Оценка «4»: уровень стажёра/джуна
Весь уровень 3 **и все** пункты ниже:
- W3C trace context переносится API → broker → worker; видны producer/consumer spans с корректной связью parent или link. По одному upload найдена цепочка до S3/DB результата, включая retry.
- Dashboard показывает p50/p95/p99, долю ошибок, queue depth/возраст старейшей job, DB pool и saturation worker. Приведены запросы и единицы измерения; percentile вычисляется из histogram, не средних.
- Задан проверяемый SLI/SLO и окно; сохранены alert rules минимум для высокого error rate и растущего queue lag. Alerts проверены искусственным отказом; доставка наружу не нужна, достаточно локального состояния firing/resolved.
- Collector имеет memory limiter/batch и ограниченную буферизацию/поведение при недоступном backend. Остановка telemetry-хранилища не ломает сообщения и не вызывает бесконечный рост RAM. Отчёт содержит разбор двух инцидентов и шаги диагностики.
## Оценка «5»: исследование и доказанный результат
Весь уровень 4 **и один законченный трек на выбор**. Приведите гипотезу, повторяемую методику, результаты и ограничения.
**Трек 1. Цена наблюдаемости.** Сравните telemetry off/on и ≥2 sampling/aggregation настройки при одинаковой нагрузке: latency, CPU/RAM, объём ingest, потери spans. Объясните, какие ошибки и редкие медленные запросы теряются; предложите и проверьте сбалансированную конфигурацию.
**Трек 2. SLO и обнаружение инцидентов.** Обоснуйте SLO для API и времени до ready, реализуйте burn-rate alerts с несколькими окнами, воспроизведите краткий всплеск и длительную деградацию. Измерьте время обнаружения/восстановления, ложные срабатывания и покажите runbook, по которому другой студент находит причину.
## Как начать и проверить
```sh
# Работает прямо в skeleton, Python 3.9+; приложение не запускается:
make check
# После реализации запустите свой сервер/стенд по REPORT.md.
# По умолчанию тест использует http://localhost:8080.
make test
```
`.env.example`, `compose.example.yaml` и `contracts/infrastructure.json` описывают настройки и роли компонентов. **compose.example.yaml — список ролей с пустым services, не запускаемый стенд.** Реализуйте свой `compose.yaml`/`stack.yaml`, закрепите версии образов, заполните локальный `.env` и опишите bootstrap/миграции. Все зависимости поднимаются из этого репозитория; ссылка «у меня БД уже установлена» не заменяет воспроизводимость.
```sh
# Compose-вариант после реализации:
docker compose --env-file .env config --quiet
docker compose --env-file .env up -d --build
# Дождитесь /health/ready; затем make test.
# Для Swarm укажите эквивалентные build/push/deploy-команды в REPORT.md.
```
Примеры запросов доступны в `examples/requests.http`, примеры JSON — в `contracts/examples.json`. Для полной проверки схемы (по желанию локально; CI делает её автоматически):
```sh
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements-dev.txt
make validate PYTHON=.venv/bin/python
```
Smoke не выставляет оценку автоматически. Проверку сохранности/отказов запускайте явно по [tests/README.md](tests/README.md), остальные доказательства приведите в отчёте.
## Сценарий защиты
1. С нуля поднять стенд, импортировать provisioning без ручного набора dashboard, запустить smoke и нагрузку.
2. Показать свежие метрики, одну трассу и соответствующий структурный лог API и worker; проверить, что это данные своего приложения.
3. Для 4: пройти от upload до worker в одном trace/связанных traces, остановить worker или ограничить DB, увидеть firing и затем resolved.
4. Остановить telemetry backend, проверить сохранение работы API и ограниченное поведение exporter. Для 5: повторить эксперимент.
## Что должно быть в PR
Collector configuration, конфиги backend, Grafana provisioning/dashboards, alert rules по уровню, схема сигналов и отчёт инцидентов.
Заполните `REPORT.md` и [шаблон PR](.github/pull_request_template.md), укажите целевую оценку/трек. Не меняйте обязательный контракт и публичные tests ради зелёного результата. Сохраняйте возможности предыдущих лабораторных в пределах [объявленных переходов](COURSE.md).
## Вопросы на защите
- Чем лог, метрика и trace отвечают на разные вопросы об одном инциденте?
- Почему chat_id в labels может исчерпать хранилище?
- Как очередь переносит причинную связь между процессами?
- Почему среднее latency не заменяет p99 и почему нельзя усреднять p99 реплик?
## Первичные материалы
- [OTel Collector configuration](https://opentelemetry.io/docs/collector/configuration/)
- [OTel propagation](https://opentelemetry.io/docs/concepts/context-propagation/)
- [W3C Trace Context](https://www.w3.org/TR/trace-context/)
- [Prometheus histograms](https://prometheus.io/docs/practices/histograms/)
- [Grafana provisioning](https://grafana.com/docs/grafana/latest/administration/provisioning/)
+37
View File
@@ -0,0 +1,37 @@
# Отчёт — лабораторная 6
- Автор, группа: TODO
- Целевая оценка; выбранный трек 5, если нужен: TODO
- Commit/PR: TODO после публикации
- Использованные источники, библиотеки и помощь ИИ: TODO
## Запуск из чистого clone
TODO: версии runtime/образов, prerequisites, команды создания локальных secrets/.env, build, миграций, запуска, ожидания готовности, тестов и остановки. Указать необходимые действия оператора. Не вставлять секреты. Отдельно указать безопасную остановку и команду намеренного удаления учебных данных.
## Архитектура и решения
TODO: схема процессов/хранилищ, источник истины, транзакционные границы, ограничения. Что перенесено из lab5, что изменено в этой работе. Соответствие переменных из .env.example реальной конфигурации.
## Доказательства критериев
| Критерий README | Команда/сценарий | Наблюдаемый результат | Файл доказательства |
| --- | --- | --- | --- |
| Публичный smoke | make test | TODO | TODO |
| Остальные пункты уровня 3 | TODO | TODO | TODO |
| Все пункты уровня 4, если заявлен | TODO | TODO | TODO |
| Выбранный трек 5, если заявлен | TODO | TODO | TODO |
TODO: добавить отдельную строку для каждого заявленного критерия. PASS без команды и наблюдения не является доказательством. Проверка инфраструктурного hook требует подтверждения, какие реальные процессы/хранилища перезапущены.
## Эксперименты и отказы
TODO: гипотеза, hardware/лимиты, версии, объём данных, профиль нагрузки, concurrency/arrival rate, длительность/прогрев, повторы, успешные RPS, ошибки, p50/p95/p99, CPU/RAM. Сохранить raw summary без tokens и объяснить ограничения измерения.
| Воздействие | Что ожидаем | Что наблюдали | Время восстановления / потеря данных |
| --- | --- | --- | --- |
| TODO | TODO | TODO | TODO |
## Ограничения и следующие шаги
TODO: какие отказы выдерживаются и какие нет; что не реализовано; где учебное упрощение; какое улучшение подтверждено, а какое пока гипотеза.
+17
View File
@@ -0,0 +1,17 @@
# Topology worksheet, deliberately NOT a runnable stack (no application supplied).
# Implement compose.yaml or stack.yaml; do not submit this empty file as deployment.
# Service names below are logical roles, not imposed container names.
x-lab-number: 6
x-required-roles:
- api
- postgres
- load_balancer
- s3
- broker
- worker
- otel_collector
- telemetry_storage
- grafana
# TODO: implement real images/builds, network wiring, volumes, healthchecks,
# credentials and bootstrap. Details: contracts/infrastructure.json and README.md.
services: {}
+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, не бинарное содержимое.
+21
View File
@@ -0,0 +1,21 @@
# Состояния вложения и договор между producer/worker
Сообщение broker соответствует [jobs.schema.json](jobs.schema.json). Можно применять native headers брокера для trace context, но семантика carrier сохраняется; для проверки покажите отображение полей. `job_id` идентифицирует одну логическую работу и не меняется при redelivery; `attempt` начинается с 1 и отражает управляемую попытку обработки (broker redelivery сам по себе не создаёт новый job_id). Ключ эффекта — `(attachment_id, pipeline_version, variant)`.
| Переход | Условие | Что устойчиво сохранено |
| --- | --- | --- |
| нет → queued | Принимаем upload; только после durable handoff отвечаем 202 | Полный проверенный оригинал в staging, метаданные, намерение обработки |
| queued → processing | Worker захватил/арендовал работу | Попытка, lease/deadline или эквивалентная защита |
| processing → queued | Временная ошибка и остались попытки | Причина без секретов, время следующей попытки |
| processing → ready | Все обязательные объекты записаны и результат атомарно опубликован | Оригинал, thumbnail (на 4 ещё crop/optimized), метаданные/checksums |
| processing → failed | Ошибка постоянная или попытки исчерпаны | error_code и запись, доступная оператору |
| processing → queued | Истёк lease после смерти worker | Восстановленная работа, без дублирования эффектов |
| failed → queued | Явный операторский replay, доступен на 4 | Audit, новая попытка, прежний логический эффект защищён от дубля |
Повторная доставка уже ready-задачи — no-op с ack после проверки завершённого эффекта. Конкурентные попытки не перезаписывают более новый pipeline_version. Основной курс использует pipeline_version=1; версионирование нескольких pipeline — трек 5. Заявляйте готовность только когда все обещанные уровнем варианты доступны. Нельзя откатывать ready в processing из-за старого дубля.
**Окна отказа для защиты:** после staging до записи задания; после COMMIT до publish; после publish до confirm; после записи S3 до COMMIT результата; после COMMIT результата до ack. На 3 покажите сохранность успешно принятой работы и повторную доставку; на 4 автоматическое восстановление разрыва DB/broker (outbox/эквивалент) и зависшего processing.
Staging не удаляется до безопасной публикации результатов. Неуспешные/непривязанные исходники имеют документированный retention и cleanup; он не удаляет текущую работу. Broker принимает только ID/служебные данные, не произвольный URL для скачивания и не путь из клиентского filename. Worker берёт доверенные storage metadata из БД.
Ack подтверждает устойчивую обработку, publisher confirm — приём брокером; это разные границы. См. [RabbitMQ reliability](https://www.rabbitmq.com/docs/reliability). At-least-once + идемпотентный эффект не означает exactly-once доставку.
+60
View File
@@ -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"
}
}
}
+88
View File
@@ -0,0 +1,88 @@
{
"lab": 6,
"kind": "requirements, not runnable deployment",
"required_for_grade_3": [
{
"role": "api",
"count_min": 2,
"student_implements": true,
"persistent_local_state": false
},
{
"role": "postgres",
"purpose": "users/chats/membership/messages/metadata",
"persistent_volume": true
},
{
"role": "load_balancer",
"public_entrypoint": true,
"backend_readiness": true
},
{
"role": "s3",
"choose_one": [
"MinIO",
"SeaweedFS",
"RustFS"
],
"private_bucket": true,
"persistent_volume": true
},
{
"role": "broker",
"choose_one": [
"RabbitMQ",
"Redis Streams",
"Valkey Streams",
"NATS JetStream",
"documented durable equivalent"
],
"persistent_volume": true,
"delivery": "at-least-once"
},
{
"role": "worker",
"separate_process": true
},
{
"role": "otel_collector",
"signals": [
"metrics",
"logs",
"traces"
]
},
{
"role": "telemetry_storage",
"signals": [
"metrics",
"logs",
"traces"
],
"possible_combination": [
"Prometheus",
"Loki",
"Tempo"
],
"persistent_volume": true
},
{
"role": "grafana",
"provisioning_in_git": true
}
],
"optional_components": [
{
"role": "redis_or_valkey",
"required_for_lab2_grade4": true,
"note": "Choose one; if only store of sessions, configure durable persistence."
}
],
"rules": [
"Choose Compose or Swarm for lab3+; do not need both.",
"Never publish internal API replica ports in bypass of LB from lab3.",
"Versions/digests must be fixed in implementation.",
"Do not remove volumes in restart/failover checks.",
"Document service names, healthchecks, bootstrap/migrations and credentials."
]
}
+63
View File
@@ -0,0 +1,63 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:mtusi:messenger:attachment-job:v1",
"title": "Durable attachment processing job v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"job_id",
"type",
"attachment_id",
"pipeline_version",
"attempt",
"created_at"
],
"properties": {
"schema_version": {
"const": 1
},
"job_id": {
"type": "string",
"format": "uuid"
},
"type": {
"const": "attachment.process.v1"
},
"attachment_id": {
"type": "string",
"format": "uuid"
},
"pipeline_version": {
"type": "integer",
"minimum": 1
},
"attempt": {
"type": "integer",
"minimum": 1
},
"created_at": {
"type": "string",
"format": "date-time"
},
"traceparent": {
"type": "string",
"pattern": "^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$"
},
"tracestate": {
"type": "string",
"maxLength": 512
}
},
"examples": [
{
"schema_version": 1,
"job_id": "11111111-1111-4111-8111-111111111111",
"type": "attachment.process.v1",
"attachment_id": "22222222-2222-4222-8222-222222222222",
"pipeline_version": 1,
"attempt": 1,
"created_at": "2026-09-01T12:00:00Z"
}
]
}
+28
View File
@@ -0,0 +1,28 @@
# Контракт наблюдаемости
Ниже логические сигналы. Допустимы стандартные semantic conventions выбранного SDK и их native metric names: зафиксируйте соответствие в `REPORT.md`, dashboard обязан ссылаться на реально существующие имена/единицы. Переименование SDK не повод дублировать одну метрику двумя инструментами. Версии SDK, semantic conventions, Collector distribution и backend закрепляются в решении.
| Сигнал | Минимальные поля/измерения | Уровень |
| --- | --- | --- |
| HTTP requests | method, route template, status class; counter | 3 |
| HTTP duration | histogram в секундах или документированное преобразование из ms | 3 |
| Jobs processed / failed | job type, outcome, counter | 3 |
| Job processing duration | job type, outcome, histogram | 3 |
| API и worker logs | timestamp, severity, service.name, event, trace_id, span_id при активном span | 3 |
| API и worker traces | реальные server/consumer spans, duration, outcome; не искусственный статический trace | 3 |
| Queue depth / oldest job age | queue name, количество / секунды | 4 |
| Pool/saturation | DB pool used/waiting, worker busy/capacity, process/container CPU/RAM | 4 |
| Сквозная связь upload → publish → consume → S3/DB | W3C traceparent/tracestate; parent-child или link по выбранной семантике | 4 |
| End-to-end time to ready | от durable acceptance до публикации ready, histogram | 4 |
Resource attributes: `service.name` (messenger-api / messenger-worker), `service.version`, `service.instance.id`, `deployment.environment.name=lab`. Список имён может отличаться, если явно сопоставлен. `X-Request-Id` коррелирует HTTP-ошибку с логом; trace_id — не замена всех request IDs.
**Не labels метрик:** user_id, chat_id, message_id, attachment_id, request_id, raw URL, filename, текст. Для маршрута используйте `/api/v1/chats/{chat_id}/messages`, не реальный UUID. В логах/трассах допустимы ограниченные технические ID для учебных синтетических данных, если обоснована необходимость; пароли, Authorization, session token, body сообщения, картинка, private key и signed URL запрещены.
Для HTTP error ratio заранее определите numerator/denominator: например 5xx/все бизнес-запросы, исключая health; не смешивайте неверный пароль клиента с отказом БД. p95 агрегируется из histogram buckets всех нужных реплик, а не средних p95. Границы buckets должны соответствовать ожидаемым задержкам. Backlog — не queue processing latency.
В Git находятся Collector config, backend configs, datasources/dashboard provisioning, dashboard JSON и на 4 alert rules. У alerts есть условие, окно, порог, `for` при необходимости, описание и локальный runbook. Отправка сообщений в почту/мессенджер не требуется.
Минимальные контролируемые инциденты для 4: (1) остановить worker и увидеть рост возраста задач; (2) сделать DB медленной/недоступной и отличить её от медленного HTTP handler по trace/метрикам. После восстановления backlog уменьшается, alerts resolved. Третий сценарий — недоступность backend telemetry: SDK/Collector ограничивают RAM/очередь, API сохраняет работу, потери telemetry считаются и объясняются.
Collector — получатель/обработчик/экспортёр, не долговременная БД. Grafana — интерфейс и запросы к источникам. Логи можно отправлять OTLP в совместимый backend; не закладывайте удалённый/устаревший exporter без проверки текущей сборки. См. [Collector configuration](https://opentelemetry.io/docs/collector/configuration/), [Trace Context](https://www.w3.org/TR/trace-context/) и [histograms](https://prometheus.io/docs/practices/histograms/).
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 сохраняйте отдельно по правилам преподавателя; одного скриншота без методики недостаточно.
+53
View File
@@ -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
+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-реплику.
+7
View File
@@ -0,0 +1,7 @@
# Конфигурация наблюдаемости — реализовать
Добавьте `collector.yaml` с OTLP receivers, processors и exporters выбранной версии Collector. Укажите явные bind endpoints, внутренние сети, memory limits/batch и поведение при backpressure по уровню. Сопоставьте все три pipeline с реально развёрнутыми backend.
Добавьте конфигурации storage, Grafana provisioning/datasources, provisioning/dashboards и импортируемые dashboard JSON. На 4 — alert rules и runbooks. Пустой dashboard или debug exporter, который только печатает данные в stdout, не заменяют хранилища/доказательства из `contracts/observability.md`.
Не публикуйте OTLP/metrics/административные ports в общую сеть без необходимости. В lab7 реализуйте TLS/mTLS по trust matrix; экспорт Authorization и message bodies запрещён.
+8
View File
@@ -0,0 +1,8 @@
{
"number": 6,
"title": "Наблюдаемость: OpenTelemetry и Grafana",
"contract_version": "1.6.0",
"default_base_url": "http://localhost:8080",
"grading": "3; 4 includes 3; 5 includes 4 plus one completed research track",
"scaffold_has_application": false
}
+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
+10
View File
@@ -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
+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)
+9
View File
@@ -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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+26
View File
@@ -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')
+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)