Add HW 2
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
target
|
||||
**/target
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
*.pyc
|
||||
@@ -0,0 +1,125 @@
|
||||
x-tests-image: &tests-image
|
||||
image: grpc-solution-tests
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tests/Dockerfile
|
||||
|
||||
services:
|
||||
server:
|
||||
image: grpc-solution-server
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: server.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_PORT: 51075
|
||||
ports:
|
||||
- "${MESSENGER_SERVER_HOST_PORT:-51075}:51075"
|
||||
# Start the clients first to exercise their reconnect logic during manual runs.
|
||||
depends_on:
|
||||
- client1
|
||||
- client2
|
||||
|
||||
server-tests:
|
||||
image: grpc-solution-server
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: server.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_PORT: 51075
|
||||
|
||||
client1:
|
||||
image: grpc-solution-client
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: client.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_ADDR: server:51075
|
||||
MESSENGER_HTTP_PORT: 8080
|
||||
ports:
|
||||
- "${MESSENGER_CLIENT1_HOST_PORT:-8080}:8080"
|
||||
|
||||
client2:
|
||||
image: grpc-solution-client
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: client.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_ADDR: server:51075
|
||||
MESSENGER_HTTP_PORT: 8080
|
||||
ports:
|
||||
- "${MESSENGER_CLIENT2_HOST_PORT:-8081}:8080"
|
||||
|
||||
client-test-server:
|
||||
image: "${MESSENGER_CLIENT_TEST_SERVER_IMAGE:-distsys.ru/course/grpc-messenger:latest}"
|
||||
entrypoint:
|
||||
- /usr/local/bin/client-test-server
|
||||
environment:
|
||||
MESSENGER_CLIENT_TEST_SERVER_EXPECTED_STREAMS: 2
|
||||
MESSENGER_CLIENT_TEST_SERVER_PROTO: /submission/proto/messenger.proto
|
||||
MESSENGER_SERVER_PORT: 51075
|
||||
volumes:
|
||||
- type: bind
|
||||
source: "${MESSENGER_CLIENT_TEST_SERVER_PROTO_DIR:-./solution/proto}"
|
||||
target: /submission/proto
|
||||
read_only: true
|
||||
user: "65532:65532"
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
client-test1:
|
||||
image: grpc-solution-client
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: client.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_ADDR: client-test-server:51075
|
||||
MESSENGER_HTTP_PORT: 8080
|
||||
depends_on:
|
||||
- client-test-server
|
||||
|
||||
client-test2:
|
||||
image: grpc-solution-client
|
||||
build:
|
||||
context: ./solution
|
||||
dockerfile: client.dockerfile
|
||||
environment:
|
||||
MESSENGER_SERVER_ADDR: client-test-server:51075
|
||||
MESSENGER_HTTP_PORT: 8080
|
||||
depends_on:
|
||||
- client-test-server
|
||||
|
||||
tests:
|
||||
<<: *tests-image
|
||||
environment:
|
||||
MESSENGER_TEST_CLIENT1_ADDR: client-test1:8080
|
||||
MESSENGER_TEST_CLIENT2_ADDR: client-test2:8080
|
||||
MESSENGER_TEST_SERVER_ADDR: server-tests:51075
|
||||
depends_on:
|
||||
- server-tests
|
||||
- client-test1
|
||||
- client-test2
|
||||
|
||||
server-test-runner:
|
||||
<<: *tests-image
|
||||
command:
|
||||
- --component
|
||||
- server
|
||||
environment:
|
||||
MESSENGER_TEST_SERVER_ADDR: server-tests:51075
|
||||
depends_on:
|
||||
- server-tests
|
||||
|
||||
client-tests:
|
||||
<<: *tests-image
|
||||
command:
|
||||
- --component
|
||||
- client
|
||||
environment:
|
||||
MESSENGER_TEST_CLIENT1_ADDR: client-test1:8080
|
||||
MESSENGER_TEST_CLIENT2_ADDR: client-test2:8080
|
||||
depends_on:
|
||||
- client-test1
|
||||
- client-test2
|
||||
@@ -0,0 +1,296 @@
|
||||
# Мессенджер на gRPC
|
||||
|
||||
Опишите gRPC-интерфейс и реализуйте **сервер** и **клиент** мессенджера с одним общим чатом. Сервер и клиент общаются по gRPC, пользователь обращается к клиенту по HTTP.
|
||||
|
||||
У сервера два RPC-метода: `SendMessage` отправляет сообщение в чат, `ReadMessages` открывает подписку на новые сообщения. Сервер должен обрабатывать несколько запросов одновременно, в том числе принимать сообщения при открытых подписках.
|
||||
|
||||
Клиент при запуске открывает подписку и сохраняет сообщения от сервера в буфере в порядке получения. Через HTTP пользователь отправляет сообщения и забирает содержимое буфера.
|
||||
|
||||
На схеме показаны сервер и два клиента. Тесты обращаются к клиентам от имени двух пользователей:
|
||||
|
||||
```mermaid
|
||||
flowchart RL
|
||||
subgraph Tests
|
||||
U1{User 1}
|
||||
U2{User 2}
|
||||
end
|
||||
|
||||
subgraph Clients with HTTP interface
|
||||
C1(fa:fa-comments Client 1)
|
||||
C2(fa:fa-comments Client 2)
|
||||
end
|
||||
|
||||
subgraph gRPC server
|
||||
S(fa:fa-server Server)
|
||||
end
|
||||
|
||||
|
||||
C1 -- SendMessage --> S
|
||||
S -. Stream ReadMessages .-> C1
|
||||
|
||||
U1 -- POST /sendMessage --> C1
|
||||
C1 -- Forward messages in /getAndFlushMessages --> U1
|
||||
|
||||
C2 -- SendMessage --> S
|
||||
S -. Stream ReadMessages .-> C2
|
||||
|
||||
U2 -- POST /sendMessage --> C2
|
||||
C2 -- Forward messages in /getAndFlushMessages --> U2
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
||||
### Доставка сообщений
|
||||
|
||||
- Подписка начинается, когда сервер регистрирует вызов `ReadMessages`, и действует до отмены RPC или закрытия соединения. Сообщения, принятые до регистрации, в подписку не попадают.
|
||||
- Сервер передаёт каждое сообщение ровно один раз во все подписки, активные в момент его принятия, включая подписку отправителя. Восстанавливать историю после разрывов и перезапусков не нужно.
|
||||
- Сообщения, общие для нескольких подписчиков, должны идти в одинаковом порядке во всех их потоках. Клиенты сохраняют этот порядок. Для одновременных вызовов `SendMessage` сервер может выбрать любой порядок.
|
||||
- `sendTime` — серверное время принятия сообщения. В течение одного запуска эти значения должны быть уникальны и строго возрастать в порядке рассылки. Ответ `SendMessage` и сообщение во всех подписках содержат одинаковый `sendTime`.
|
||||
- Успешный ответ `SendMessage` означает, что сервер принял сообщение. Это не гарантирует, что все клиенты уже его получили.
|
||||
|
||||
### HTTP-интерфейс клиента
|
||||
|
||||
Пользователи и тесты обращаются к клиенту через два HTTP-метода.
|
||||
|
||||
При успехе оба метода возвращают HTTP `200` и JSON. Поле `sendTime` — строка в [JSON-формате `google.protobuf.Timestamp`](https://protobuf.dev/reference/protobuf/google.protobuf/#timestamp), например `2025-09-20T10:58:42.665193557Z`.
|
||||
|
||||
```
|
||||
POST /sendMessage
|
||||
Отправляет одно сообщение в общий чат.
|
||||
|
||||
Тело запроса:
|
||||
{
|
||||
"author": "Ivan Ivanov",
|
||||
"text": "Hey guys"
|
||||
}
|
||||
|
||||
Тело ответа:
|
||||
{
|
||||
"sendTime": "..."
|
||||
}
|
||||
```
|
||||
```
|
||||
POST /getAndFlushMessages
|
||||
Возвращает накопленные сообщения в порядке получения и очищает буфер.
|
||||
|
||||
Тело запроса: нет
|
||||
|
||||
Тело ответа:
|
||||
[{
|
||||
"author": "Ivan Ivanov",
|
||||
"text": "Hey guys",
|
||||
"sendTime": "..."
|
||||
},{
|
||||
"author": "Petr Petrov",
|
||||
"text": "Hey Ivan",
|
||||
"sendTime": "..."
|
||||
}]
|
||||
```
|
||||
|
||||
Если буфер пуст, `getAndFlushMessages` сразу возвращает `[]`. Чтение и очистка буфера должны быть атомарными: сообщение, пришедшее во время этой операции, попадает в текущий или следующий ответ. Клиент не должен терять сообщения, выдавать их повторно или менять их порядок.
|
||||
|
||||
### gRPC-интерфейс сервера
|
||||
|
||||
Тесты проверяют сервер отдельно от клиента. Соблюдайте требования к интерфейсу:
|
||||
|
||||
- синтаксис — `proto3`, пакет — `mes_grpc`;
|
||||
- gRPC-сервис `MessengerServer` содержит два метода: `SendMessage` и `ReadMessages`;
|
||||
- `SendMessage` — унарный вызов. Запрос содержит одиночные строковые поля `author` и `text`, ответ — одиночное поле `sendTime` типа `google.protobuf.Timestamp`;
|
||||
- `ReadMessages` принимает один пустой запрос и возвращает поток сообщений. Можно описать свой тип пустого сообщения или взять готовый из библиотеки. Каждое сообщение в потоке содержит одиночные поля `author` и `text` типа `string` и `sendTime` типа `google.protobuf.Timestamp`.
|
||||
|
||||
Все перечисленные поля одного сообщения должны допускать одновременное заполнение.
|
||||
|
||||
Имена типов сообщений и номера полей выберите самостоятельно — тесты их не фиксируют.
|
||||
|
||||
## Оценивание
|
||||
|
||||
За задание можно получить 10 баллов:
|
||||
|
||||
- **2 балла** — протокол `messenger.proto`, проверяется в `test_proto.py`.
|
||||
- **4 балла** — сервер, проверяется в `test_server.py`.
|
||||
- **4 балла** — клиент, проверяется в `test_client.py`.
|
||||
|
||||
В отчёте `solution/readme.md` опишите структуру решения, какие компоненты вы реализовали и как работают методы сервера и клиента. Без отчёта тесты запускаются, но защита не проводится и решение не засчитывается — см. [общие правила сдачи](../readme.md#сдача-решения).
|
||||
|
||||
Баллы за компонент начисляются, только если прошли все его тесты: 2 или 0 за протокол, 4 или 0 за сервер, 4 или 0 за клиент. Значение `SCORE` в выводе тестов — предварительная оценка. Итоговую оценку преподаватель выставляет после защиты с учётом штрафов ниже.
|
||||
|
||||
Система собирает и проверяет сервер и клиент независимо. Если сервер не собирается или не запускается, он получает 0 баллов, но клиент всё равно проверяется, и наоборот. Протокол проверяется отдельно.
|
||||
|
||||
При независимом оценивании клиенты работают со служебным gRPC-сервером, построенным по вашему `messenger.proto`. Поэтому для проверки клиента нужен корректный протокол, но ошибки вашего сервера не влияют на баллы за клиент.
|
||||
|
||||
На защите можно потерять баллы за следующие ошибки:
|
||||
|
||||
- Сервер не может обрабатывать несколько запросов одновременно — 2 балла.
|
||||
- При конкурентном доступе сервер может потерять или продублировать сообщения, выдать их в разном порядке в потоках `ReadMessages` либо нарушить требования к `sendTime` — 2 балла.
|
||||
- Клиент теряет, повторно выдаёт или меняет порядок сообщений из потока `ReadMessages` — 2 балла.
|
||||
|
||||
За ошибки сервера снимаются только баллы за сервер, за ошибки клиента — только баллы за клиент, не больше 4 баллов в каждом случае. Баллы за протокол сохраняются. На защите нужно разобрать предложенный преподавателем сценарий конкурентного выполнения и объяснить по своему коду, почему решение работает правильно.
|
||||
|
||||
## Заготовки для клиента
|
||||
|
||||
В `templates` есть заготовки клиента на Python, Python с asyncio и Go. Официальная заготовка — `messenger-py/client`; она проверена для текущего задания. Остальные заготовки относятся к прошлым версиям задания: их можно использовать, но расхождения с условием нужно исправить самостоятельно. Можно выбрать и другой язык — тесты обращаются к решению через HTTP и gRPC.
|
||||
|
||||
## Порядок выполнения задания
|
||||
|
||||
Выполняйте команды из папки `homework/02-grpc-messenger`. Примеры с переменными окружения написаны для Bash. В Windows используйте WSL или задавайте переменные через PowerShell.
|
||||
|
||||
### Подготовка окружения
|
||||
|
||||
Установите Python 3.12 или новее и Docker по [общей инструкции](../readme.md#настройка-окружения). Для Python-заготовки и локального запуска тестов установите зависимости:
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r templates/messenger-py/client/requirements.txt -r tests/requirements.txt
|
||||
grpcurl -version
|
||||
```
|
||||
|
||||
Если `grpcurl` не найден, установите его по инструкции в разделе «Полезные материалы». На Windows используйте `python` вместо `python3`.
|
||||
|
||||
### Структура проекта
|
||||
|
||||
Разместите решение в папке `solution`. Сохраните пути к трём файлам, которые используют тесты и [docker-compose.yml](docker-compose.yml):
|
||||
|
||||
- `client.dockerfile` — сборка и запуск клиента;
|
||||
- `server.dockerfile` — сборка и запуск сервера;
|
||||
- `proto/messenger.proto` — описание gRPC-интерфейса; дополните начальный файл.
|
||||
|
||||
При сдаче отправляется только папка `solution`. Изменения за её пределами не учитываются.
|
||||
|
||||
Для официальной Python-заготовки скопируйте `templates/messenger-py/client` в `solution/client`, а образец `client.dockerfile` — в `solution/client.dockerfile`. Сервер разместите в `solution/server/server.py`. Заготовка использует пакет `solution` и импорты `from solution.proto import messenger_pb2, messenger_pb2_grpc`. В своей реализации можно выбрать другую структуру, сохранив три обязательных пути выше.
|
||||
|
||||
### Описание и компиляция gRPC-интерфейса
|
||||
|
||||
Опишите сообщения и сервис в `solution/proto/messenger.proto`.
|
||||
|
||||
Сгенерируйте код для выбранного языка с помощью `protoc`:
|
||||
|
||||
```bash
|
||||
# Python
|
||||
python3 -m grpc_tools.protoc -I. --python_out=. --pyi_out=. --grpc_python_out=. solution/proto/messenger.proto
|
||||
|
||||
# Go (после установки protoc и плагинов protoc-gen-go и protoc-gen-go-grpc)
|
||||
protoc -I solution/proto --go_out=solution/proto --go_opt=paths=source_relative --go-grpc_out=solution/proto --go-grpc_opt=paths=source_relative messenger.proto
|
||||
```
|
||||
|
||||
Для Go укажите в `option go_package` путь пакета в вашем модуле; пример есть в Go-заготовке. Закрепите версии генераторов, совместимые с вашей версией Go. Сгенерированные файлы включите в решение или генерируйте при сборке образа с закреплёнными версиями инструментов.
|
||||
|
||||
### Реализация сервера и клиента
|
||||
|
||||
Сервер реализуйте с нуля; заготовки для него нет. Можно использовать потоки или асинхронный код. Открытые подписки не должны мешать обработке других запросов.
|
||||
|
||||
Если используете Python-заготовку клиента, заполните места с пометкой TODO. HTTP-сервер в ней уже реализован. При запуске клиент должен дождаться сервера, открыть `ReadMessages` и принимать сообщения независимо от обработки HTTP-запросов.
|
||||
|
||||
Сервер и клиент должны брать настройки из переменных окружения:
|
||||
|
||||
| Переменная | Назначение |
|
||||
| --- | --- |
|
||||
| `MESSENGER_SERVER_PORT` | Порт gRPC-сервера, по умолчанию `51075` |
|
||||
| `MESSENGER_SERVER_ADDR` | Адрес gRPC-сервера для подключения клиента |
|
||||
| `MESSENGER_HTTP_PORT` | Порт HTTP-интерфейса клиента |
|
||||
|
||||
Сервер и HTTP-интерфейс клиента должны слушать на `0.0.0.0`.
|
||||
|
||||
В `solution/server.dockerfile` и `solution/client.dockerfile` опишите сборку и запуск сервера и клиента. Контекст сборки — папка `solution`. Включите в образы все нужные файлы и зависимости. За образец можно взять Dockerfile заготовки.
|
||||
|
||||
## Тестирование решения
|
||||
|
||||
Публичные тесты проверяют [протокол](tests/test_proto.py), [сервер](tests/test_server.py) и [клиент](tests/test_client.py). Сложные конкурентные сценарии вы разберёте на защите.
|
||||
|
||||
### Полная проверка
|
||||
|
||||
Рекомендуемый запуск в окружении тестирующей системы:
|
||||
|
||||
```bash
|
||||
docker run --privileged --pull always --rm -v ./solution:/hw/solution distsys.ru/course/grpc-messenger:latest
|
||||
```
|
||||
|
||||
Сервер и клиент собираются и проверяются независимо: ошибка сборки одного не мешает проверить другой.
|
||||
|
||||
### Отдельные компоненты через Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose build tests
|
||||
|
||||
# Протокол
|
||||
docker compose run --rm --no-deps tests --component proto
|
||||
|
||||
# Сервер
|
||||
docker compose build server-tests
|
||||
docker compose run --rm server-test-runner
|
||||
|
||||
# Клиент со служебным сервером
|
||||
docker compose build client-test1
|
||||
docker compose run --rm client-tests
|
||||
```
|
||||
|
||||
После изменения кода пересоберите соответствующий образ и повторите проверку. После изменения протокола заново сгенерируйте код и выполните:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose build tests server-tests client-test1
|
||||
docker compose run --rm tests
|
||||
```
|
||||
|
||||
Смотрите логи через `docker compose logs`, останавливайте контейнеры командой `docker compose down`. После обновления задания скачайте свежий служебный образ: `docker compose pull client-test-server`.
|
||||
|
||||
### Ручная отладка своей связки (необязательно)
|
||||
|
||||
Запустите свой сервер и два клиента через Compose:
|
||||
|
||||
```bash
|
||||
docker compose build server client1
|
||||
docker compose up -d server client1 client2
|
||||
```
|
||||
|
||||
Сервер доступен на `localhost:51075`, клиенты — на `localhost:8080` и `localhost:8081`. Compose запускает клиентов раньше сервера. После правок пересоберите соответствующий образ и повторите `up`; для логов и остановки используйте команды выше.
|
||||
|
||||
Без Docker запустите компоненты и тестер в отдельных терминалах из папки задания. Для официальной Python-заготовки и сервера в `solution/server/server.py`:
|
||||
|
||||
```bash
|
||||
# Терминал 1
|
||||
python3 -m solution.server.server
|
||||
# Терминал 2
|
||||
python3 -m solution.client.client
|
||||
# Терминал 3
|
||||
MESSENGER_HTTP_PORT=8081 python3 -m solution.client.client
|
||||
# Терминал 4
|
||||
python3 tests/main.py
|
||||
```
|
||||
|
||||
Если вы добавили зависимости, установите и их. Чтобы проверить один компонент, передайте тестеру `--component proto`, `--component server` или `--component client`. По умолчанию проверяются все компоненты (`--component all`).
|
||||
|
||||
Здесь клиенты работают с вашим сервером, поэтому его ошибки могут повлиять на клиентские тесты. Для независимой проверки клиента используйте Compose со служебным сервером.
|
||||
|
||||
Примеры HTTP-запросов к запущенному клиенту:
|
||||
|
||||
```bash
|
||||
curl -X POST localhost:8080/sendMessage -d '{"author": "alice", "text": "hey"}'
|
||||
curl -X POST localhost:8080/getAndFlushMessages
|
||||
```
|
||||
|
||||
### Сдача решения
|
||||
|
||||
Подготовьте `solution/readme.md` и отправьте решение по [общей инструкции](../readme.md#сдача-решения). В журнале проверки будут вывод сборки, результаты тестов (после строки `=== RUN TESTS`) и логи контейнеров.
|
||||
|
||||
## Полезные материалы
|
||||
|
||||
### grpcurl
|
||||
|
||||
[grpcurl](https://github.com/fullstorydev/grpcurl) позволяет вызывать gRPC-методы из терминала. Для Linux и Windows скачайте архив для своей ОС и архитектуры со [страницы релизов](https://github.com/fullstorydev/grpcurl/releases), распакуйте его и добавьте каталог с исполняемым файлом в `PATH`. В macOS: `brew install grpcurl`.
|
||||
|
||||
Для проверки сервера откройте подписку в одном терминале, а в другом отправьте сообщение:
|
||||
|
||||
```bash
|
||||
# Терминал 1: поток остаётся открытым; Ctrl+C отменяет вызов
|
||||
grpcurl -proto solution/proto/messenger.proto -plaintext localhost:51075 mes_grpc.MessengerServer/ReadMessages
|
||||
|
||||
# Терминал 2
|
||||
grpcurl -proto solution/proto/messenger.proto -d '{"author": "alice", "text": "hello"}' -plaintext localhost:51075 mes_grpc.MessengerServer/SendMessage
|
||||
```
|
||||
|
||||
### Конкурентная обработка в Python
|
||||
|
||||
При конкурентном доступе к общим изменяемым данным учитывайте возможные гонки. Структуры данных и способы синхронизации выберите самостоятельно.
|
||||
|
||||
- [gRPC Basics Tutorial](https://grpc.io/docs/languages/python/basics/) и [официальные примеры](https://github.com/grpc/grpc/blob/master/examples).
|
||||
- Документация Python: [`threading`](https://docs.python.org/3/library/threading.html), [`queue`](https://docs.python.org/3/library/queue.html), [`asyncio`](https://docs.python.org/3/library/asyncio.html).
|
||||
- Для работы с несколькими терминалами при желании можно использовать [tmux](https://github.com/tmux/tmux/wiki/Getting-Started).
|
||||
@@ -0,0 +1 @@
|
||||
# TODO: Write Docker file for client
|
||||
@@ -0,0 +1,5 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mes_grpc;
|
||||
|
||||
// TODO: Add messages and service
|
||||
@@ -0,0 +1 @@
|
||||
# TODO: Write Docker file for server
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /grpc-messenger
|
||||
COPY proto proto
|
||||
COPY client client
|
||||
RUN cd client && go mod download && go build .
|
||||
|
||||
FROM alpine:latest
|
||||
WORKDIR /grpc-messenger
|
||||
COPY --from=builder /grpc-messenger .
|
||||
CMD ["./client/client"]
|
||||
@@ -0,0 +1,12 @@
|
||||
module github.com/distsys-course/grpc-messenger/client
|
||||
|
||||
go 1.23
|
||||
|
||||
replace github.com/distsys-course/grpc-messenger/grpc => ../proto
|
||||
|
||||
require (
|
||||
github.com/distsys-course/grpc-messenger/grpc v0.0.0-00010101000000-000000000000
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/golang/protobuf v1.5.4
|
||||
google.golang.org/grpc v1.75.0
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mes_grpc "github.com/distsys-course/grpc-messenger/grpc"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang/protobuf/jsonpb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
Author string `json:"author"`
|
||||
Text string `json:"text"`
|
||||
SendTime time.Time `json:"sendTime"`
|
||||
}
|
||||
|
||||
type MessengerClient struct {
|
||||
pendingMessages []ChatMessage
|
||||
pendingMutex sync.Mutex
|
||||
grpcClient YourMessengerServerClient
|
||||
}
|
||||
|
||||
func NewMessengerClient(serverAddr string) *MessengerClient {
|
||||
// TODO
|
||||
}
|
||||
|
||||
func (c *MessengerClient) ReadMessages() {
|
||||
// TODO: implement messages consumer here
|
||||
}
|
||||
|
||||
func (c *MessengerClient) GetPending() (messages []ChatMessage) {
|
||||
c.pendingMutex.Lock()
|
||||
result := c.pendingMessages
|
||||
c.pendingMessages = nil
|
||||
c.pendingMutex.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
type MessageResponse struct {
|
||||
SendTime *time.Time `json:"sendTime"`
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
serverAddr := os.Getenv("MESSENGER_SERVER_ADDR")
|
||||
if serverAddr == "" {
|
||||
serverAddr = "localhost:51075"
|
||||
fmt.Println("Missing MESSENGER_SERVER_ADDR variable, using default value: " + serverAddr)
|
||||
}
|
||||
// TODO: create your grpc client with given address
|
||||
r.POST("/getAndFlushMessages", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, client.GetPending())
|
||||
})
|
||||
|
||||
r.POST("/sendMessage", func(c *gin.Context) {
|
||||
// TODO: implement send message here, that parses body into protobuf and sends to the server
|
||||
c.JSON(http.StatusOK, MessageResponse{SendTime: nil}) // TODO: do not forget to fill SendTime
|
||||
return
|
||||
})
|
||||
|
||||
// TODO: run consumer in a goroutine
|
||||
|
||||
addr := os.Getenv("MESSENGER_HTTP_PORT")
|
||||
if addr == "" {
|
||||
addr = "0.0.0.0:8080"
|
||||
fmt.Println("Missing MESSENGER_HTTP_PORT variable, using default value: 8080")
|
||||
} else {
|
||||
addr = "0.0.0.0:" + addr
|
||||
}
|
||||
if err := r.Run(addr); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/distsys-course/grpc-messenger/grpc
|
||||
|
||||
go 1.23
|
||||
@@ -0,0 +1,7 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mes_grpc;
|
||||
|
||||
option go_package = "proto/;mes_grpc";
|
||||
|
||||
// TODO: Add messages and service
|
||||
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
# TODO: implement grpc client for messenger service
|
||||
|
||||
class MessengerHandler:
|
||||
_pendingMessages: List[dict] # list of messages, that have not been requested yet via get_messages
|
||||
_pendingMessagesLock: asyncio.Lock
|
||||
_grpcClient = None # grpc client of the messenger service
|
||||
|
||||
def __init__(self):
|
||||
self._pendingMessages = []
|
||||
self._pendingMessagesLock = asyncio.Lock()
|
||||
|
||||
async def send_message(self, request):
|
||||
"""
|
||||
Body should be of the form:
|
||||
{"author": "Ivan", "text": "hey guys"}
|
||||
:return web.json_response of the form {"sendTime": ... }
|
||||
"""
|
||||
j = await request.json() # TODO: use google.protobuf.json_format.ParseDict and raise BadRequest on error
|
||||
if 'author' not in j or 'text' not in j:
|
||||
raise web.HTTPBadRequest
|
||||
print('Got message to send:', json.dumps(j))
|
||||
|
||||
# TODO: your rpc call of the messenger here
|
||||
|
||||
raise NotImplementedError
|
||||
return web.json_response({'sendTime': ""}) # TODO: use google.protobuf.json_format.MessageToDict here
|
||||
|
||||
async def get_messages(self, _):
|
||||
async with self._pendingMessagesLock:
|
||||
res: List[dict] = copy.deepcopy(self._pendingMessages)
|
||||
self._pendingMessages = []
|
||||
return web.json_response(res)
|
||||
|
||||
# TODO: implement message stream consumer in async method, that fills self._pendingMessages
|
||||
# btw, consumption can be lazy and happen on get_messages, implement in any suitable way
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = web.Application()
|
||||
grpcServerAddr = os.environ.get('MESSENGER_SERVER_ADDR', 'localhost:51075')
|
||||
|
||||
# TODO: create your grpc client with given address and pass it to MessengerHandler constructor
|
||||
|
||||
handler = MessengerHandler()
|
||||
app.add_routes([web.post('/getAndFlushMessages', handler.get_messages)])
|
||||
app.add_routes([web.post('/sendMessage', handler.send_message)])
|
||||
|
||||
# TODO: run message stream consumer in a background coroutine
|
||||
|
||||
httpPort = os.environ.get('MESSENGER_HTTP_PORT', '8080')
|
||||
web.run_app(app, host='0.0.0.0', port=httpPort)
|
||||
@@ -0,0 +1,3 @@
|
||||
aiohttp==3.12.15
|
||||
grpcio==1.75.0
|
||||
grpcio-tools==1.75.0
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /grpc-messenger
|
||||
|
||||
COPY client/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY client/client.py solution/client/
|
||||
COPY proto solution/proto/
|
||||
|
||||
ENTRYPOINT ["python", "-m", "solution.client.client"]
|
||||
@@ -0,0 +1,102 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from http import HTTPStatus
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from typing import List, Dict
|
||||
|
||||
import google.protobuf.empty_pb2 # Empty
|
||||
import google.protobuf.json_format # ParseDict, MessageToDict
|
||||
import grpc
|
||||
|
||||
from solution.proto import messenger_pb2
|
||||
from solution.proto import messenger_pb2_grpc
|
||||
|
||||
|
||||
class PostBox:
|
||||
def __init__(self):
|
||||
self._messages: List[Dict] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def collect_messages(self) -> List[Dict]:
|
||||
with self._lock:
|
||||
messages = copy.deepcopy(self._messages)
|
||||
self._messages = []
|
||||
return messages
|
||||
|
||||
def put_message(self, message: Dict):
|
||||
with self._lock:
|
||||
self._messages.append(message)
|
||||
|
||||
|
||||
class MessageHandler(BaseHTTPRequestHandler):
|
||||
_stub = None
|
||||
_postbox: PostBox
|
||||
|
||||
def _read_content(self):
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
bytes_content = self.rfile.read(content_length)
|
||||
return bytes_content.decode('ascii')
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
def do_POST(self):
|
||||
if self.path == '/sendMessage':
|
||||
response = self._send_message(self._read_content())
|
||||
elif self.path == '/getAndFlushMessages':
|
||||
response = self._get_messages()
|
||||
else:
|
||||
self.send_error(HTTPStatus.NOT_IMPLEMENTED)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
response_bytes = json.dumps(response).encode('ascii')
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header('Content-Length', str(len(response_bytes)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response_bytes)
|
||||
|
||||
def _send_message(self, content: str) -> dict:
|
||||
json_request = json.loads(content)
|
||||
|
||||
# TODO: use google.protobuf.json_format.ParseDict
|
||||
|
||||
# TODO: your rpc call of the messenger here
|
||||
|
||||
# TODO: use google.protobuf.json_format.MessageToDict here
|
||||
return {'sendTime': ''}
|
||||
|
||||
def _get_messages(self) -> List[dict]:
|
||||
return self._postbox.collect_messages()
|
||||
|
||||
|
||||
def main():
|
||||
grpc_server_address = os.environ.get('MESSENGER_SERVER_ADDR', 'localhost:51075')
|
||||
|
||||
# TODO: create your grpc client and wait for the server to become available.
|
||||
# The client may start before the server.
|
||||
stub = None
|
||||
|
||||
# A list of messages obtained from the server-py but not yet requested by the user to be shown
|
||||
# (via the http's /getAndFlushMessages).
|
||||
postbox = PostBox()
|
||||
|
||||
# TODO: Implement and run a messages stream consumer in a background thread here.
|
||||
# It should fetch messages via the grpc client and store them in the postbox.
|
||||
|
||||
# Pass the stub and the postbox to the HTTP server.
|
||||
# Dirty, but this simple http server doesn't provide interface
|
||||
# for passing arguments to the handler c-tor.
|
||||
MessageHandler._stub = stub
|
||||
MessageHandler._postbox = postbox
|
||||
|
||||
http_port = os.environ.get('MESSENGER_HTTP_PORT', '8080')
|
||||
http_server_address = ('0.0.0.0', int(http_port))
|
||||
|
||||
# NB: handler_class is instantiated for every http request. Do not store any inter-request state in it.
|
||||
httpd = HTTPServer(http_server_address, MessageHandler)
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
grpcio==1.75.0
|
||||
grpcio-tools==1.75.0
|
||||
@@ -0,0 +1,5 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mes_grpc;
|
||||
|
||||
// TODO: Add messages and service
|
||||
@@ -0,0 +1,21 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.12-alpine
|
||||
|
||||
COPY tests/requirements.txt .
|
||||
RUN --mount=type=cache,id=distsys-course-pip,target=/root/.cache/pip,sharing=locked \
|
||||
pip install -r requirements.txt
|
||||
|
||||
ARG GRPCURL_VERSION=1.9.3
|
||||
ARG GRPCURL_SHA256=a926b62a85787ccf73ef8736b3ae554f1242e39d92bb8767a79d6dd23b11d1d5
|
||||
RUN apk add --no-cache curl tini \
|
||||
&& curl --fail --silent --show-error --location \
|
||||
"https://github.com/fullstorydev/grpcurl/releases/download/v${GRPCURL_VERSION}/grpcurl_${GRPCURL_VERSION}_linux_x86_64.tar.gz" \
|
||||
--output /tmp/grpcurl.tar.gz \
|
||||
&& echo "${GRPCURL_SHA256} /tmp/grpcurl.tar.gz" | sha256sum -c \
|
||||
&& tar -xzf /tmp/grpcurl.tar.gz -C /usr/local/bin \
|
||||
&& rm -f /tmp/grpcurl.tar.gz
|
||||
|
||||
COPY solution/proto/ solution/proto/
|
||||
COPY tests/*.py tests/
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "python3", "-u", "tests/main.py"]
|
||||
@@ -0,0 +1,109 @@
|
||||
import argparse
|
||||
import os
|
||||
import pathlib
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
SUITE_TIMEOUT_S = 120
|
||||
|
||||
|
||||
class PassedCounter:
|
||||
def __init__(self):
|
||||
self.passed = 0
|
||||
|
||||
def pytest_report_teststatus(self, report, config):
|
||||
if report.when == 'call' and report.passed:
|
||||
self.passed += 1
|
||||
|
||||
|
||||
def suite_score(passed, expected, maximum, exit_code=pytest.ExitCode.OK):
|
||||
return maximum if exit_code == pytest.ExitCode.OK and passed == expected else 0
|
||||
|
||||
|
||||
def run_suite(filename, expected, maximum):
|
||||
# Each component has its own deadline so a timeout cannot discard other scores.
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, '-u', str(pathlib.Path(__file__).resolve()),
|
||||
'--suite', str(SCRIPT_DIR / filename), str(expected)],
|
||||
start_new_session=os.name == 'posix',
|
||||
)
|
||||
try:
|
||||
exit_code = process.wait(timeout=SUITE_TIMEOUT_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
if os.name == 'posix':
|
||||
# Include grpcurl and other subprocesses started by this test suite.
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass # The suite may have exited just after wait() timed out.
|
||||
else:
|
||||
process.kill()
|
||||
process.wait()
|
||||
print(f'{filename} exceeded {SUITE_TIMEOUT_S} seconds; component score is zero.', flush=True)
|
||||
return 0
|
||||
return maximum if exit_code == 0 else 0
|
||||
|
||||
|
||||
def suite_exit_code(filename, expected):
|
||||
counter = PassedCounter()
|
||||
exit_code = pytest.main(['-vs', filename], plugins=[counter])
|
||||
return 0 if suite_score(counter.passed, expected, 1, exit_code) else 1
|
||||
|
||||
|
||||
def component_enabled(environment_name):
|
||||
return os.environ.get(environment_name, '1') == '1'
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
'--component',
|
||||
choices=('all', 'proto', 'server', 'client'),
|
||||
default='all',
|
||||
)
|
||||
parser.add_argument('--suite', nargs=2, metavar=('FILE', 'EXPECTED'), help=argparse.SUPPRESS)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
if args.suite is not None:
|
||||
return suite_exit_code(args.suite[0], int(args.suite[1]))
|
||||
component = args.component
|
||||
score = 0
|
||||
|
||||
if component in ('all', 'proto'):
|
||||
proto_score = run_suite('test_proto.py', expected=1, maximum=2)
|
||||
score += proto_score
|
||||
print(f'Proto: {proto_score}/2')
|
||||
print()
|
||||
|
||||
if component in ('all', 'server'):
|
||||
if component_enabled('MESSENGER_SERVER_TESTS_ENABLED'):
|
||||
server_score = run_suite('test_server.py', expected=4, maximum=4)
|
||||
else:
|
||||
server_score = 0
|
||||
print('Server tests were not run because the server image did not build.')
|
||||
score += server_score
|
||||
print(f'Server: {server_score}/4')
|
||||
print()
|
||||
|
||||
if component in ('all', 'client'):
|
||||
if component_enabled('MESSENGER_CLIENT_TESTS_ENABLED'):
|
||||
client_score = run_suite('test_client.py', expected=3, maximum=4)
|
||||
else:
|
||||
client_score = 0
|
||||
print('Client tests were not run because the client image did not build.')
|
||||
score += client_score
|
||||
print(f'Client: {client_score}/4')
|
||||
|
||||
print(f'\nSCORE: {score}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
grpcio==1.75.0
|
||||
grpcio-tools==1.75.0
|
||||
protobuf==6.32.1
|
||||
pytest==8.4.2
|
||||
requests==2.32.5
|
||||
@@ -0,0 +1,179 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
HTTP_CONNECT_TIMEOUT_S = 1
|
||||
HTTP_READ_TIMEOUT_S = 5
|
||||
HTTP_READY_TIMEOUT_S = 20
|
||||
HTTP_RETRY_INTERVAL_S = 0.5
|
||||
HTTP_TIMEOUT = (HTTP_CONNECT_TIMEOUT_S, HTTP_READ_TIMEOUT_S)
|
||||
MESSAGE_TIMEOUT_S = 10
|
||||
MESSAGE_POLL_INTERVAL_S = 0.05
|
||||
|
||||
|
||||
def wait_for_http(url):
|
||||
deadline = time.monotonic() + HTTP_READY_TIMEOUT_S
|
||||
last_exception = None
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
pytest.fail(
|
||||
f'timed out waiting for HTTP endpoint {url}: {last_exception}',
|
||||
pytrace=False,
|
||||
)
|
||||
readiness_timeout = (
|
||||
min(HTTP_CONNECT_TIMEOUT_S, remaining),
|
||||
min(HTTP_READ_TIMEOUT_S, remaining),
|
||||
)
|
||||
try:
|
||||
response = requests.get(url, timeout=readiness_timeout)
|
||||
response.close()
|
||||
return
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
|
||||
last_exception = exc
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
pytest.fail(
|
||||
f'timed out waiting for HTTP endpoint {url}: {last_exception}',
|
||||
pytrace=False,
|
||||
)
|
||||
time.sleep(min(HTTP_RETRY_INTERVAL_S, remaining))
|
||||
|
||||
|
||||
def post_json(url, path, timeout=HTTP_TIMEOUT, **kwargs):
|
||||
endpoint = url + path
|
||||
try:
|
||||
response = requests.post(endpoint, timeout=timeout, **kwargs)
|
||||
except requests.exceptions.Timeout as exc:
|
||||
pytest.fail(f'timed out waiting for HTTP response from {endpoint}: {exc}', pytrace=False)
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
pytest.fail(f'could not connect to HTTP endpoint {endpoint}: {exc}', pytrace=False)
|
||||
|
||||
try:
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def client1_ready_url():
|
||||
url = 'http://' + os.environ.get('MESSENGER_TEST_CLIENT1_ADDR', '127.0.0.1:8080')
|
||||
wait_for_http(url)
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def client2_ready_url():
|
||||
url = 'http://' + os.environ.get('MESSENGER_TEST_CLIENT2_ADDR', '127.0.0.1:8081')
|
||||
wait_for_http(url)
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client1_url(client1_ready_url):
|
||||
url = client1_ready_url
|
||||
get_messages(url) # we need to flush pending messages before and after each tests
|
||||
yield url
|
||||
get_messages(url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client2_url(client2_ready_url):
|
||||
url = client2_ready_url
|
||||
get_messages(url)
|
||||
yield url
|
||||
get_messages(url)
|
||||
|
||||
|
||||
def send_message(url, mes):
|
||||
return post_json(url, '/sendMessage', json=mes)
|
||||
|
||||
|
||||
def get_messages(url, **kwargs):
|
||||
return post_json(url, '/getAndFlushMessages', **kwargs)
|
||||
|
||||
|
||||
def wait_for_messages(url, expected):
|
||||
deadline = time.monotonic() + MESSAGE_TIMEOUT_S
|
||||
messages = []
|
||||
while len(messages) < len(expected):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
pytest.fail(
|
||||
f'timed out waiting for messages from {url}: '
|
||||
f'expected {expected!r}, received {messages!r}',
|
||||
pytrace=False,
|
||||
)
|
||||
# Divide the remaining budget between connecting and reading the response.
|
||||
timeout = (
|
||||
min(HTTP_CONNECT_TIMEOUT_S, remaining / 2),
|
||||
min(HTTP_READ_TIMEOUT_S, remaining / 2),
|
||||
)
|
||||
batch = get_messages(url, timeout=timeout)
|
||||
assert isinstance(batch, list), f'expected a message array, received {batch!r}'
|
||||
messages.extend(batch)
|
||||
assert messages == expected[:len(messages)], \
|
||||
f'expected {expected!r}, received {messages!r}'
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
pytest.fail(f'timed out waiting for messages from {url}', pytrace=False)
|
||||
if len(messages) < len(expected):
|
||||
time.sleep(min(MESSAGE_POLL_INTERVAL_S, remaining))
|
||||
return messages
|
||||
|
||||
|
||||
def test_single_client_single_message(client1_url, client2_url):
|
||||
mes = {
|
||||
'author': 'TestSingleClient',
|
||||
'text': 'This is test text'
|
||||
}
|
||||
resp = send_message(client1_url, mes)
|
||||
mes['sendTime'] = resp['sendTime']
|
||||
assert wait_for_messages(client1_url, [mes]) == [mes]
|
||||
# Drain both subscriptions before the next test sends more messages.
|
||||
assert wait_for_messages(client2_url, [mes]) == [mes]
|
||||
|
||||
|
||||
def test_single_client_multiple_messages(client1_url, client2_url):
|
||||
mes = [{
|
||||
'author': 'TestSingleClient1',
|
||||
'text': 'This is test text'
|
||||
}, {
|
||||
'author': 'TestSingleClient2',
|
||||
'text': 'This is test text'
|
||||
}]
|
||||
for m in mes:
|
||||
resp = send_message(client1_url, m)
|
||||
m['sendTime'] = resp['sendTime']
|
||||
assert wait_for_messages(client1_url, mes) == mes
|
||||
assert wait_for_messages(client2_url, mes) == mes
|
||||
|
||||
|
||||
def test_two_clients_multiple_messages(client1_url, client2_url):
|
||||
client1_name = 'TestMultiClient1'
|
||||
client2_name = 'TestMultiClient2'
|
||||
mes = [{
|
||||
'author': client1_name,
|
||||
'text': 'This is test text #1'
|
||||
}, {
|
||||
'author': client1_name,
|
||||
'text': 'This is test text #2'
|
||||
}, {
|
||||
'author': client2_name,
|
||||
'text': 'This is test text #3'
|
||||
}, {
|
||||
'author': client2_name,
|
||||
'text': 'This is test text #4'
|
||||
}]
|
||||
times = set()
|
||||
for m in mes:
|
||||
resp = send_message(client1_url if m['author'] == client1_name else client2_url, m)
|
||||
m['sendTime'] = resp['sendTime']
|
||||
times.add(m['sendTime'])
|
||||
assert len(times) == len(mes)
|
||||
assert wait_for_messages(client1_url, mes) == mes
|
||||
assert wait_for_messages(client2_url, mes) == mes
|
||||
@@ -0,0 +1,140 @@
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
import grpc_tools
|
||||
from google.protobuf import descriptor
|
||||
from google.protobuf import descriptor_pb2
|
||||
from google.protobuf import descriptor_pool
|
||||
from grpc_tools import protoc
|
||||
|
||||
|
||||
SCRIPT_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
PROTO_DIR = SCRIPT_DIR.parent / 'solution' / 'proto'
|
||||
PROTO_FILE = PROTO_DIR / 'messenger.proto'
|
||||
WELL_KNOWN_PROTO_DIR = pathlib.Path(grpc_tools.__file__).parent / '_proto'
|
||||
|
||||
|
||||
def compile_descriptor_set(output_path):
|
||||
result = protoc.main([
|
||||
'grpc_tools.protoc',
|
||||
f'-I{PROTO_DIR}',
|
||||
f'-I{WELL_KNOWN_PROTO_DIR}',
|
||||
f'--descriptor_set_out={output_path}',
|
||||
'--include_imports',
|
||||
str(PROTO_FILE),
|
||||
])
|
||||
assert result == 0, 'messenger.proto must compile successfully'
|
||||
|
||||
descriptor_set = descriptor_pb2.FileDescriptorSet()
|
||||
descriptor_set.ParseFromString(output_path.read_bytes())
|
||||
return descriptor_set
|
||||
|
||||
|
||||
def build_descriptor_pool(descriptor_set):
|
||||
pool = descriptor_pool.DescriptorPool()
|
||||
remaining = list(descriptor_set.file)
|
||||
while remaining:
|
||||
deferred = []
|
||||
for file_descriptor in remaining:
|
||||
try:
|
||||
pool.Add(file_descriptor)
|
||||
except TypeError:
|
||||
deferred.append(file_descriptor)
|
||||
assert len(deferred) < len(remaining), 'messenger.proto imports could not be resolved'
|
||||
remaining = deferred
|
||||
return pool
|
||||
|
||||
|
||||
def require_singular_field(message_type, field_name, field_type, message_type_name=None):
|
||||
assert field_name in message_type.fields_by_name, \
|
||||
f'{message_type.full_name} must contain field {field_name}'
|
||||
field = message_type.fields_by_name[field_name]
|
||||
assert not field.is_repeated, f'{field.full_name} must be a singular field'
|
||||
assert field.type == field_type, f'{field.full_name} has an invalid type'
|
||||
if message_type_name is not None:
|
||||
assert field.message_type is not None
|
||||
assert field.message_type.full_name == message_type_name, \
|
||||
f'{field.full_name} has an invalid message type'
|
||||
|
||||
|
||||
def require_fields_can_coexist(message_type, field_names):
|
||||
fields_by_oneof = {}
|
||||
for field_name in field_names:
|
||||
field = message_type.fields_by_name[field_name]
|
||||
if field.containing_oneof is None:
|
||||
continue
|
||||
previous_field = fields_by_oneof.setdefault(field.containing_oneof.full_name, field_name)
|
||||
assert previous_field == field_name, \
|
||||
f'{message_type.full_name} fields must allow simultaneous values'
|
||||
|
||||
|
||||
def test_proto_contract():
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
descriptor_path = pathlib.Path(temporary_directory) / 'messenger.pb'
|
||||
descriptor_set = compile_descriptor_set(descriptor_path)
|
||||
|
||||
submitted_file = next(
|
||||
(file_descriptor for file_descriptor in descriptor_set.file
|
||||
if pathlib.PurePosixPath(file_descriptor.name).name == PROTO_FILE.name),
|
||||
None,
|
||||
)
|
||||
assert submitted_file is not None, 'messenger.proto descriptor is missing'
|
||||
assert submitted_file.syntax == 'proto3', 'messenger.proto must use proto3 syntax'
|
||||
assert submitted_file.package == 'mes_grpc', 'messenger.proto must use package mes_grpc'
|
||||
|
||||
pool = build_descriptor_pool(descriptor_set)
|
||||
try:
|
||||
messenger = pool.FindServiceByName('mes_grpc.MessengerServer')
|
||||
except KeyError:
|
||||
raise AssertionError('gRPC service must be named mes_grpc.MessengerServer') from None
|
||||
|
||||
assert 'SendMessage' in messenger.methods_by_name, \
|
||||
'MessengerServer must contain method SendMessage'
|
||||
send_message = messenger.methods_by_name['SendMessage']
|
||||
assert not send_message.client_streaming and not send_message.server_streaming, \
|
||||
'SendMessage must be unary'
|
||||
require_singular_field(
|
||||
send_message.input_type,
|
||||
'author',
|
||||
descriptor.FieldDescriptor.TYPE_STRING,
|
||||
)
|
||||
require_singular_field(
|
||||
send_message.input_type,
|
||||
'text',
|
||||
descriptor.FieldDescriptor.TYPE_STRING,
|
||||
)
|
||||
require_fields_can_coexist(send_message.input_type, ('author', 'text'))
|
||||
require_singular_field(
|
||||
send_message.output_type,
|
||||
'sendTime',
|
||||
descriptor.FieldDescriptor.TYPE_MESSAGE,
|
||||
'google.protobuf.Timestamp',
|
||||
)
|
||||
|
||||
assert 'ReadMessages' in messenger.methods_by_name, \
|
||||
'MessengerServer must contain method ReadMessages'
|
||||
read_messages = messenger.methods_by_name['ReadMessages']
|
||||
assert not read_messages.client_streaming and read_messages.server_streaming, \
|
||||
'ReadMessages must be a unary request with a server stream response'
|
||||
assert not read_messages.input_type.fields, \
|
||||
'ReadMessages request must not contain fields'
|
||||
require_singular_field(
|
||||
read_messages.output_type,
|
||||
'author',
|
||||
descriptor.FieldDescriptor.TYPE_STRING,
|
||||
)
|
||||
require_singular_field(
|
||||
read_messages.output_type,
|
||||
'text',
|
||||
descriptor.FieldDescriptor.TYPE_STRING,
|
||||
)
|
||||
require_singular_field(
|
||||
read_messages.output_type,
|
||||
'sendTime',
|
||||
descriptor.FieldDescriptor.TYPE_MESSAGE,
|
||||
'google.protobuf.Timestamp',
|
||||
)
|
||||
require_fields_can_coexist(
|
||||
read_messages.output_type,
|
||||
('author', 'text', 'sendTime'),
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import queue
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict
|
||||
|
||||
import pytest
|
||||
|
||||
test_message = {'author': 'alice', 'text': 'hello'}
|
||||
|
||||
PROTO_DIR = pathlib.Path(__file__).resolve().parent.parent / 'solution' / 'proto'
|
||||
|
||||
SOCKET_CONNECT_TIMEOUT_S = 1
|
||||
SERVICE_READY_TIMEOUT_S = 20
|
||||
SERVICE_RETRY_INTERVAL_S = 0.5
|
||||
GRPC_CALL_TIMEOUT_S = 5
|
||||
GRPC_PROCESS_TIMEOUT_S = 10
|
||||
GRPC_STREAM_TIMEOUT_S = 60
|
||||
PROCESS_STOP_TIMEOUT_S = 5
|
||||
MESSAGE_TIMEOUT_S = 10
|
||||
|
||||
|
||||
def wait_for_socket(host, port):
|
||||
deadline = time.monotonic() + SERVICE_READY_TIMEOUT_S
|
||||
last_exception = None
|
||||
while True:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=SOCKET_CONNECT_TIMEOUT_S):
|
||||
pass
|
||||
return
|
||||
except OSError as exc:
|
||||
last_exception = exc
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
pytest.fail(
|
||||
f'timed out waiting for TCP endpoint {host}:{port}: {last_exception}',
|
||||
pytrace=False,
|
||||
)
|
||||
time.sleep(min(SERVICE_RETRY_INTERVAL_S, remaining))
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def server_addr():
|
||||
addr = os.environ.get('MESSENGER_TEST_SERVER_ADDR', '127.0.0.1:51075')
|
||||
host = addr.split(':')[0]
|
||||
port = int(addr.split(':')[1])
|
||||
wait_for_socket(host, port)
|
||||
yield addr
|
||||
|
||||
|
||||
def send_message(server_address, message: Dict[str, str]) -> Dict[str, str]:
|
||||
grpcurl_cmd = ['grpcurl',
|
||||
'-max-time', str(GRPC_CALL_TIMEOUT_S),
|
||||
'-import-path', str(PROTO_DIR),
|
||||
'-proto', 'messenger.proto',
|
||||
'-d',
|
||||
json.dumps(message),
|
||||
'-plaintext',
|
||||
server_address,
|
||||
'mes_grpc.MessengerServer/SendMessage']
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
grpcurl_cmd,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=GRPC_PROCESS_TIMEOUT_S,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
pytest.fail(
|
||||
f'grpcurl did not finish within {GRPC_PROCESS_TIMEOUT_S} seconds',
|
||||
pytrace=False,
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert len(completed.stderr) == 0, completed.stderr
|
||||
output_str = completed.stdout.decode('ascii')
|
||||
output = json.loads(output_str)
|
||||
|
||||
message_with_timestamp = copy.deepcopy(message)
|
||||
message_with_timestamp['sendTime'] = output['sendTime']
|
||||
return message_with_timestamp
|
||||
|
||||
|
||||
class MessageStream:
|
||||
def __init__(self, server_address):
|
||||
grpcurl_cmd = ['grpcurl',
|
||||
'-max-time', str(GRPC_STREAM_TIMEOUT_S),
|
||||
'-import-path', str(PROTO_DIR),
|
||||
'-proto', 'messenger.proto',
|
||||
'-plaintext',
|
||||
server_address,
|
||||
'mes_grpc.MessengerServer/ReadMessages']
|
||||
self._process = subprocess.Popen(
|
||||
grpcurl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self._messages = queue.Queue()
|
||||
self._reader_error = None
|
||||
self._reader_finished = threading.Event()
|
||||
self._closed = False
|
||||
self._reader = threading.Thread(target=self._read_messages, daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
def _read_messages(self):
|
||||
try:
|
||||
message_lines = []
|
||||
for line in self._process.stdout:
|
||||
message_lines.append(line)
|
||||
if line.rstrip() == '}':
|
||||
self._messages.put(json.loads(''.join(message_lines)))
|
||||
message_lines = []
|
||||
except Exception as exc:
|
||||
self._reader_error = exc
|
||||
finally:
|
||||
self._reader_finished.set()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.close()
|
||||
|
||||
def _failure_detail(self):
|
||||
if self._reader_error is not None:
|
||||
return f'reader failed: {self._reader_error}'
|
||||
returncode = self._process.poll()
|
||||
if returncode is not None:
|
||||
stderr = self._process.stderr.read().strip()
|
||||
return f'grpcurl exited with status {returncode}: {stderr}'
|
||||
return 'grpcurl is still running but produced no matching message'
|
||||
|
||||
def _get_message(self, deadline, timeout_message):
|
||||
while True:
|
||||
if self._reader_error is not None:
|
||||
raise AssertionError(self._failure_detail())
|
||||
if self._reader_finished.is_set() and self._messages.empty():
|
||||
raise AssertionError(self._failure_detail())
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise AssertionError(f'{timeout_message}: {self._failure_detail()}')
|
||||
try:
|
||||
return self._messages.get(timeout=min(remaining, 0.1))
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def wait_for_message(self, expected_message, timeout, preceding_messages=None):
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
message = self._get_message(
|
||||
deadline,
|
||||
f'timed out waiting for message {expected_message}',
|
||||
)
|
||||
if message == expected_message:
|
||||
return
|
||||
if preceding_messages is not None:
|
||||
preceding_messages.append(message)
|
||||
|
||||
def read_messages(self, count, timeout):
|
||||
deadline = time.monotonic() + timeout
|
||||
messages = []
|
||||
while len(messages) < count:
|
||||
messages.append(self._get_message(
|
||||
deadline,
|
||||
f'timed out after receiving {len(messages)} of {count} messages',
|
||||
))
|
||||
return messages
|
||||
|
||||
def close(self):
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if self._process.poll() is None:
|
||||
self._process.terminate()
|
||||
try:
|
||||
self._process.wait(timeout=PROCESS_STOP_TIMEOUT_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
try:
|
||||
self._process.wait(timeout=PROCESS_STOP_TIMEOUT_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
pytest.fail('grpcurl did not exit after SIGKILL', pytrace=False)
|
||||
self._reader.join(timeout=PROCESS_STOP_TIMEOUT_S)
|
||||
assert not self._reader.is_alive()
|
||||
|
||||
|
||||
def wait_for_streams(server_address, streams):
|
||||
preceding_messages = [[] for _ in streams]
|
||||
probes = []
|
||||
for attempt in range(10):
|
||||
probe = send_message(
|
||||
server_address,
|
||||
{'author': 'StreamProbe', 'text': f'probe #{attempt}'},
|
||||
)
|
||||
probes.append(probe)
|
||||
streams_ready = True
|
||||
for index, stream in enumerate(streams):
|
||||
try:
|
||||
stream.wait_for_message(
|
||||
probe,
|
||||
timeout=1,
|
||||
preceding_messages=preceding_messages[index],
|
||||
)
|
||||
except AssertionError:
|
||||
streams_ready = False
|
||||
if streams_ready:
|
||||
return [
|
||||
[message for message in messages if message not in probes]
|
||||
for messages in preceding_messages
|
||||
]
|
||||
raise AssertionError('ReadMessages streams did not become ready')
|
||||
|
||||
|
||||
def timestamp_key(timestamp):
|
||||
match = re.fullmatch(r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z', timestamp)
|
||||
assert match is not None, f'invalid protobuf timestamp: {timestamp}'
|
||||
seconds = int(datetime.strptime(match.group(1), '%Y-%m-%dT%H:%M:%S')
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
nanos = int((match.group(2) or '').ljust(9, '0'))
|
||||
return seconds, nanos
|
||||
|
||||
|
||||
def test_send_smoke(server_addr):
|
||||
send_message(server_addr, test_message)
|
||||
|
||||
|
||||
def test_send_returns_ascending_time(server_addr):
|
||||
outputs = []
|
||||
for _ in range(10):
|
||||
outputs.append(send_message(server_addr, test_message))
|
||||
|
||||
for output1, output2 in zip(outputs, outputs[1:]):
|
||||
assert timestamp_key(output1['sendTime']) < timestamp_key(output2['sendTime'])
|
||||
|
||||
|
||||
def test_get_messages_smoke(server_addr):
|
||||
with MessageStream(server_addr) as stream:
|
||||
wait_for_streams(server_addr, [stream])
|
||||
test_message_with_timestamp = send_message(server_addr, test_message)
|
||||
messages = stream.read_messages(1, timeout=MESSAGE_TIMEOUT_S)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0] == test_message_with_timestamp
|
||||
|
||||
|
||||
def test_get_only_sends_new(server_addr):
|
||||
messages1 = []
|
||||
messages3 = []
|
||||
n1, n2, n3 = 2, 3, 4
|
||||
|
||||
with MessageStream(server_addr) as stream:
|
||||
messages_before_ready = wait_for_streams(server_addr, [stream])
|
||||
assert messages_before_ready == [[]]
|
||||
for _ in range(n1):
|
||||
message = send_message(server_addr, test_message)
|
||||
messages1.append(message)
|
||||
messages = stream.read_messages(n1, timeout=MESSAGE_TIMEOUT_S)
|
||||
|
||||
assert len(messages1) == len(messages)
|
||||
for m1, m2 in zip(messages1, messages):
|
||||
assert m1 == m2
|
||||
|
||||
for _ in range(n2):
|
||||
send_message(server_addr, test_message)
|
||||
|
||||
with MessageStream(server_addr) as stream:
|
||||
messages_before_ready = wait_for_streams(server_addr, [stream])
|
||||
assert messages_before_ready == [[]]
|
||||
for _ in range(n3):
|
||||
message = send_message(server_addr, test_message)
|
||||
messages3.append(message)
|
||||
messages = stream.read_messages(n3, timeout=MESSAGE_TIMEOUT_S)
|
||||
|
||||
assert len(messages3) == len(messages)
|
||||
for m1, m2 in zip(messages3, messages):
|
||||
assert m1 == m2
|
||||
Reference in New Issue
Block a user