Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6210b46c1f | ||
|
|
0a1eb51bf8 | ||
|
|
25fd4ed555 | ||
|
|
a5011dd94b | ||
|
|
43f49f4b2a |
@@ -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
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# HTTP-сервер
|
||||||
|
|
||||||
|
В этом задании вам предстоит реализовать простой HTTP-сервер для хранения файлов.
|
||||||
|
|
||||||
|
На практике вы, скорее всего, будете использовать готовые библиотеки, в которых работа с HTTP уже реализована. Но полезно хотя бы раз сделать это «руками», чтобы лучше понять протокол: как клиент передаёт запрос, как сервер его читает и формирует ответ.
|
||||||
|
|
||||||
|
Формат сообщений и работу соединений описывает [RFC 9112](https://www.rfc-editor.org/rfc/rfc9112.html), семантику методов и заголовков — [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html). В задании используется ограниченное подмножество HTTP/1.1, описанное ниже. Прочитайте условие целиком перед началом работы.
|
||||||
|
|
||||||
|
## Запуск сервера
|
||||||
|
|
||||||
|
При запуске сервер получает адрес и порт для приёма соединений, рабочую директорию и ожидаемое доменное имя. Эти настройки можно задать параметрами командной строки или переменными окружения:
|
||||||
|
|
||||||
|
| Параметр | Переменная окружения | По умолчанию | Назначение |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--host` | `SERVER_HOST` | `0.0.0.0` | Адрес прослушивания TCP-соединений |
|
||||||
|
| `--port` | `SERVER_PORT` | `8080` | Порт сервера |
|
||||||
|
| `--working-directory` | `SERVER_WORKING_DIRECTORY` | Нет | Абсолютный путь к существующей рабочей директории |
|
||||||
|
| `--server-domain` | `SERVER_DOMAIN` | `localhost` | Ожидаемое доменное имя в заголовке `Host` |
|
||||||
|
|
||||||
|
Параметр командной строки имеет приоритет над переменной окружения, а переменная окружения — над значением по умолчанию. Если рабочая директория не указана ни одним способом или задана пустой строкой, сервер завершается с кодом 1. В Python-заготовке эта настройка уже реализована.
|
||||||
|
|
||||||
|
Параметры `host` и `port` определяют, где сервер принимает TCP-соединения. Параметр `server-domain` используется для проверки заголовка `Host` в запросах; он не меняет адрес прослушивания и не требует настройки DNS.
|
||||||
|
|
||||||
|
### Проверка готовности
|
||||||
|
|
||||||
|
Тестер ждёт готовности сервера не более **10 секунд**, отправляя запросы `GET / HTTP/1.1` с заголовком `Host`, равным `server-domain`. Готовность подтверждает любой корректный HTTP-ответ, включая ответ об ошибке.
|
||||||
|
|
||||||
|
**Сервер должен отвечать на GET `/` во всех группах тестов.** В G1 и G2 можно вернуть код 200 с коротким текстом в теле ответа. Начиная с G3, этот запрос должен возвращать список файлов и директорий в рабочей директории.
|
||||||
|
|
||||||
|
Если сервер не начнёт отвечать вовремя, тестер завершит запуск с ошибкой `server did not start in 10 seconds`. В этом случае проверьте вывод сервера в журнале тестирования: проблема может быть как в запуске, так и в обработке GET `/`.
|
||||||
|
|
||||||
|
## Работа с файлами
|
||||||
|
|
||||||
|
Сервер поддерживает четыре метода: GET читает файл или содержимое директории, POST создаёт файл или директорию, PUT заменяет содержимое существующего файла, а DELETE удаляет файл или директорию. Реализовывать HEAD и OPTIONS не требуется.
|
||||||
|
|
||||||
|
Метод задаёт действие, а путь в запросе — файл или директорию, к которым это действие применяется. Все четыре метода используют одну схему путей: путь отсчитывается от рабочей директории сервера, заданной при запуске. Например, при `--working-directory /srv/files` запросы к `/docs/file.txt` работают с `/srv/files/docs/file.txt`. Начальный `/` в запросе обозначает рабочую директорию сервера, а не корень файловой системы компьютера. Отдельные адреса вроде `/get` или `/upload` создавать не нужно.
|
||||||
|
|
||||||
|
Ниже записи вида `GET /docs/file.txt` обозначают метод и путь запроса. В передаваемом по TCP HTTP-сообщении к ним добавляются версия протокола, заголовки и, при необходимости, тело.
|
||||||
|
|
||||||
|
### GET: чтение файла или директории
|
||||||
|
|
||||||
|
Если путь указывает на файл, сервер возвращает его содержимое в теле ответа. Например, `GET /docs/file.txt` возвращает байты файла `/srv/files/docs/file.txt`.
|
||||||
|
|
||||||
|
Если путь указывает на директорию, сервер возвращает список её непосредственных дочерних файлов и директорий — листинг. Так, `GET /docs` возвращает имена объектов внутри `/srv/files/docs`, а `GET /` — внутри самой рабочей директории. Вложенные директории обходить не нужно.
|
||||||
|
|
||||||
|
В списке должны присутствовать все имена, включая скрытые файлы, но без `.` и `..`. Порядок и оформление произвольные: можно вернуть текст или HTML. Размеры, даты и права доступа не требуются; дополнительные сведения допустимы.
|
||||||
|
|
||||||
|
### POST: создание файла или директории
|
||||||
|
|
||||||
|
Путь в POST-запросе задаёт имя создаваемого объекта целиком. Например, `POST /docs/new.txt` создаёт файл `/srv/files/docs/new.txt` и записывает в него тело запроса. Директория `/srv/files/docs` к этому моменту должна существовать. Если тело пустое, создаётся пустой файл.
|
||||||
|
|
||||||
|
Чтобы создать директорию, клиент передаёт заголовок `Create-Directory: True`. Например, `POST /docs/archive` с этим заголовком создаёт пустую директорию `/srv/files/docs/archive`. Сервер создаёт только указанный объект: промежуточные директории автоматически не создаются. Если по этому пути уже есть файл или директория, POST завершается ошибкой и не заменяет их.
|
||||||
|
|
||||||
|
### PUT: замена содержимого файла
|
||||||
|
|
||||||
|
PUT записывает тело запроса в существующий файл по указанному пути. Например, `PUT /docs/file.txt` полностью заменяет содержимое `/srv/files/docs/file.txt`. Если новые данные короче прежних, остаток прежнего содержимого должен исчезнуть; пустое тело делает файл пустым.
|
||||||
|
|
||||||
|
В этом задании PUT применяется только к существующим файлам. Создавать новый файл или заменять директорию этим методом нельзя.
|
||||||
|
|
||||||
|
### DELETE: удаление файла или директории
|
||||||
|
|
||||||
|
Запрос `DELETE /docs/file.txt` удаляет файл `/srv/files/docs/file.txt`. Для удаления директории клиент должен явно передать `Remove-Directory: True`. Например, `DELETE /docs/archive` с этим заголовком удаляет `/srv/files/docs/archive` вместе со всеми вложенными файлами и директориями. Без разрешающего заголовка сервер должен отказать в удалении директории, даже если она пустая.
|
||||||
|
|
||||||
|
### Коды ответов и общие правила
|
||||||
|
|
||||||
|
В таблице собраны коды ответов для всех перечисленных операций. Там, где указаны два кода, можно выбрать любой из них.
|
||||||
|
|
||||||
|
| Запрос и состояние пути | Действие и ответ |
|
||||||
|
|---|---|
|
||||||
|
| GET существующего файла | Вернуть содержимое файла с кодом 200 |
|
||||||
|
| GET существующей директории | Вернуть список её непосредственных дочерних файлов и директорий с кодом 200 |
|
||||||
|
| GET отсутствующего пути | Вернуть 404 |
|
||||||
|
| POST нового объекта, родительская директория существует | Создать объект и вернуть 200 или 201 |
|
||||||
|
| POST существующего файла или директории | Вернуть 409 |
|
||||||
|
| POST без родительской директории | Вернуть 404, не создавать промежуточные директории |
|
||||||
|
| PUT существующего файла | Полностью заменить содержимое телом запроса и вернуть 200 или 204 |
|
||||||
|
| PUT директории | Вернуть 409 |
|
||||||
|
| PUT отсутствующего пути | Вернуть 404, не создавать файл |
|
||||||
|
| DELETE существующего файла | Удалить файл и вернуть 200 |
|
||||||
|
| DELETE `/` | Вернуть 403: удаление рабочей директории запрещено независимо от заголовка `Remove-Directory` |
|
||||||
|
| DELETE директории с `Remove-Directory: True` | Удалить директорию со всем содержимым и вернуть 200 |
|
||||||
|
| DELETE директории без `Remove-Directory: True` | Вернуть 406 |
|
||||||
|
| DELETE отсутствующего пути | Вернуть 404 |
|
||||||
|
|
||||||
|
Заголовки `Create-Directory` и `Remove-Directory` включают соответствующее действие только со значением `True`. Значение `False` равнозначно отсутствию заголовка; других значений в запросах не будет.
|
||||||
|
|
||||||
|
Для упрощения работы с путями в тестах используются только ASCII-имена из букв, цифр, дефиса, подчёркивания и точки. Компоненты `.` и `..`, параметры после `?`, percent-encoding и символические ссылки исключены. Если один из промежуточных компонентов пути является файлом, такой путь обрабатывается как отсутствующий.
|
||||||
|
|
||||||
|
При ошибке файловая система должна остаться без изменений. Ответ об ошибке содержит непустой понятный текст; точная формулировка не задана. Успешные ответы на POST, PUT и DELETE могут иметь пустое тело. Ответ 204 всегда передаётся без тела.
|
||||||
|
|
||||||
|
## HTTP и заголовки
|
||||||
|
|
||||||
|
Каждое соединение содержит один запрос. Сервер отправляет ответ с `Connection: close` и закрывает соединение. Одновременная обработка нескольких соединений не требуется.
|
||||||
|
|
||||||
|
Запросы синтаксически корректны и используют HTTP/1.1. В них нет повторяющихся заголовков, `Transfer-Encoding` и `Expect`. Длина тела задаётся корректным `Content-Length`; если заголовка нет, тела нет. Имена заголовков сравниваются без учёта регистра. Неизвестные заголовки можно игнорировать.
|
||||||
|
|
||||||
|
TCP передаёт поток байтов: результат одного чтения из сокета может содержать часть заголовка либо конец заголовков вместе с началом тела. Сервер должен работать при любом таком разбиении и не ждать закрытия соединения клиентом, чтобы определить конец запроса. Файлы могут содержать произвольные байты.
|
||||||
|
|
||||||
|
| Заголовок | Требование |
|
||||||
|
|---|---|
|
||||||
|
| `Host` в запросе | В функциональных тестах содержит доменное имя без порта. Сравнивается с `server-domain` без учёта регистра; при несовпадении вернуть 400, не выполняя файловую операцию |
|
||||||
|
| `Content-Length` в ответе | Обязателен, кроме ответа 204. Равен размеру передаваемого тела в байтах; для пустого тела — 0, для gzip — размеру сжатых данных. В ответе 204 этот заголовок запрещён |
|
||||||
|
| `Connection` в ответе | `close` |
|
||||||
|
| `Content-Type` в ответе | Описывает тип содержимого тела. Для непустого тела нужен корректный MIME-тип. Для листинга допустимы `text/plain` и `text/html`. Для любых файлов, включая текстовые, разрешено использовать `application/octet-stream`; определять MIME-тип по расширению не требуется |
|
||||||
|
| `Server` в ответе | Непустое название сервера |
|
||||||
|
| `Accept-Encoding` в запросе | Сообщает, что клиент готов принять сжатые данные. В задании поддерживается только значение `gzip` |
|
||||||
|
| `Content-Encoding` в ответе | Сообщает, каким алгоритмом сжато тело ответа. При сжатии имеет значение `gzip`; в несжатом ответе отсутствует |
|
||||||
|
|
||||||
|
Проверка `Host`, `Content-Type` и `Server` входит в G5 и G7. Коды ответа, длина и границы тела, закрытие соединения и заданное условием содержимое проверяются во всех группах.
|
||||||
|
|
||||||
|
В G7 успешные GET-ответы на запрос с `Accept-Encoding: gzip` должны содержать сжатые данные. Это относится и к файлам, и к листингам. Без этого заголовка ответ не сжимается и не содержит `Content-Encoding`. Сжатие ошибок не требуется. Готовые библиотеки gzip использовать можно. `Transfer-Encoding` в ответах не используется.
|
||||||
|
|
||||||
|
## Компоненты и оценивание
|
||||||
|
|
||||||
|
Автоматические тесты определяют предварительную оценку. Можно реализовать часть задания: каждая группа проверяет только перечисленные для неё возможности. Например, в G1 достаточно чтения существующих текстовых файлов и ответа на служебный GET `/`, а создание и удаление файлов появляются в G4.
|
||||||
|
|
||||||
|
Для получения баллов за группу нужно пройти все её проверки. Группы оцениваются независимо: ошибка в одной группе не лишает баллов за успешно пройденные другие. За все семь групп можно получить 10 баллов.
|
||||||
|
|
||||||
|
| Группа | Баллы | Что проверяется |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | 3 | GET существующих текстовых файлов в ASCII, размером ≤ 8 МБ; настройка и запуск сервера |
|
||||||
|
| G2 | 1 | GET существующих файлов с произвольными байтами, размером ≤ 8 МБ |
|
||||||
|
| G3 | 1 | GET файлов и директорий, ошибки отсутствующих путей; файлы в ASCII, размером ≤ 8 МБ |
|
||||||
|
| G4 | 2 | GET, POST, PUT и DELETE, листинг и ошибки; произвольные файлы размером ≤ 8 МБ |
|
||||||
|
| G5 | 1 | Возможности G4 и проверка `Host`, `Content-Type`, `Server` |
|
||||||
|
| G6 | 1 | Возможности G4 и работа с большими файлами |
|
||||||
|
| G7 | 1 | Возможности G6, дополнительные заголовки из G5 и gzip |
|
||||||
|
|
||||||
|
В G1 и G3 используются текстовые файлы в кодировке ASCII. В остальных группах файлы могут содержать произвольные байты, поэтому их содержимое нельзя считать текстом.
|
||||||
|
|
||||||
|
В G1–G5 размер каждого файла не превышает 8 МБ. Это ограничение действует и на содержимое файлов, передаваемое в запросах POST и PUT. В G6 и G7 файлы могут быть больше 8 МБ. В этом задании 1 МБ = 1024 × 1024 байта.
|
||||||
|
|
||||||
|
В тестирующей системе решение работает в Docker с лимитом памяти **128 МБ**. В G6 и G7 файлы могут превышать объём доступной памяти: нужно уметь и отдавать их клиенту, и принимать при POST и PUT, не загружая целиком в память. Проверить соблюдение лимита можно локально с Docker по инструкции ниже. Запуск без Docker этот лимит не проверяет.
|
||||||
|
|
||||||
|
Для передачи большого файла с gzip и корректным `Content-Length` можно предварительно сжать его во временный файл, определить размер результата и затем передать его клиенту по частям. Само сжатие также должно укладываться в ограничение памяти.
|
||||||
|
|
||||||
|
### Отчёт и защита
|
||||||
|
|
||||||
|
Вместе с кодом сдайте краткий отчёт `solution/readme.md`. Опишите устройство решения и используемые библиотеки, а также объясните следующие механизмы, указав соответствующие файлы и функции в своём коде:
|
||||||
|
|
||||||
|
- как определяются границы заголовков и тела;
|
||||||
|
- как обрабатываются большие файлы и от чего зависит расход памяти, если реализованы G6/G7;
|
||||||
|
- как определяется длина сжатого ответа, если реализована G7.
|
||||||
|
|
||||||
|
Без отчёта автоматические тесты запускаются, но решение не засчитывается по [общим правилам сдачи](../readme.md). Использование LLM укажите согласно [политике курса](../../llm-policy.md).
|
||||||
|
|
||||||
|
На защите нужно объяснить сданную реализацию, разобрать предложенный сценарий и при необходимости внести небольшую правку либо проследить выполнение кода. Ответы подтверждают баллы соответствующих компонентов; прохождение тестов само по себе не гарантирует итоговую оценку.
|
||||||
|
|
||||||
|
Если вы используете заготовку, на защите нужно понимать, как ваш код взаимодействует с ней: как запускается сервер, откуда берутся параметры и как соединение передаётся обработчику запроса.
|
||||||
|
|
||||||
|
## Заготовка и ограничения реализации
|
||||||
|
|
||||||
|
Нельзя использовать готовые библиотеки HTTP или парсеры HTTP-сообщений, в том числе из стандартной библиотеки языка. Можно использовать TCP-сокеты, `socketserver`, библиотеки для файлов, командной строки и gzip.
|
||||||
|
|
||||||
|
В `solution` находится Python-заготовка с настройкой параметров, TCP-сервером, логированием и структурами сообщений. Вам предстоит реализовать чтение и разбор запроса, обработку методов и формирование ответа. Методы в `http_messages.py` предназначены для стартовой строки и заголовков; тело обрабатывается отдельно. Наличие констант других методов не означает, что их нужно поддерживать.
|
||||||
|
|
||||||
|
Можно изменить структуру заготовки или выбрать другой язык; использовать именно её классы необязательно. Решение должно поддерживать описанные параметры командной строки и переменные окружения. В `solution/Dockerfile` должно быть описание сборки образа и запуска вашего сервера. Если вы меняете язык или способ запуска, обновите этот файл.
|
||||||
|
|
||||||
|
Весь код решения, необходимые для сборки файлы и отчёт разместите в папке `solution`. При сдаче отправляется только эта папка; изменения в тестах, `tests/launch.tmpl` и других файлах вне неё в тестирующую систему не попадут.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
Тесты написаны на Go и находятся в папке `tests`; точка входа — [TestHW](./tests/hw_test.go). Во время разработки удобно запускать их без Docker для быстрой проверки после изменений. Перед сдачей проверьте решение и с Docker: этот режим воспроизводит окружение тестирующей системы и ограничение памяти.
|
||||||
|
|
||||||
|
### Как устроены тесты
|
||||||
|
|
||||||
|
Внутри каждой группы есть несколько запусков. Для каждого запуска тестер создаёт набор файлов и директорий, запускает ваш сервер с соответствующей рабочей директорией и отправляет ему последовательность HTTP-запросов. Сервер работает до конца этого запуска, поэтому изменения после POST, PUT и DELETE влияют на последующие запросы. Запуск считается успешным, если сервер правильно обработал все запросы; для получения баллов нужно пройти все запуски группы.
|
||||||
|
|
||||||
|
Наборы файлов и запросов генерируются случайным образом, но воспроизводятся при повторном запуске той же версии тестов. Числовые идентификаторы в журнале позволяют повторить нужную последовательность. Помимо сгенерированных запросов, есть отдельные проверки конкретных случаев, например пустого файла или разбиения запроса между несколькими чтениями TCP-потока. Тесты помогают находить ошибки, но не заменяют проверку соответствия всему условию.
|
||||||
|
|
||||||
|
### Без Docker
|
||||||
|
|
||||||
|
Установите Go. В `tests/launch.tmpl` задана команда, которой тестер запускает сервер. При необходимости измените её для своей системы или языка; в Windows вместо `python3` может потребоваться `python`. Сохраните в шаблоне `{{.CommandLineArgs}}`: на это место тестер подставляет параметры запуска.
|
||||||
|
|
||||||
|
Если используете Python-заготовку, установите её зависимости. Затем запустите тесты следующими командами из папки задания:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m pip install -r solution/requirements.txt
|
||||||
|
cd tests
|
||||||
|
go test
|
||||||
|
```
|
||||||
|
|
||||||
|
Для отдельной группы или запуска используйте фильтр:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -run 'TestHW/G1'
|
||||||
|
go test -run 'TestHW/G1/1337'
|
||||||
|
```
|
||||||
|
|
||||||
|
Имена проверок видны в журнале. Например, `TestHW/G2/93/42300` обозначает группу G2, запуск 93 и запрос 42300. В G1 и G2 запросы только читают файлы, поэтому можно повторить отдельный запрос, указав его полный идентификатор: `go test -run 'TestHW/G2/93/42300'`.
|
||||||
|
|
||||||
|
В группах с POST, PUT и DELETE запросы используют общее изменяемое состояние файлов. Поэтому ошибку в такой группе воспроизводите всем запуском: отдельный запрос может получить другое начальное состояние.
|
||||||
|
|
||||||
|
### С Docker
|
||||||
|
|
||||||
|
Перед первым запуском и после объявления об обновлении тестов загрузите образ:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker pull distsys.ru/course/http-server:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Запускайте из папки задания:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --privileged --rm -v ./solution:/hw/solution distsys.ru/course/http-server:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Для отдельной группы добавьте `-run 'TestHW/G1'` после имени образа. Тестер собирает контейнер вашего сервера и запускает его с лимитом памяти 128 МБ. Общий лимит запуска образа тестов — 10 минут. Скорость зависит от компьютера и Docker-окружения.
|
||||||
|
|
||||||
|
Можно собрать образ сервера самостоятельно и проверить его локальным тестером:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd solution
|
||||||
|
docker build -t hw3img .
|
||||||
|
cd ../tests
|
||||||
|
go test --docker -timeout 10m
|
||||||
|
```
|
||||||
|
|
||||||
|
Для сборки самого образа тестов выполните из папки задания:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t hw3tests ./tests
|
||||||
|
docker run --privileged --rm -v ./solution:/hw/solution hw3tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Как читать результаты
|
||||||
|
|
||||||
|
В журнале видны команда запуска с параметрами и переменными окружения, весь вывод сервера в stdout/stderr и причины ошибок. При отладке начните с первого неудачного запроса: его идентификатор позволяет повторить соответствующий запуск с помощью фильтра `-run`.
|
||||||
|
|
||||||
|
После ошибки оставшиеся запросы этого запуска и оставшиеся запуски группы пропускаются, а за группу начисляется 0 баллов. Тестирование других групп продолжается. В конце выводятся баллы по группам и строка `SCORE: N`. Если использовался фильтр, эта оценка учитывает только выбранные проверки; оценку за всё задание показывает полный прогон.
|
||||||
|
|
||||||
|
После локальной проверки отправьте решение по [общей инструкции](../readme.md).
|
||||||
|
|
||||||
|
### Бонус за пробелы в тестах
|
||||||
|
|
||||||
|
Если найдёте ошибку в тестах или требование, нарушение которого они не обнаруживают, опишите ситуацию в отчёте; при необходимости приложите пример ошибочного решения. За подтверждённую проблему можно получить 1 бонусный балл, а за тест, обнаруживающий её, или описание его логики — ещё 1 балл.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /http/server
|
||||||
|
|
||||||
|
COPY requirements.txt requirements.txt
|
||||||
|
RUN pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENTRYPOINT ["python3", "server.py"]
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import dataclasses
|
||||||
|
import typing as t
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HTTPRequest:
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
version: str
|
||||||
|
parameters: t.Dict[str, str]
|
||||||
|
headers: t.Dict[str, str]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_bytes(data: bytes) -> "HTTPRequest":
|
||||||
|
# data contains the request line and headers, including the final CRLF.
|
||||||
|
# Read the body separately in HTTPHandler; it may exceed available RAM.
|
||||||
|
# Query parameters are not required: parameters can be an empty dict.
|
||||||
|
# TODO: Parse the request line and headers.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HTTPResponse:
|
||||||
|
version: str
|
||||||
|
status: str
|
||||||
|
headers: t.Dict[str, str]
|
||||||
|
|
||||||
|
def to_bytes(self) -> bytes:
|
||||||
|
# Return the status line and headers, ending with an empty line.
|
||||||
|
# HTTPHandler sends the body separately.
|
||||||
|
# TODO: Serialize the response headers.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Common HTTP strings and constants
|
||||||
|
|
||||||
|
|
||||||
|
CR = b'\r'
|
||||||
|
LF = b'\n'
|
||||||
|
CRLF = CR + LF
|
||||||
|
|
||||||
|
HTTP_VERSION = "1.1"
|
||||||
|
|
||||||
|
OPTIONS = 'OPTIONS'
|
||||||
|
GET = 'GET'
|
||||||
|
HEAD = 'HEAD'
|
||||||
|
POST = 'POST'
|
||||||
|
PUT = 'PUT'
|
||||||
|
DELETE = 'DELETE'
|
||||||
|
|
||||||
|
METHODS = [
|
||||||
|
OPTIONS,
|
||||||
|
GET,
|
||||||
|
HEAD,
|
||||||
|
POST,
|
||||||
|
PUT,
|
||||||
|
DELETE,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Only GET, POST, PUT and DELETE are required in this assignment.
|
||||||
|
|
||||||
|
HEADER_HOST = "Host"
|
||||||
|
HEADER_CONTENT_LENGTH = "Content-Length"
|
||||||
|
HEADER_CONTENT_TYPE = "Content-Type"
|
||||||
|
HEADER_CONTENT_ENCODING = "Content-Encoding"
|
||||||
|
HEADER_ACCEPT_ENCODING = "Accept-Encoding"
|
||||||
|
HEADER_CREATE_DIRECTORY = "Create-Directory"
|
||||||
|
HEADER_SERVER = "Server"
|
||||||
|
HEADER_REMOVE_DIRECTORY = "Remove-Directory"
|
||||||
|
|
||||||
|
GZIP = "gzip"
|
||||||
|
|
||||||
|
TEXT_PLAIN = "text/plain"
|
||||||
|
APPLICATION_OCTET_STREAM = "application/octet-stream"
|
||||||
|
APPLICATION_GZIP = "application/gzip"
|
||||||
|
|
||||||
|
OK = "200"
|
||||||
|
BAD_REQUEST = "400"
|
||||||
|
NOT_FOUND = "404"
|
||||||
|
METHOD_NOT_ALLOWED = "405"
|
||||||
|
NOT_ACCEPTABLE = "406"
|
||||||
|
CONFLICT = "409"
|
||||||
|
|
||||||
|
HTTP_REASON_BY_STATUS = {
|
||||||
|
"100": "Continue",
|
||||||
|
"101": "Switching Protocols",
|
||||||
|
"200": "OK",
|
||||||
|
"201": "Created",
|
||||||
|
"202": "Accepted",
|
||||||
|
"203": "Non-Authoritative Information",
|
||||||
|
"204": "No Content",
|
||||||
|
"205": "Reset Content",
|
||||||
|
"206": "Partial Content",
|
||||||
|
"300": "Multiple Choices",
|
||||||
|
"301": "Moved Permanently",
|
||||||
|
"302": "Found",
|
||||||
|
"303": "See Other",
|
||||||
|
"304": "Not Modified",
|
||||||
|
"305": "Use Proxy",
|
||||||
|
"307": "Temporary Redirect",
|
||||||
|
"400": "Bad Request",
|
||||||
|
"401": "Unauthorized",
|
||||||
|
"402": "Payment Required",
|
||||||
|
"403": "Forbidden",
|
||||||
|
"404": "Not Found",
|
||||||
|
"405": "Method Not Allowed",
|
||||||
|
"406": "Not Acceptable",
|
||||||
|
"407": "Proxy Authentication Required",
|
||||||
|
"408": "Request Time-out",
|
||||||
|
"409": "Conflict",
|
||||||
|
"410": "Gone",
|
||||||
|
"411": "Length Required",
|
||||||
|
"412": "Precondition Failed",
|
||||||
|
"413": "Request Entity Too Large",
|
||||||
|
"414": "Request-URI Too Large",
|
||||||
|
"415": "Unsupported Media Type",
|
||||||
|
"416": "Requested range not satisfiable",
|
||||||
|
"417": "Expectation Failed",
|
||||||
|
"500": "Internal Server Error",
|
||||||
|
"501": "Not Implemented",
|
||||||
|
"502": "Bad Gateway",
|
||||||
|
"503": "Service Unavailable",
|
||||||
|
"504": "Gateway Time-out",
|
||||||
|
"505": "HTTP Version not supported",
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
click==8.3.0
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from socketserver import StreamRequestHandler
|
||||||
|
import typing as t
|
||||||
|
import click
|
||||||
|
import socket
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HTTPServer:
|
||||||
|
server_address: t.Tuple[str, int]
|
||||||
|
socket: socket.socket
|
||||||
|
server_domain: str
|
||||||
|
working_directory: pathlib.Path
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPHandler(StreamRequestHandler):
|
||||||
|
server: HTTPServer
|
||||||
|
|
||||||
|
# Use self.rfile and self.wfile to interact with the client
|
||||||
|
# Access domain and working directory with self.server.{attr}
|
||||||
|
def handle(self) -> None:
|
||||||
|
first_line = self.rfile.readline()
|
||||||
|
logger.info(f"Handle connection from {self.client_address}, first_line {first_line}")
|
||||||
|
|
||||||
|
# TODO: Read the remaining headers, handle the request and send a response.
|
||||||
|
# Read the body separately using its Content-Length, not until EOF.
|
||||||
|
# Keep memory bounded when processing large files.
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--host", envvar="SERVER_HOST", default="0.0.0.0", type=str)
|
||||||
|
@click.option("--port", envvar="SERVER_PORT", default=8080, type=int)
|
||||||
|
@click.option("--server-domain", envvar="SERVER_DOMAIN", default="localhost", type=str)
|
||||||
|
@click.option("--working-directory", envvar="SERVER_WORKING_DIRECTORY", type=str)
|
||||||
|
def main(host, port, server_domain, working_directory):
|
||||||
|
if not working_directory:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
working_directory_path = pathlib.Path(working_directory)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Starting server on {host}:{port}, domain {server_domain}, working directory {working_directory}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a server socket
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
|
||||||
|
# Set SO_REUSEADDR option
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
|
||||||
|
# Bind the socket object to the address and port
|
||||||
|
s.bind((host, port))
|
||||||
|
# Start listening for incoming connections
|
||||||
|
s.listen()
|
||||||
|
|
||||||
|
logger.info(f"Listening at {s.getsockname()}")
|
||||||
|
server = HTTPServer((host, port), s, server_domain, working_directory_path)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Accept any new connection (request, client_address)
|
||||||
|
try:
|
||||||
|
conn, addr = s.accept()
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Handle the request
|
||||||
|
HTTPHandler(conn, addr, server)
|
||||||
|
|
||||||
|
# Close the connection
|
||||||
|
conn.shutdown(socket.SHUT_WR)
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
target
|
||||||
|
**/target
|
||||||
|
**/__pycache__
|
||||||
|
**/.pytest_cache
|
||||||
|
*.pyc
|
||||||
|
tmp/
|
||||||
|
tests.log
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
tmp
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM docker:dind
|
||||||
|
|
||||||
|
RUN apk add --no-cache bash ca-certificates go wget
|
||||||
|
|
||||||
|
WORKDIR /hw
|
||||||
|
COPY go.mod go.sum tests/
|
||||||
|
RUN --mount=type=cache,id=distsys-course-go-mod,target=/go/pkg/mod,sharing=locked \
|
||||||
|
cd tests && go mod download
|
||||||
|
COPY . tests
|
||||||
|
RUN --mount=type=cache,id=distsys-course-go-mod,target=/go/pkg/mod,sharing=locked \
|
||||||
|
--mount=type=cache,id=distsys-course-go-build,target=/root/.cache/go-build,sharing=locked \
|
||||||
|
cd tests && go test -c -o /usr/local/bin/hw3test .
|
||||||
|
COPY entrypoint.sh /usr/local/bin/
|
||||||
|
COPY verify-zram-scratch.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/verify-zram-scratch.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["timeout", "-k", "10", "600", "entrypoint.sh"]
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireFileContent requires that the contents of the file match the contents of the reader.
|
||||||
|
func RequireFileContent(t *TC, workdir, p string, f *EnvFile) {
|
||||||
|
fullpath := path.Join(workdir, p)
|
||||||
|
file, err := os.Open(fullpath)
|
||||||
|
require.NoError(t, err, "failed to open file %s, expected it exists on disk", p)
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
stat, err := file.Stat()
|
||||||
|
require.NoError(t, err, "failed to stat file %s", p)
|
||||||
|
require.Equal(t, f.Size, stat.Size(), "file %s has wrong size", p)
|
||||||
|
require.NoError(t, CompareFileContent(t, file, f), "file %s has wrong content", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareFileContent compares first f.Size bytes of the reader with generated content.
|
||||||
|
func CompareFileContent(t *TC, r io.Reader, f *EnvFile) error {
|
||||||
|
actual := make([]byte, 64*1024)
|
||||||
|
expected := make([]byte, len(actual))
|
||||||
|
var generator io.Reader
|
||||||
|
if f.TextOnly {
|
||||||
|
generator = f.Open()
|
||||||
|
} else {
|
||||||
|
generator = newComparisonReader(f.GenSeed)
|
||||||
|
}
|
||||||
|
for offset := int64(0); offset < f.Size; {
|
||||||
|
count := int64(len(actual))
|
||||||
|
if remaining := f.Size - offset; remaining < count {
|
||||||
|
count = remaining
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(r, actual[:count]); err != nil {
|
||||||
|
return fmt.Errorf("unexpected file error at position %d: %w", offset, err)
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(generator, expected[:count]); err != nil {
|
||||||
|
return fmt.Errorf("unexpected generator error at position %d: %w", offset, err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(actual[:count], expected[:count]) {
|
||||||
|
for i := int64(0); i < count; i++ {
|
||||||
|
if actual[i] != expected[i] {
|
||||||
|
return fmt.Errorf("position %d, expected byte %d, got %d", offset+i, expected[i], actual[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += count
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// comparisonReader emits the same bytes as rand.New(source).Read, seven bytes
|
||||||
|
// from each Int63 value. It fills complete seven-byte groups without the
|
||||||
|
// per-byte branch in math/rand.Read. Request bodies still use EnvFile.Open.
|
||||||
|
type comparisonReader struct {
|
||||||
|
source rand.Source
|
||||||
|
value uint64
|
||||||
|
remaining uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
func newComparisonReader(seed int64) *comparisonReader {
|
||||||
|
return &comparisonReader{source: rand.NewSource(seed)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *comparisonReader) Read(p []byte) (int, error) {
|
||||||
|
n := len(p)
|
||||||
|
for len(p) > 0 && r.remaining > 0 {
|
||||||
|
p[0] = byte(r.value)
|
||||||
|
r.value >>= 8
|
||||||
|
r.remaining--
|
||||||
|
p = p[1:]
|
||||||
|
}
|
||||||
|
for len(p) >= 7 {
|
||||||
|
value := uint64(r.source.Int63())
|
||||||
|
p[0] = byte(value)
|
||||||
|
p[1] = byte(value >> 8)
|
||||||
|
p[2] = byte(value >> 16)
|
||||||
|
p[3] = byte(value >> 24)
|
||||||
|
p[4] = byte(value >> 32)
|
||||||
|
p[5] = byte(value >> 40)
|
||||||
|
p[6] = byte(value >> 48)
|
||||||
|
p = p[7:]
|
||||||
|
}
|
||||||
|
if len(p) > 0 {
|
||||||
|
r.value = uint64(r.source.Int63())
|
||||||
|
r.remaining = 7
|
||||||
|
for i := range p {
|
||||||
|
p[i] = byte(r.value)
|
||||||
|
r.value >>= 8
|
||||||
|
r.remaining--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireDir ensures that the directory exists on disk.
|
||||||
|
func RequireDir(t *TC, workdir string, p string, dir *EnvDir) {
|
||||||
|
fullpath := path.Join(workdir, p)
|
||||||
|
stat, err := os.Stat(fullpath)
|
||||||
|
require.NoError(t, err, "failed to stat directory %s, expected it exists on disk", p)
|
||||||
|
require.True(t, stat.IsDir(), "expected %s to be a directory", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireNotExists ensures that the file/directory does not exist on disk.
|
||||||
|
func RequireNotExists(t *TC, workdir string, p string) {
|
||||||
|
fullpath := path.Join(workdir, p)
|
||||||
|
_, err := os.Stat(fullpath)
|
||||||
|
require.True(t, os.IsNotExist(err), "expected %s to not exist on disk", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireExists ensures that the file/directory exists on disk.
|
||||||
|
func RequireExists(t *TC, workdir string, p string) {
|
||||||
|
fullpath := path.Join(workdir, p)
|
||||||
|
_, err := os.Stat(fullpath)
|
||||||
|
require.NoError(t, err, "failed to stat %s, expected it exists on disk", p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComparisonReaderMatchesEnvFile(t *testing.T) {
|
||||||
|
for _, seed := range []int64{-7824, 0, 1, 7824, 9223372036854775807} {
|
||||||
|
for _, chunkSize := range []int{1, 6, 7, 8, 13, 64 * 1024, 131071} {
|
||||||
|
const total = 1024*1024 + 19
|
||||||
|
expected := make([]byte, total)
|
||||||
|
actual := make([]byte, total)
|
||||||
|
_, err := io.ReadFull(rand.New(rand.NewSource(seed)), expected)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reader := newComparisonReader(seed)
|
||||||
|
for offset := 0; offset < total; {
|
||||||
|
end := offset + chunkSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(reader, actual[offset:end]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
offset = end
|
||||||
|
}
|
||||||
|
if !bytes.Equal(actual, expected) {
|
||||||
|
t.Fatalf("different bytes for seed %d and chunk size %d", seed, chunkSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkComparisonReaders(b *testing.B) {
|
||||||
|
const size = 128 * 1024 * 1024
|
||||||
|
buffer := make([]byte, 64*1024)
|
||||||
|
for _, candidate := range []struct {
|
||||||
|
name string
|
||||||
|
open func() io.Reader
|
||||||
|
}{
|
||||||
|
{"math/rand", func() io.Reader { return rand.New(rand.NewSource(7824)) }},
|
||||||
|
{"comparison", func() io.Reader { return newComparisonReader(7824) }},
|
||||||
|
} {
|
||||||
|
b.Run(candidate.name, func(b *testing.B) {
|
||||||
|
b.SetBytes(size)
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
reader := candidate.open()
|
||||||
|
for remaining := size; remaining > 0; remaining -= len(buffer) {
|
||||||
|
if _, err := io.ReadFull(reader, buffer); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contract cases use a small separate tree, so snapshots never scan G6/G7 files.
|
||||||
|
func RunContractTests(t *TC, runner Runner, tmpRoot, group string) {
|
||||||
|
t.RunByName("contract", func(t *TC) {
|
||||||
|
workdir, err := os.MkdirTemp(tmpRoot, "contract-")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer os.RemoveAll(workdir)
|
||||||
|
require.NoError(t, os.Chmod(workdir, 0755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(workdir, "dir", "nested"), 0755))
|
||||||
|
ascii := []byte("hello\r\nworld\n")
|
||||||
|
binary := []byte{0, 255, 13, 10, 13, 10, 128, 1}
|
||||||
|
if group == "G1" || group == "G3" {
|
||||||
|
binary = ascii
|
||||||
|
}
|
||||||
|
for name, body := range map[string][]byte{
|
||||||
|
"alpha.txt": ascii, "binary": binary, "empty": {},
|
||||||
|
".hidden": []byte("hidden"), "dir/nested/child": []byte("child"),
|
||||||
|
} {
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workdir, filepath.FromSlash(name)), body, 0644))
|
||||||
|
}
|
||||||
|
port, err := GetFreePort()
|
||||||
|
require.NoError(t, err)
|
||||||
|
opts := RunOpts{Port: port, WorkingDirectory: workdir, ListenAddr: "0.0.0.0", ServerDomain: "localhost"}
|
||||||
|
opts.GenerateRunConfig(t, rand.New(rand.NewSource(9103)), &EnvGen{AllowEnv: true})
|
||||||
|
stop, err := runner.Run(t, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer stop()
|
||||||
|
require.NoError(t, WaitForServer(t, opts))
|
||||||
|
extra := group == "G5" || group == "G7"
|
||||||
|
|
||||||
|
request := func(method, path, headers string, body []byte) string {
|
||||||
|
return fmt.Sprintf("%s %s HTTP/1.1\r\nhOsT: LOCALHOST\r\ncOnTeNt-LeNgTh: %d\r\n%s\r\n%s", method, path, len(body), headers, body)
|
||||||
|
}
|
||||||
|
run := func(name, raw string, split bool, codes []int, expected []byte, unchanged bool) {
|
||||||
|
t.RunByName(name, func(t *TC) {
|
||||||
|
before, err := snapshotTree(workdir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
parts := [][]byte{[]byte(raw)}
|
||||||
|
if split {
|
||||||
|
// Split inside a header and inside CRLFCRLF; coalesce its end with body bytes.
|
||||||
|
boundary := strings.Index(raw, "\r\n\r\n")
|
||||||
|
parts = [][]byte{[]byte(raw[:9]), []byte(raw[9 : boundary+3]), []byte(raw[boundary+3:])}
|
||||||
|
}
|
||||||
|
resp, body := contractExchange(t, opts, parts)
|
||||||
|
require.Contains(t, codes, resp.StatusCode)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
require.NotEmpty(t, bytes.TrimSpace(body), "error explanation is empty")
|
||||||
|
}
|
||||||
|
if extra {
|
||||||
|
require.NotEmpty(t, resp.Header.Get("Server"))
|
||||||
|
if len(body) > 0 {
|
||||||
|
mediaType, err := parseContentType(resp.Header.Get("Content-Type"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if methodIsDirectoryGet(raw) && resp.StatusCode == 200 {
|
||||||
|
require.Contains(t, []string{"text/plain", "text/html"}, mediaType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if expected != nil {
|
||||||
|
require.Empty(t, resp.Header.Get("Content-Encoding"))
|
||||||
|
require.Equal(t, expected, body)
|
||||||
|
}
|
||||||
|
if unchanged {
|
||||||
|
after, err := snapshotTree(workdir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, compareSnapshots(before, after))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if group == "G4" || group == "G5" || group == "G6" || group == "G7" {
|
||||||
|
run("post-root-file", request("POST", "/", "", binary), false, []int{409}, nil, true)
|
||||||
|
run("post-root-dir", request("POST", "/", "Create-Directory: True\r\n", nil), false, []int{409}, nil, true)
|
||||||
|
run("put-root", request("PUT", "/", "", binary), false, []int{409}, nil, true)
|
||||||
|
for _, value := range []string{"absent", "False", "True"} {
|
||||||
|
header := ""
|
||||||
|
if value != "absent" {
|
||||||
|
header = "Remove-Directory: " + value + "\r\n"
|
||||||
|
}
|
||||||
|
run("delete-root-"+value, request("DELETE", "/", header, nil), false, []int{403}, nil, true)
|
||||||
|
}
|
||||||
|
if extra {
|
||||||
|
raw := strings.Replace(request("DELETE", "/", "Remove-Directory: True\r\n", nil), "LOCALHOST", "wrong.example", 1)
|
||||||
|
run("wrong-host-delete-root", raw, false, []int{400}, nil, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch group {
|
||||||
|
case "G1":
|
||||||
|
run("split-headers", request("GET", "/alpha.txt", "", nil), true, []int{200}, ascii, true)
|
||||||
|
// No Content-Length is also valid for an empty request.
|
||||||
|
run("no-request-body", "GET /alpha.txt HTTP/1.1\r\nHost: localhost\r\n\r\n", false, []int{200}, ascii, true)
|
||||||
|
run("empty-file", request("GET", "/empty", "", nil), false, []int{200}, []byte{}, true)
|
||||||
|
case "G2":
|
||||||
|
run("binary-body", request("GET", "/binary", "", nil), true, []int{200}, binary, true)
|
||||||
|
case "G3":
|
||||||
|
run("missing", request("GET", "/missing", "", nil), false, []int{404}, nil, true)
|
||||||
|
t.RunByName("root-listing", func(t *TC) {
|
||||||
|
resp, body := contractExchange(t, opts, [][]byte{[]byte(request("GET", "/", "", nil))})
|
||||||
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
require.Empty(t, resp.Header.Get("Content-Encoding"))
|
||||||
|
for _, name := range []string{"alpha.txt", "binary", "empty", ".hidden", "dir"} {
|
||||||
|
require.Contains(t, string(body), name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
case "G4", "G5":
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, method, path, headers string
|
||||||
|
code int
|
||||||
|
}{
|
||||||
|
{"post-existing-file", "POST", "/alpha.txt", "", 409},
|
||||||
|
{"post-existing-dir", "POST", "/dir", "Create-Directory: True\r\n", 409},
|
||||||
|
{"post-missing-parent", "POST", "/missing/new", "", 404},
|
||||||
|
{"post-dir-missing-parent", "POST", "/missing/new", "Create-Directory: True\r\n", 404},
|
||||||
|
{"post-file-as-parent", "POST", "/alpha.txt/child", "", 404},
|
||||||
|
{"put-missing", "PUT", "/missing", "", 404},
|
||||||
|
{"put-dir", "PUT", "/dir", "", 409},
|
||||||
|
{"delete-missing", "DELETE", "/missing", "", 404},
|
||||||
|
{"delete-dir-absent", "DELETE", "/dir", "", 406},
|
||||||
|
{"delete-dir-false", "DELETE", "/dir", "rEmOvE-dIrEcToRy: False\r\n", 406},
|
||||||
|
} {
|
||||||
|
body := []byte(nil)
|
||||||
|
if tc.method == "PUT" || (tc.method == "POST" && tc.headers == "") {
|
||||||
|
body = binary
|
||||||
|
}
|
||||||
|
run(tc.name, request(tc.method, tc.path, tc.headers, body), false, []int{tc.code}, nil, true)
|
||||||
|
}
|
||||||
|
run("create-file-false", request("POST", "/new", "cReAtE-dIrEcToRy: False\r\n", binary), true, []int{200, 201}, nil, false)
|
||||||
|
run("read-created", request("GET", "/new", "", nil), false, []int{200}, binary, true)
|
||||||
|
run("create-empty", request("POST", "/new-empty", "", nil), false, []int{200, 201}, nil, false)
|
||||||
|
run("read-empty", request("GET", "/new-empty", "", nil), false, []int{200}, []byte{}, true)
|
||||||
|
run("replace-shorter", request("PUT", "/alpha.txt", "", []byte("x")), true, []int{200, 204}, nil, false)
|
||||||
|
run("read-shorter", request("GET", "/alpha.txt", "", nil), false, []int{200}, []byte("x"), true)
|
||||||
|
run("replace-empty", request("PUT", "/alpha.txt", "", nil), false, []int{200, 204}, nil, false)
|
||||||
|
run("read-replaced-empty", request("GET", "/alpha.txt", "", nil), false, []int{200}, []byte{}, true)
|
||||||
|
run("create-dir", request("POST", "/created-dir", "cReAtE-dIrEcToRy: True\r\n", nil), false, []int{200, 201}, nil, false)
|
||||||
|
t.RunByName("created-dir-on-disk", func(t *TC) {
|
||||||
|
entries, err := os.ReadDir(filepath.Join(workdir, "created-dir"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, entries)
|
||||||
|
})
|
||||||
|
run("recursive-delete", request("DELETE", "/dir", "rEmOvE-dIrEcToRy: True\r\n", nil), false, []int{200}, nil, false)
|
||||||
|
t.RunByName("deleted-tree-on-disk", func(t *TC) {
|
||||||
|
_, err := os.Stat(filepath.Join(workdir, "dir"))
|
||||||
|
require.True(t, os.IsNotExist(err), "directory must be removed")
|
||||||
|
})
|
||||||
|
if extra {
|
||||||
|
for _, method := range []string{"GET", "POST", "PUT", "DELETE"} {
|
||||||
|
raw := strings.Replace(request(method, "/alpha.txt", "", []byte("changed")), "LOCALHOST", "wrong.example", 1)
|
||||||
|
run("wrong-host-"+method, raw, false, []int{400}, nil, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "G7":
|
||||||
|
for _, path := range []string{"/binary", "/dir", "/empty"} {
|
||||||
|
t.RunByName("gzip-"+strings.TrimPrefix(path, "/"), func(t *TC) {
|
||||||
|
before, err := snapshotTree(workdir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, body := contractExchange(t, opts, [][]byte{[]byte(request("GET", path, "aCcEpT-eNcOdInG: gzip\r\n", nil))})
|
||||||
|
require.Equal(t, 200, resp.StatusCode)
|
||||||
|
require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"))
|
||||||
|
require.NotEmpty(t, resp.Header.Get("Server"))
|
||||||
|
mediaType, err := parseContentType(resp.Header.Get("Content-Type"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
gz, err := gzip.NewReader(bytes.NewReader(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
decoded, err := io.ReadAll(gz)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, gz.Close())
|
||||||
|
if path == "/binary" {
|
||||||
|
require.Equal(t, binary, decoded)
|
||||||
|
}
|
||||||
|
if path == "/empty" {
|
||||||
|
require.Empty(t, decoded)
|
||||||
|
}
|
||||||
|
if path == "/dir" {
|
||||||
|
require.Contains(t, string(decoded), "nested")
|
||||||
|
require.Contains(t, []string{"text/plain", "text/html"}, mediaType)
|
||||||
|
}
|
||||||
|
after, err := snapshotTree(workdir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, compareSnapshots(before, after))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func methodIsDirectoryGet(raw string) bool { return strings.HasPrefix(raw, "GET / HTTP/") }
|
||||||
|
|
||||||
|
// The client leaves its sending side open: a server reading to EOF must time out.
|
||||||
|
func contractExchange(t *TC, opts RunOpts, parts [][]byte) (*http.Response, []byte) {
|
||||||
|
req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(bytes.Join(parts, nil))))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer req.Body.Close()
|
||||||
|
address, err := url.Parse(opts.Address())
|
||||||
|
require.NoError(t, err)
|
||||||
|
conn, err := net.DialTimeout("tcp", address.Host, 10*time.Second)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer conn.Close()
|
||||||
|
require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second)))
|
||||||
|
for i, part := range parts {
|
||||||
|
_, err := io.Copy(conn, bytes.NewReader(part))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if i+1 < len(parts) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, body, err := readContractResponse(bufio.NewReader(conn), req.ContentLength > 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return resp, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func readContractResponse(reader *bufio.Reader, requestHasBody bool) (*http.Response, []byte, error) {
|
||||||
|
resp, err := http.ReadResponse(reader, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if err := validateResponseFraming(resp); err != nil {
|
||||||
|
return resp, nil, err
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 204 && int64(len(body)) != resp.ContentLength {
|
||||||
|
return resp, nil, fmt.Errorf("response length mismatch")
|
||||||
|
}
|
||||||
|
if err := checkResponseEnd(reader, requestHasBody); err != nil {
|
||||||
|
return resp, nil, err
|
||||||
|
}
|
||||||
|
return resp, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type treeEntry struct {
|
||||||
|
Mode os.FileMode
|
||||||
|
Size int64
|
||||||
|
Digest [32]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotTree(root string) (map[string]treeEntry, error) {
|
||||||
|
result := make(map[string]treeEntry)
|
||||||
|
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry := treeEntry{Mode: info.Mode()}
|
||||||
|
if info.Mode().IsRegular() {
|
||||||
|
entry.Size = info.Size()
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
_, copyErr := io.Copy(h, f)
|
||||||
|
closeErr := f.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
copy(entry.Digest[:], h.Sum(nil))
|
||||||
|
}
|
||||||
|
result[rel] = entry
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareSnapshots(before, after map[string]treeEntry) error {
|
||||||
|
if !reflect.DeepEqual(before, after) {
|
||||||
|
return fmt.Errorf("request changed the file system unexpectedly")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContractResponseRejectsBadFraming(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, response string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"ok", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\ndata", true},
|
||||||
|
{"short", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 5\r\n\r\ndata", false},
|
||||||
|
{"long", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 3\r\n\r\ndata", false},
|
||||||
|
{"no-close", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", false},
|
||||||
|
{"no-length", "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", false},
|
||||||
|
{"204", "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n", true},
|
||||||
|
{"204-length", "HTTP/1.1 204 No Content\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", false},
|
||||||
|
{"204-body", "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\nx", false},
|
||||||
|
{"chunked", "HTTP/1.1 200 OK\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, _, err := readContractResponse(bufio.NewReader(strings.NewReader(tc.response)), false)
|
||||||
|
if (err == nil) != tc.valid {
|
||||||
|
t.Fatalf("valid=%v, error=%v", tc.valid, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type resetReader struct{}
|
||||||
|
|
||||||
|
func (resetReader) Read([]byte) (int, error) {
|
||||||
|
return 0, &net.OpError{Op: "read", Net: "tcp", Err: errors.New("connection reset")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractResponseAfterEarlyRejection(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, body string
|
||||||
|
requestHasBody bool
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"complete error then reset", "error", true, true},
|
||||||
|
{"reset without request body", "error", false, false},
|
||||||
|
{"truncated error", "err", true, false},
|
||||||
|
{"extra response byte", "error!", true, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
wire := "HTTP/1.1 409 Conflict\r\nConnection: close\r\nContent-Length: 5\r\n\r\n" + tc.body
|
||||||
|
reader := bufio.NewReader(io.MultiReader(strings.NewReader(wire), resetReader{}))
|
||||||
|
_, _, err := readContractResponse(reader, tc.requestHasBody)
|
||||||
|
if (err == nil) != tc.valid {
|
||||||
|
t.Fatalf("valid=%v, error=%v", tc.valid, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotDetectsErrorSideEffects(t *testing.T) {
|
||||||
|
for _, mutation := range []string{"overwrite", "create", "delete", "mkdir"} {
|
||||||
|
t.Run(mutation, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
file := filepath.Join(root, "file")
|
||||||
|
if err := os.WriteFile(file, []byte("before"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err := snapshotTree(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := snapshotTree(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := compareSnapshots(before, after); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch mutation {
|
||||||
|
case "overwrite":
|
||||||
|
err = os.WriteFile(file, []byte("after!"), 0644)
|
||||||
|
case "create":
|
||||||
|
err = os.WriteFile(filepath.Join(root, "new"), nil, 0644)
|
||||||
|
case "delete":
|
||||||
|
err = os.Remove(file)
|
||||||
|
case "mkdir":
|
||||||
|
err = os.Mkdir(filepath.Join(root, "new"), 0755)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err = snapshotTree(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := compareSnapshots(before, after); err == nil {
|
||||||
|
t.Fatal("side effect was accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingPathActionsAndRoot(t *testing.T) {
|
||||||
|
env := &Env{RootDir: &EnvDir{Listing: map[string]EnvNode{"file": &EnvFile{}, "dir": &EnvDir{Listing: map[string]EnvNode{}}}}}
|
||||||
|
opts := &RunOpts{ServerDomain: "localhost"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"POST", "missing/new", 404}, {"POST", "file/new", 404},
|
||||||
|
{"POST", "dir", 409}, {"PUT", "missing", 404}, {"PUT", "dir", 409}, {"DELETE", "missing", 404},
|
||||||
|
{"POST", "/", 409}, {"PUT", "/", 409}, {"DELETE", "/", 403},
|
||||||
|
} {
|
||||||
|
q := Query{Method: tc.method, Path: tc.path, HostHeader: "LOCALHOST"}
|
||||||
|
action, ok := q.Action(env, opts).(HttpErrorAction)
|
||||||
|
if !ok || action.Status != tc.status {
|
||||||
|
t.Errorf("%s %s: got %#v", tc.method, tc.path, q.Action(env, opts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q := Query{Method: "GET", Path: "", HostHeader: "LOCALHOST"}
|
||||||
|
if _, ok := q.Action(env, opts).(GetDirAction); !ok {
|
||||||
|
t.Fatal("root GET is not a listing")
|
||||||
|
}
|
||||||
|
for _, root := range []string{"", "/"} {
|
||||||
|
for _, remove := range []bool{false, true} {
|
||||||
|
q := Query{Method: "DELETE", Path: root, HostHeader: "localhost", RemoveDirectory: remove}
|
||||||
|
action, ok := q.Action(env, opts).(HttpErrorAction)
|
||||||
|
if !ok || action.Status != 403 {
|
||||||
|
t.Fatalf("root DELETE with RemoveDirectory=%v: got %#v", remove, action)
|
||||||
|
}
|
||||||
|
q.HostHeader = "wrong.example"
|
||||||
|
action, ok = q.Action(env, opts).(HttpErrorAction)
|
||||||
|
if !ok || action.Status != 400 {
|
||||||
|
t.Fatalf("Host must be checked before root DELETE: got %#v", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -u
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
fatal() {
|
||||||
|
printf 'harness: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_docker() {
|
||||||
|
attempts=30
|
||||||
|
while [ "$attempts" -gt 0 ]; do
|
||||||
|
docker info >/dev/null 2>&1 && return 0
|
||||||
|
attempts=$((attempts - 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
docker ps -q --filter ancestor=hw3img | while IFS= read -r container_id; do
|
||||||
|
[ -n "$container_id" ] || continue
|
||||||
|
docker rm --force "$container_id" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_tests() {
|
||||||
|
local arg
|
||||||
|
local -a original_args=("$@")
|
||||||
|
local -a test_args=(-docker -test.timeout=10m)
|
||||||
|
while (($#)); do
|
||||||
|
arg="$1"
|
||||||
|
shift
|
||||||
|
case "$arg" in
|
||||||
|
-run|-count|-timeout|-parallel|-skip)
|
||||||
|
if (($# == 0)); then
|
||||||
|
fatal "$arg requires a value"
|
||||||
|
fi
|
||||||
|
test_args+=("-test.${arg#-}" "$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-run=*|-count=*|-timeout=*|-parallel=*|-skip=*)
|
||||||
|
test_args+=("-test.${arg#-}")
|
||||||
|
;;
|
||||||
|
-v|-short|-failfast|-v=*|-short=*|-failfast=*)
|
||||||
|
test_args+=("-test.${arg#-}")
|
||||||
|
;;
|
||||||
|
-test.*|-docker)
|
||||||
|
test_args+=("$arg")
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Keep Go's flag handling for less common test and build options.
|
||||||
|
go test --docker -timeout 10m "${original_args[@]}"
|
||||||
|
return $?
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
/usr/local/bin/hw3test "${test_args[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_score_file() {
|
||||||
|
score_lines="$(grep -c '^SCORE:' "$1" || true)"
|
||||||
|
valid_score_lines="$(grep -Ec '^SCORE: [0-9]+([.][0-9]{1,2})?$' "$1" || true)"
|
||||||
|
[ "$score_lines" -eq 1 ] && [ "$valid_score_lines" -eq 1 ] \
|
||||||
|
|| fatal "test command must report exactly one valid SCORE line"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_registry_proxy() {
|
||||||
|
proxy_url="${DISTSYS_REGISTRY_PROXY_URL:-}"
|
||||||
|
ca_file="/run/distsys-registry-proxy/ca.crt"
|
||||||
|
|
||||||
|
[ -n "$proxy_url" ] || return 1
|
||||||
|
printf '%s' "$proxy_url" | grep -Eq '^http://[A-Za-z0-9.-]+(:[0-9]+)?$' || {
|
||||||
|
printf 'warning: invalid registry proxy URL; using direct Docker pulls\n' >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
[ -f "$ca_file" ] && [ ! -L "$ca_file" ] || {
|
||||||
|
printf 'warning: registry proxy CA is unavailable; using direct Docker pulls\n' >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
downloaded_ca="$(mktemp)" || return 1
|
||||||
|
if ! wget -q -T 5 -O "$downloaded_ca" "${proxy_url}/ca.crt" \
|
||||||
|
|| ! cmp -s "$ca_file" "$downloaded_ca"; then
|
||||||
|
rm -f "$downloaded_ca"
|
||||||
|
printf 'warning: registry proxy is unavailable or its CA differs; using direct Docker pulls\n' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$downloaded_ca"
|
||||||
|
|
||||||
|
cp "$ca_file" /usr/local/share/ca-certificates/distsys-registry-proxy.crt
|
||||||
|
update-ca-certificates >/dev/null
|
||||||
|
proxy_no_proxy='localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'
|
||||||
|
export HTTP_PROXY="$proxy_url" HTTPS_PROXY="$proxy_url" NO_PROXY="$proxy_no_proxy"
|
||||||
|
export http_proxy="$proxy_url" https_proxy="$proxy_url" no_proxy="$proxy_no_proxy"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_registry_proxy || unset HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy
|
||||||
|
|
||||||
|
if [ "${HW3_REQUIRE_ZRAM:-0}" = 1 ]; then
|
||||||
|
/usr/local/bin/verify-zram-scratch.sh || fatal "HW3_REQUIRE_ZRAM=1 but /hw/tests/tmp is not mounted from a zram ext4 device"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start docker daemon
|
||||||
|
trap cleanup EXIT
|
||||||
|
dockerd-entrypoint.sh --storage-driver=overlay2 >/var/log/dockerd-entrypoint.log 2>&1 &
|
||||||
|
if ! wait_for_docker; then
|
||||||
|
tail -n 200 /var/log/dockerd-entrypoint.log >&2 || true
|
||||||
|
fatal "inner Docker daemon did not become ready"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build server image
|
||||||
|
docker build solution -t hw3img || fatal "could not build solution image"
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cd tests
|
||||||
|
export NO_COLOR=1
|
||||||
|
test_status=0
|
||||||
|
run_tests "$@" 2>&1 | tee tests.log || test_status=$?
|
||||||
|
validate_score_file tests.log
|
||||||
|
|
||||||
|
# Go reports a non-zero status when some grading groups fail. A valid score is
|
||||||
|
# the grading result; absence of a score remains an infrastructure failure.
|
||||||
|
if [ "$test_status" -ne 0 ]; then
|
||||||
|
printf 'harness: grading tests exited with status %s after reporting a score\n' \
|
||||||
|
"$test_status" >&2
|
||||||
|
fi
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Env contains information about all files in the test environment.
|
||||||
|
// Can be written to disk.
|
||||||
|
type Env struct {
|
||||||
|
RootDir *EnvDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the node for the given path.
|
||||||
|
func (e *Env) Lookup(p string) (*EnvDir, EnvNode) {
|
||||||
|
p = path.Clean(strings.TrimPrefix(p, "/"))
|
||||||
|
if p == "." {
|
||||||
|
return nil, e.RootDir
|
||||||
|
}
|
||||||
|
dir, file := path.Split(p)
|
||||||
|
|
||||||
|
dirs := strings.Split(dir, "/")
|
||||||
|
dirs = dirs[:len(dirs)-1]
|
||||||
|
|
||||||
|
parent := e.RootDir
|
||||||
|
for _, d := range dirs {
|
||||||
|
nxt, ok := parent.Listing[d]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parent, ok = nxt.(*EnvDir)
|
||||||
|
if !ok {
|
||||||
|
// not a directory
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nxt := parent.Listing[file]
|
||||||
|
return parent, nxt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone returns deep clone of an Env.
|
||||||
|
func (e *Env) Clone() *Env {
|
||||||
|
return &Env{
|
||||||
|
RootDir: e.RootDir.Clone().(*EnvDir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvNode is a file/dir.
|
||||||
|
type EnvNode interface {
|
||||||
|
// WriteToDisk persists node and its children to disk.
|
||||||
|
WriteToDisk(path string) error
|
||||||
|
|
||||||
|
// Stats aggregates stats of the node and its children.
|
||||||
|
Stats(path string, stats *Stats)
|
||||||
|
|
||||||
|
// Clone creates a deep copy of the node.
|
||||||
|
Clone() EnvNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvDir is a virtual directory, that can be written to disk.
|
||||||
|
type EnvDir struct {
|
||||||
|
Listing map[string]EnvNode
|
||||||
|
Depth int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *EnvDir) WriteToDisk(p string) error {
|
||||||
|
err := os.Mkdir(p, 0777)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, writter := range d.Listing {
|
||||||
|
nxt := path.Join(p, name)
|
||||||
|
err := writter.WriteToDisk(nxt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteToDiskSelected keeps the size and directory structure of every initial
|
||||||
|
// file, but only materializes contents for paths that a query may read.
|
||||||
|
func (d *EnvDir) WriteToDiskSelected(p string, needed map[string]bool) error {
|
||||||
|
return d.writeToDiskSelected(p, "", needed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *EnvDir) writeToDiskSelected(p, relative string, needed map[string]bool) error {
|
||||||
|
if err := os.Mkdir(p, 0777); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for name, node := range d.Listing {
|
||||||
|
childPath := filepath.Join(p, name)
|
||||||
|
childRelative := path.Join(relative, name)
|
||||||
|
switch child := node.(type) {
|
||||||
|
case *EnvDir:
|
||||||
|
if err := child.writeToDiskSelected(childPath, childRelative, needed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case *EnvFile:
|
||||||
|
if needed[childRelative] {
|
||||||
|
if err := child.WriteToDisk(childPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err := child.WriteSparseToDisk(childPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported environment node %T", child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *EnvDir) Stats(p string, stats *Stats) {
|
||||||
|
stats.Dirs++
|
||||||
|
stats.DirPaths = append(stats.DirPaths, p)
|
||||||
|
for name, node := range d.Listing {
|
||||||
|
node.Stats(path.Join(p, name), stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *EnvDir) Clone() EnvNode {
|
||||||
|
newDir := &EnvDir{
|
||||||
|
Listing: map[string]EnvNode{},
|
||||||
|
Depth: d.Depth,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, node := range d.Listing {
|
||||||
|
newDir.Listing[name] = node.Clone()
|
||||||
|
}
|
||||||
|
return newDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *EnvDir) CreateDir(name string) (dir *EnvDir, exist bool) {
|
||||||
|
_, exist = d.Listing[name]
|
||||||
|
if exist {
|
||||||
|
return nil, exist
|
||||||
|
}
|
||||||
|
|
||||||
|
newDir := &EnvDir{
|
||||||
|
Listing: map[string]EnvNode{},
|
||||||
|
Depth: d.Depth + 1,
|
||||||
|
}
|
||||||
|
d.Listing[name] = newDir
|
||||||
|
return newDir, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvFile is a virtual file, that can be written to disk.
|
||||||
|
type EnvFile struct {
|
||||||
|
GenSeed int64
|
||||||
|
Size int64
|
||||||
|
TextOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EnvFile) Open() io.Reader {
|
||||||
|
var gen io.Reader = rand.New(rand.NewSource(f.GenSeed))
|
||||||
|
if f.TextOnly {
|
||||||
|
gen = &TextReader{gen}
|
||||||
|
}
|
||||||
|
gen = io.LimitReader(gen, f.Size)
|
||||||
|
return gen
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EnvFile) WriteToDisk(p string) error {
|
||||||
|
file, err := os.OpenFile(p, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
reader := f.Open()
|
||||||
|
_, err = io.CopyBuffer(file, reader, make([]byte, 256*1024))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EnvFile) WriteSparseToDisk(p string) error {
|
||||||
|
file, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return file.Truncate(f.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EnvFile) Stats(p string, stats *Stats) {
|
||||||
|
stats.Files++
|
||||||
|
stats.Size += f.Size
|
||||||
|
stats.FilePaths = append(stats.FilePaths, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *EnvFile) Clone() EnvNode {
|
||||||
|
return &EnvFile{
|
||||||
|
GenSeed: f.GenSeed,
|
||||||
|
Size: f.Size,
|
||||||
|
TextOnly: f.TextOnly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats is a helper for listing all files/dirs in environment.
|
||||||
|
type Stats struct {
|
||||||
|
// Number of directories in the environment.
|
||||||
|
Files int
|
||||||
|
|
||||||
|
// Count of directories in the environment.
|
||||||
|
Dirs int
|
||||||
|
|
||||||
|
// Size of all files in the environment.
|
||||||
|
Size int64
|
||||||
|
|
||||||
|
// FilePaths to all files in the environment.
|
||||||
|
FilePaths []string
|
||||||
|
|
||||||
|
// DirPaths to all directories in the environment.
|
||||||
|
DirPaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize will fix stats to be deterministic and don't contain root dir.
|
||||||
|
func (s *Stats) Normalize() {
|
||||||
|
sort.Strings(s.FilePaths)
|
||||||
|
sort.Strings(s.DirPaths)
|
||||||
|
|
||||||
|
if len(s.DirPaths) > 0 && s.DirPaths[0] == "" {
|
||||||
|
s.DirPaths = s.DirPaths[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import "math/rand"
|
||||||
|
|
||||||
|
// EnvGen contains config for generating test environment.
|
||||||
|
// Test environment is a directory with files and subdirectories.
|
||||||
|
type EnvGen struct {
|
||||||
|
// Generated file tree will not contain subdirectories deeper than this depth.
|
||||||
|
MaxDepth int
|
||||||
|
// Generated file tree will not contain more than this number of subdirs.
|
||||||
|
MaxDirs int
|
||||||
|
// Generated file tree will not contain more than this number of files.
|
||||||
|
MaxFiles int
|
||||||
|
// Generated files will be text files.
|
||||||
|
TextOnly bool
|
||||||
|
// Maximum file size in KB
|
||||||
|
MaxFileSizeKB int
|
||||||
|
// TempDirectory will contain subdirectory for every new environment.
|
||||||
|
TempDirectory string
|
||||||
|
// SparseUnusedFiles avoids writing initial file contents that no query can read.
|
||||||
|
SparseUnusedFiles bool
|
||||||
|
// Allow to use env for configuration.
|
||||||
|
AllowEnv bool
|
||||||
|
// Filename generator.
|
||||||
|
FilenameGen func(r *rand.Rand) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateFile returns file with random contents.
|
||||||
|
func (g *EnvGen) GenerateFile(r *rand.Rand) *EnvFile {
|
||||||
|
return &EnvFile{
|
||||||
|
GenSeed: r.Int63(),
|
||||||
|
Size: r.Int63n(1024 * int64(g.MaxFileSizeKB)),
|
||||||
|
TextOnly: g.TextOnly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate generates whole file tree.
|
||||||
|
func (g *EnvGen) Generate(seed int64) (*Env, error) {
|
||||||
|
r := rand.New(rand.NewSource(seed))
|
||||||
|
root := &EnvDir{Listing: map[string]EnvNode{}}
|
||||||
|
|
||||||
|
dirs := []*EnvDir{root}
|
||||||
|
|
||||||
|
for i := 0; i < g.MaxDirs; i++ {
|
||||||
|
parent := dirs[r.Intn(len(dirs))]
|
||||||
|
if parent.Depth >= g.MaxDepth {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := g.FilenameGen(r)
|
||||||
|
newDir, exist := parent.CreateDir(name)
|
||||||
|
if exist {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dirs = append(dirs, newDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < g.MaxFiles; i++ {
|
||||||
|
parent := dirs[r.Intn(len(dirs))]
|
||||||
|
|
||||||
|
name := g.FilenameGen(r)
|
||||||
|
_, exist := parent.Listing[name]
|
||||||
|
if exist {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.Listing[name] = g.GenerateFile(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Env{
|
||||||
|
RootDir: root,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteToDiskSelected(t *testing.T) {
|
||||||
|
selected := &EnvFile{GenSeed: 42, Size: 4096}
|
||||||
|
unused := &EnvFile{GenSeed: 43, Size: 4096}
|
||||||
|
root := &EnvDir{Listing: map[string]EnvNode{
|
||||||
|
"nested": &EnvDir{Listing: map[string]EnvNode{
|
||||||
|
"selected": selected,
|
||||||
|
"unused": unused,
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
location := filepath.Join(t.TempDir(), "environment")
|
||||||
|
if err := root.WriteToDiskSelected(location, map[string]bool{"nested/selected": true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotSelected, err := os.ReadFile(filepath.Join(location, "nested", "selected"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantSelected, err := io.ReadAll(selected.Open())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(gotSelected, wantSelected) {
|
||||||
|
t.Fatal("selected file contents differ from generated contents")
|
||||||
|
}
|
||||||
|
|
||||||
|
gotUnused, err := os.ReadFile(filepath.Join(location, "nested", "unused"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(gotUnused) != int(unused.Size) || !bytes.Equal(gotUnused, make([]byte, unused.Size)) {
|
||||||
|
t.Fatal("unused file must retain its size without generated contents")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateResponseFraming checks headers before reading the response body.
|
||||||
|
func validateResponseFraming(resp *http.Response) error {
|
||||||
|
// net/http removes Connection: close from Header and records it in Close.
|
||||||
|
// With HTTP/1.1 and explicit framing, Close reflects that header.
|
||||||
|
closeFound := resp.ProtoMajor == 1 && resp.ProtoMinor == 1 && resp.Close
|
||||||
|
for _, value := range resp.Header.Values("Connection") {
|
||||||
|
for _, token := range strings.Split(value, ",") {
|
||||||
|
closeFound = closeFound || strings.EqualFold(strings.TrimSpace(token), "close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !closeFound {
|
||||||
|
return fmt.Errorf("expected Connection: close")
|
||||||
|
}
|
||||||
|
if len(resp.TransferEncoding) != 0 || len(resp.Header.Values("Transfer-Encoding")) != 0 {
|
||||||
|
return fmt.Errorf("Transfer-Encoding is not supported")
|
||||||
|
}
|
||||||
|
lengths := resp.Header.Values("Content-Length")
|
||||||
|
if resp.StatusCode == http.StatusNoContent {
|
||||||
|
if len(lengths) != 0 {
|
||||||
|
return fmt.Errorf("204 must not contain Content-Length")
|
||||||
|
}
|
||||||
|
} else if len(lengths) != 1 || resp.ContentLength < 0 {
|
||||||
|
return fmt.Errorf("expected exactly one valid Content-Length")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound a stalled peer, without imposing a total duration on large transfers.
|
||||||
|
type idleDeadlineReader struct {
|
||||||
|
net.Conn
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *idleDeadlineReader) Read(p []byte) (int, error) {
|
||||||
|
if err := r.Conn.SetReadDeadline(time.Now().Add(r.Timeout)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return r.Conn.Read(p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SimpleFilenameGenerator(maxLen int) func(r *rand.Rand) string {
|
||||||
|
return func(r *rand.Rand) string {
|
||||||
|
return GenFilename(r, maxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenFilename(r *rand.Rand, maxLen int) string {
|
||||||
|
// TODO: better filename generation, right now it's only uppercase english letters
|
||||||
|
n := r.Intn(maxLen) + 1
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
b[i] = byte(r.Intn(26) + 65)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextReader is io.Reader filtering out non-text characters.
|
||||||
|
type TextReader struct {
|
||||||
|
r io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TextReader) Read(p []byte) (n int, err error) {
|
||||||
|
for {
|
||||||
|
n, err = t.r.Read(p)
|
||||||
|
if n == 0 || err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m := 0
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
// p[i] is a plain text character
|
||||||
|
if p[i] == '\n' || (p[i] >= 32 && p[i] <= 126) {
|
||||||
|
p[m] = p[i]
|
||||||
|
m++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module hw3test
|
||||||
|
|
||||||
|
go 1.18
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/stretchr/testify v1.8.0
|
||||||
|
go.uber.org/zap v1.23.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
go.uber.org/atomic v1.10.0 // indirect
|
||||||
|
go.uber.org/multierr v1.8.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||||
|
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||||
|
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
|
||||||
|
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||||
|
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
|
||||||
|
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
useDocker bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
flag.BoolVar(&useDocker, "docker", false, "Run solution in Docker")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHW(tt *testing.T) {
|
||||||
|
if !flag.Parsed() {
|
||||||
|
flag.Parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
t := NewTestContext(tt)
|
||||||
|
Info(t, "Starting tests")
|
||||||
|
|
||||||
|
// loading commandline args template from disk, to run solution with it
|
||||||
|
launchTmpl := os.Getenv("LAUNCH_TMPL")
|
||||||
|
if launchTmpl == "" {
|
||||||
|
launchTmpl = "./launch.tmpl"
|
||||||
|
if useDocker {
|
||||||
|
launchTmpl = "./launch_docker.tmpl"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmplContent, err := os.ReadFile(launchTmpl)
|
||||||
|
require.NoError(t, err, "failed to read template file")
|
||||||
|
launchTemplate := template.Must(template.New("launch").Parse(string(tmplContent)))
|
||||||
|
|
||||||
|
runner := NewCmdRunner(launchTemplate, useDocker)
|
||||||
|
workdir, err := os.Getwd()
|
||||||
|
require.NoError(t, err, "failed to get current directory")
|
||||||
|
tmpRoot := filepath.Join(workdir, "tmp")
|
||||||
|
require.NoError(t, os.MkdirAll(tmpRoot, 0777), "failed to create tmp dir")
|
||||||
|
workspace, err := filepath.EvalSymlinks(workdir)
|
||||||
|
require.NoError(t, err, "failed to resolve test directory")
|
||||||
|
tmpRoot, err = filepath.EvalSymlinks(tmpRoot)
|
||||||
|
require.NoError(t, err, "failed to resolve tmp dir")
|
||||||
|
rel, err := filepath.Rel(workspace, tmpRoot)
|
||||||
|
require.NoError(t, err, "failed to verify tmp dir")
|
||||||
|
require.Equal(t, "tmp", rel, "tmp dir must stay inside the test directory")
|
||||||
|
tmpDir, err := os.MkdirTemp(tmpRoot, "run-")
|
||||||
|
require.NoError(t, err, "failed to create run-specific tmp dir")
|
||||||
|
require.NoError(t, os.Chmod(tmpDir, 0755), "failed to make tmp dir accessible to the server")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
resolved, err := filepath.EvalSymlinks(tmpDir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err, "failed to resolve run-specific tmp dir")
|
||||||
|
rel, err := filepath.Rel(tmpRoot, resolved)
|
||||||
|
require.NoError(t, err, "failed to verify run-specific tmp dir")
|
||||||
|
require.Equal(t, filepath.Base(tmpDir), rel, "run-specific tmp dir must stay inside tmp dir")
|
||||||
|
require.NoError(t, os.RemoveAll(tmpDir), "failed to remove run-specific tmp dir")
|
||||||
|
})
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
|
||||||
|
type RunResult struct {
|
||||||
|
Name string
|
||||||
|
Scored int
|
||||||
|
Max int
|
||||||
|
}
|
||||||
|
runResults := []RunResult{}
|
||||||
|
|
||||||
|
runGroup := func(name string, points int, f func(t *TC)) {
|
||||||
|
t.RunByName(name, func(t *TC) {
|
||||||
|
Info(t, "Starting tests group", zap.Int("points", points))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
r := RunResult{
|
||||||
|
Name: name,
|
||||||
|
Max: points,
|
||||||
|
}
|
||||||
|
|
||||||
|
ok := !t.Failed()
|
||||||
|
if ok {
|
||||||
|
score += points
|
||||||
|
r.Scored = points
|
||||||
|
|
||||||
|
Info(t, "Tests group passed", zap.String("name", name), zap.Int("score", score))
|
||||||
|
} else {
|
||||||
|
Warn(t, "Tests group failed", zap.String("name", name), zap.Int("score", score))
|
||||||
|
}
|
||||||
|
|
||||||
|
runResults = append(runResults, r)
|
||||||
|
})
|
||||||
|
f(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
textEnv := &EnvGen{
|
||||||
|
MaxDepth: 1,
|
||||||
|
MaxDirs: 3,
|
||||||
|
MaxFiles: 5,
|
||||||
|
TextOnly: true,
|
||||||
|
MaxFileSizeKB: 64,
|
||||||
|
TempDirectory: tmpDir,
|
||||||
|
FilenameGen: SimpleFilenameGenerator(8),
|
||||||
|
AllowEnv: false,
|
||||||
|
}
|
||||||
|
binaryEnv := &EnvGen{
|
||||||
|
MaxDepth: 4,
|
||||||
|
MaxDirs: 16,
|
||||||
|
MaxFiles: 25,
|
||||||
|
TextOnly: false,
|
||||||
|
MaxFileSizeKB: 1024,
|
||||||
|
TempDirectory: tmpDir,
|
||||||
|
FilenameGen: SimpleFilenameGenerator(16),
|
||||||
|
AllowEnv: true,
|
||||||
|
}
|
||||||
|
largeEnv := &EnvGen{
|
||||||
|
MaxDepth: 1,
|
||||||
|
MaxDirs: 6,
|
||||||
|
MaxFiles: 6,
|
||||||
|
TextOnly: false,
|
||||||
|
MaxFileSizeKB: 192 * 1024, // 192 MB
|
||||||
|
TempDirectory: tmpDir,
|
||||||
|
SparseUnusedFiles: true,
|
||||||
|
FilenameGen: SimpleFilenameGenerator(16),
|
||||||
|
AllowEnv: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple GET queries for existing text files, 3 points.
|
||||||
|
runGroup("G1", 3, func(t *TC) {
|
||||||
|
RunContractTests(t, runner, tmpDir, "G1")
|
||||||
|
RunTestEmptyWorkDir(t, 42, runner)
|
||||||
|
|
||||||
|
env := textEnv
|
||||||
|
queries := &QueriesGen{
|
||||||
|
Count: 20,
|
||||||
|
GetFile: true,
|
||||||
|
GetFileNoErrors: true,
|
||||||
|
GetDirectory: false,
|
||||||
|
Post: false,
|
||||||
|
Put: false,
|
||||||
|
Delete: false,
|
||||||
|
Compression: false,
|
||||||
|
AllHeaders: false,
|
||||||
|
}
|
||||||
|
RunTests(t, 1337, runner, env, queries)
|
||||||
|
RunTests(t, 1338, runner, env, queries)
|
||||||
|
RunTests(t, 1339, runner, env, queries)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simple GET queries for existing binary files, 1 point.
|
||||||
|
runGroup("G2", 1, func(t *TC) {
|
||||||
|
RunContractTests(t, runner, tmpDir, "G2")
|
||||||
|
env := binaryEnv
|
||||||
|
queries := &QueriesGen{
|
||||||
|
Count: 40,
|
||||||
|
GetFile: true,
|
||||||
|
GetFileNoErrors: true,
|
||||||
|
GetDirectory: false,
|
||||||
|
Post: false,
|
||||||
|
Put: false,
|
||||||
|
Delete: false,
|
||||||
|
Compression: false,
|
||||||
|
AllHeaders: false,
|
||||||
|
}
|
||||||
|
RunTests(t, 93, runner, env, queries)
|
||||||
|
RunTests(t, 2945, runner, env, queries)
|
||||||
|
RunTests(t, 3110, runner, env, queries)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Any GET queries, 1 point.
|
||||||
|
runGroup("G3", 1, func(t *TC) {
|
||||||
|
RunContractTests(t, runner, tmpDir, "G3")
|
||||||
|
env := textEnv
|
||||||
|
queries := &QueriesGen{
|
||||||
|
Count: 30,
|
||||||
|
GetFile: true,
|
||||||
|
GetFileNoErrors: false,
|
||||||
|
GetDirectory: true,
|
||||||
|
Post: false,
|
||||||
|
Put: false,
|
||||||
|
Delete: false,
|
||||||
|
Compression: false,
|
||||||
|
AllHeaders: false,
|
||||||
|
}
|
||||||
|
RunTests(t, 5311, runner, env, queries)
|
||||||
|
RunTests(t, 2863, runner, env, queries)
|
||||||
|
RunTests(t, 6712, runner, env, queries)
|
||||||
|
RunTests(t, 7233, runner, env, queries)
|
||||||
|
RunTests(t, 7067, runner, env, queries)
|
||||||
|
RunTests(t, 3930, runner, env, queries)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simple file server, 2 points.
|
||||||
|
runGroup("G4", 2, func(t *TC) {
|
||||||
|
RunContractTests(t, runner, tmpDir, "G4")
|
||||||
|
env := binaryEnv
|
||||||
|
queries := &QueriesGen{
|
||||||
|
Count: 30,
|
||||||
|
GetFile: true,
|
||||||
|
GetDirectory: true,
|
||||||
|
Post: true,
|
||||||
|
Put: true,
|
||||||
|
Delete: true,
|
||||||
|
Compression: false,
|
||||||
|
AllHeaders: false,
|
||||||
|
}
|
||||||
|
RunTests(t, 3152, runner, env, queries)
|
||||||
|
RunTests(t, 2929, runner, env, queries)
|
||||||
|
RunTests(t, 6554, runner, env, queries)
|
||||||
|
RunTests(t, 1388, runner, env, queries)
|
||||||
|
RunTests(t, 1672, runner, env, queries)
|
||||||
|
RunTests(t, 1769, runner, env, queries)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extra headers, 1 point.
|
||||||
|
runGroup("G5", 1, func(t *TC) {
|
||||||
|
RunContractTests(t, runner, tmpDir, "G5")
|
||||||
|
env := binaryEnv
|
||||||
|
queries := &QueriesGen{
|
||||||
|
Count: 30,
|
||||||
|
GetFile: true,
|
||||||
|
GetDirectory: true,
|
||||||
|
Post: true,
|
||||||
|
Put: true,
|
||||||
|
Delete: true,
|
||||||
|
Compression: false,
|
||||||
|
AllHeaders: true,
|
||||||
|
}
|
||||||
|
RunTests(t, 7942, runner, env, queries)
|
||||||
|
RunTests(t, 1479, runner, env, queries)
|
||||||
|
RunTests(t, 3324, runner, env, queries)
|
||||||
|
RunTests(t, 6519, runner, env, queries)
|
||||||
|
RunTests(t, 3746, runner, env, queries)
|
||||||
|
RunTests(t, 1961, runner, env, queries)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Large files, 1 point.
|
||||||
|
runGroup("G6", 1, func(t *TC) {
|
||||||
|
RunContractTests(t, runner, tmpDir, "G6")
|
||||||
|
env := largeEnv
|
||||||
|
queries := &QueriesGen{
|
||||||
|
Count: 25,
|
||||||
|
GetFile: true,
|
||||||
|
GetDirectory: true,
|
||||||
|
LargePuts: &LargePutPolicy{
|
||||||
|
RunSeed: 3793,
|
||||||
|
LargeSizes: []int64{160 << 20, 191 << 20},
|
||||||
|
OtherMaxBytes: 8 << 20,
|
||||||
|
},
|
||||||
|
Post: true,
|
||||||
|
Put: true,
|
||||||
|
Delete: true,
|
||||||
|
Compression: false,
|
||||||
|
AllHeaders: false,
|
||||||
|
}
|
||||||
|
RunTests(t, 7824, runner, env, queries)
|
||||||
|
RunTests(t, 1671, runner, env, queries)
|
||||||
|
RunTests(t, 3793, runner, env, queries)
|
||||||
|
RunTests(t, 272, runner, env, queries)
|
||||||
|
RunTests(t, 2715, runner, env, queries)
|
||||||
|
RunTests(t, 1436, runner, env, queries)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Large files and gzip, 1 point.
|
||||||
|
runGroup("G7", 1, func(t *TC) {
|
||||||
|
RunContractTests(t, runner, tmpDir, "G7")
|
||||||
|
env := largeEnv
|
||||||
|
queries := &QueriesGen{
|
||||||
|
Count: 30,
|
||||||
|
GetFile: true,
|
||||||
|
GetDirectory: true,
|
||||||
|
LargePuts: &LargePutPolicy{
|
||||||
|
RunSeed: 3224,
|
||||||
|
LargeSizes: []int64{160 << 20, 191 << 20},
|
||||||
|
OtherMaxBytes: 8 << 20,
|
||||||
|
},
|
||||||
|
Post: true,
|
||||||
|
Put: true,
|
||||||
|
Delete: true,
|
||||||
|
Compression: true,
|
||||||
|
AllHeaders: true,
|
||||||
|
}
|
||||||
|
RunTests(t, 3224, runner, env, queries)
|
||||||
|
RunTests(t, 7507, runner, env, queries)
|
||||||
|
RunTests(t, 4172, runner, env, queries)
|
||||||
|
RunTests(t, 7777, runner, env, queries)
|
||||||
|
RunTests(t, 6666, runner, env, queries)
|
||||||
|
RunTests(t, 6094, runner, env, queries)
|
||||||
|
RunTests(t, 6442, runner, env, queries)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, r := range runResults {
|
||||||
|
Info(t, fmt.Sprintf("Score for group [%s]: %d / %d", r.Name, r.Scored, r.Max))
|
||||||
|
}
|
||||||
|
|
||||||
|
Info(t, "Tests finished", zap.Int("score", score))
|
||||||
|
fmt.Println("==================================================================================")
|
||||||
|
fmt.Printf("SCORE: %d\n", score)
|
||||||
|
fmt.Println("==================================================================================")
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLargePutPlansKeepBoundaryWritesAndQueries(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
runSeeds []int64
|
||||||
|
count int
|
||||||
|
largeSeed int64
|
||||||
|
allHeaders bool
|
||||||
|
compression bool
|
||||||
|
}{
|
||||||
|
{"G6", []int64{7824, 1671, 3793, 272, 2715, 1436}, 25, 3793, false, false},
|
||||||
|
{"G7", []int64{3224, 7507, 4172, 7777, 6666, 6094, 6442}, 30, 3224, true, true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
context := NewTestContext(t)
|
||||||
|
envGen := &EnvGen{
|
||||||
|
MaxDepth: 1,
|
||||||
|
MaxDirs: 6,
|
||||||
|
MaxFiles: 6,
|
||||||
|
MaxFileSizeKB: 192 * 1024,
|
||||||
|
FilenameGen: SimpleFilenameGenerator(16),
|
||||||
|
}
|
||||||
|
policy := &LargePutPolicy{
|
||||||
|
RunSeed: test.largeSeed,
|
||||||
|
LargeSizes: []int64{160 << 20, 191 << 20},
|
||||||
|
OtherMaxBytes: 8 << 20,
|
||||||
|
}
|
||||||
|
options := RunOpts{ServerDomain: "localhost"}
|
||||||
|
largePosts := 0
|
||||||
|
largeGets := 0
|
||||||
|
largeGzipGets := 0
|
||||||
|
for _, runSeed := range test.runSeeds {
|
||||||
|
env, err := envGen.Generate(runSeed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := rand.New(rand.NewSource(runSeed))
|
||||||
|
seeds := make([]int64, test.count)
|
||||||
|
for i := range seeds {
|
||||||
|
seeds[i] = r.Int63n(100000)
|
||||||
|
}
|
||||||
|
generator := &QueriesGen{
|
||||||
|
Count: test.count, GetFile: true, GetDirectory: true,
|
||||||
|
Post: true, Put: true, Delete: true,
|
||||||
|
Compression: test.compression, AllHeaders: test.allHeaders,
|
||||||
|
LargePuts: policy,
|
||||||
|
}
|
||||||
|
queries := generator.Generate(context, env, envGen, seeds, options, runSeed)
|
||||||
|
generator.LargePuts = nil
|
||||||
|
baseline := generator.Generate(context, env, envGen, seeds, options, runSeed)
|
||||||
|
if len(queries) != len(baseline) {
|
||||||
|
t.Fatalf("run %d changed query count", runSeed)
|
||||||
|
}
|
||||||
|
state := env.Clone()
|
||||||
|
var largePutSizes []int64
|
||||||
|
for i, query := range queries {
|
||||||
|
if query.Method == "PUT" {
|
||||||
|
baseline[i].FileContent.Size = query.FileContent.Size
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(query, baseline[i]) {
|
||||||
|
t.Fatalf("run %d changed query %d beyond PUT body size", runSeed, i)
|
||||||
|
}
|
||||||
|
action := query.Action(state, &options)
|
||||||
|
if query.Path == "" && query.Method != "GET" {
|
||||||
|
t.Fatal("generated a modifying request to the working directory")
|
||||||
|
}
|
||||||
|
if get, ok := action.(GetFileAction); ok && get.File.Size > 128<<20 {
|
||||||
|
largeGets++
|
||||||
|
if get.Compression {
|
||||||
|
largeGzipGets++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if query.Method == "PUT" {
|
||||||
|
if query.FileContent.Size > 128<<20 {
|
||||||
|
if _, ok := action.(ReplaceFileAction); !ok {
|
||||||
|
t.Fatalf("run %d has a large PUT that cannot update a file", runSeed)
|
||||||
|
}
|
||||||
|
largePutSizes = append(largePutSizes, query.FileContent.Size)
|
||||||
|
} else if query.FileContent.Size > policy.OtherMaxBytes {
|
||||||
|
t.Fatalf("run %d has an unbounded PUT", runSeed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if query.Method == "POST" && query.FileContent != nil && query.FileContent.Size > 128<<20 {
|
||||||
|
if _, ok := action.(CreateFileAction); ok {
|
||||||
|
largePosts++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action != nil {
|
||||||
|
action.ApplyEnv(context, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runSeed == test.largeSeed {
|
||||||
|
if !reflect.DeepEqual(largePutSizes, policy.LargeSizes) {
|
||||||
|
t.Fatalf("run %d large PUT sizes: got %v, want %v", runSeed, largePutSizes, policy.LargeSizes)
|
||||||
|
}
|
||||||
|
} else if len(largePutSizes) != 0 {
|
||||||
|
t.Fatalf("run %d has unexpected large PUTs", runSeed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if largePosts == 0 {
|
||||||
|
t.Fatal("no successful POST larger than the solution memory limit")
|
||||||
|
}
|
||||||
|
if largeGets == 0 {
|
||||||
|
t.Fatal("no successful GET larger than the solution memory limit")
|
||||||
|
}
|
||||||
|
if test.compression && largeGzipGets == 0 {
|
||||||
|
t.Fatal("no gzip GET larger than the solution memory limit")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{{- /*gotype: hw3test.RunOpts */ -}}
|
||||||
|
python3 ../solution/server.py {{.CommandLineArgs}}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{{- /*gotype: hw3test.RunOpts */ -}}
|
||||||
|
docker run --memory=128m --memory-swap=128m --memory-swappiness=0 --rm {{.DockerVolumeArgs}} {{.DockerPortArgs}} {{.DockerEnvArgs}} hw3img {{.DockerCommandLineArgs}}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func logLevelFromEnv() zap.AtomicLevel {
|
||||||
|
levelStr := os.Getenv("LOG_LEVEL")
|
||||||
|
if levelStr == "" {
|
||||||
|
return zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
level, err := zap.ParseAtomicLevel(levelStr)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("failed to parse log level: %w", err))
|
||||||
|
}
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
var global *zap.Logger
|
||||||
|
var globalOnce sync.Once
|
||||||
|
|
||||||
|
func Global() *zap.Logger {
|
||||||
|
if global == nil {
|
||||||
|
globalOnce.Do(func() {
|
||||||
|
encoder := zapcore.EncoderConfig{
|
||||||
|
// Keys can be anything except the empty string.
|
||||||
|
TimeKey: "",
|
||||||
|
LevelKey: "L",
|
||||||
|
NameKey: "_",
|
||||||
|
CallerKey: "C",
|
||||||
|
FunctionKey: "F",
|
||||||
|
MessageKey: "M",
|
||||||
|
StacktraceKey: "S",
|
||||||
|
LineEnding: zapcore.DefaultLineEnding,
|
||||||
|
EncodeLevel: zapcore.CapitalColorLevelEncoder,
|
||||||
|
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||||||
|
EncodeDuration: zapcore.StringDurationEncoder,
|
||||||
|
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||||
|
}
|
||||||
|
cfg := zap.Config{
|
||||||
|
Level: logLevelFromEnv(),
|
||||||
|
Development: true,
|
||||||
|
DisableCaller: true,
|
||||||
|
DisableStacktrace: true,
|
||||||
|
Encoding: "console",
|
||||||
|
EncoderConfig: encoder,
|
||||||
|
OutputPaths: []string{"stderr"},
|
||||||
|
ErrorOutputPaths: []string{"stderr"},
|
||||||
|
}
|
||||||
|
options := []zap.Option{
|
||||||
|
zap.AddCallerSkip(1),
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
global, err = cfg.Build(options...)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("failed to initialize global logger: %w", err))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return global
|
||||||
|
}
|
||||||
|
|
||||||
|
func Logger(t *TC) *zap.Logger {
|
||||||
|
logger := Global()
|
||||||
|
logger = logger.Named(t.Name())
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func Debug(t *TC, msg string, args ...zap.Field) {
|
||||||
|
Logger(t).Debug(msg, args...)
|
||||||
|
_ = Logger(t).Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Info(t *TC, msg string, args ...zap.Field) {
|
||||||
|
Logger(t).Info(msg, args...)
|
||||||
|
_ = Logger(t).Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Warn(t *TC, msg string, args ...zap.Field) {
|
||||||
|
Logger(t).Warn(msg, args...)
|
||||||
|
_ = Logger(t).Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(t *TC, msg string, args ...zap.Field) {
|
||||||
|
Logger(t).Error(msg, args...)
|
||||||
|
_ = Logger(t).Sync()
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseContentType(value string) (string, error) {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !strings.Contains(mediaType, "/") {
|
||||||
|
return "", fmt.Errorf("content type %q has no subtype", value)
|
||||||
|
}
|
||||||
|
return mediaType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query represents a query to the solution server.
|
||||||
|
type Query struct {
|
||||||
|
// Seed is used as a query ID.
|
||||||
|
Seed int64
|
||||||
|
|
||||||
|
// Method of a request, e.g. "GET", "POST", "PUT", "DELETE".
|
||||||
|
Method string
|
||||||
|
|
||||||
|
// Simple file path, splitted by slashes. E.g. "foo/bar/baz".
|
||||||
|
Path string
|
||||||
|
|
||||||
|
// If true, will use `Accept-Encoding` for GET query.
|
||||||
|
Gzip bool
|
||||||
|
|
||||||
|
// If true, will pass `Create-Directory: True` in POST query.
|
||||||
|
CreateDirectory bool
|
||||||
|
|
||||||
|
// If true, will pass `Remove-Directory: True` in DELETE query.
|
||||||
|
RemoveDirectory bool
|
||||||
|
|
||||||
|
// Pass the Host header.
|
||||||
|
HostHeader string
|
||||||
|
|
||||||
|
// Verify all server headers: `Content-Length`, `Content-Type`, `Server`.
|
||||||
|
VerifyHeaders bool
|
||||||
|
|
||||||
|
// FileContent when creating a file.
|
||||||
|
FileContent *EnvFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Query) CreateRequest(t *TC, queryURL string) *http.Request {
|
||||||
|
var body io.Reader
|
||||||
|
if q.FileContent != nil {
|
||||||
|
body = q.FileContent.Open()
|
||||||
|
if q.FileContent.Size == 0 {
|
||||||
|
body = http.NoBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(q.Method, queryURL, body)
|
||||||
|
require.NoError(t, err, "failed to create go request")
|
||||||
|
|
||||||
|
if q.FileContent != nil {
|
||||||
|
req.ContentLength = q.FileContent.Size
|
||||||
|
}
|
||||||
|
if q.CreateDirectory {
|
||||||
|
req.Header.Set("Create-Directory", "True")
|
||||||
|
}
|
||||||
|
if q.RemoveDirectory {
|
||||||
|
req.Header.Set("Remove-Directory", "True")
|
||||||
|
}
|
||||||
|
if q.HostHeader != "" {
|
||||||
|
req.Host = q.HostHeader
|
||||||
|
}
|
||||||
|
if q.Gzip {
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
}
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Query) CommonValidate(t *TC, r *http.Request, resp *http.Response) {
|
||||||
|
require.NoError(t, validateResponseFraming(resp))
|
||||||
|
if q.VerifyHeaders {
|
||||||
|
require.NotEmpty(t, resp.Header.Get("Server"), "missing Server header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action interface {
|
||||||
|
// VerifyBefore verifies file content on disk before sending query to the server.
|
||||||
|
VerifyBefore(t *TC, workdir string)
|
||||||
|
|
||||||
|
// VerifyAfter verifies file content on disk after sending query to the server.
|
||||||
|
VerifyAfter(t *TC, workdir string)
|
||||||
|
|
||||||
|
// ApplyEnv applies the action to the environment.
|
||||||
|
ApplyEnv(t *TC, env *Env) (changed bool)
|
||||||
|
|
||||||
|
// VerifyResponse verifies the response from the server.
|
||||||
|
VerifyResponse(t *TC, r *http.Request, resp *http.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HttpErrorAction is an Action, which expect specific status code in response.
|
||||||
|
type HttpErrorAction struct {
|
||||||
|
Status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HttpErrorAction) VerifyBefore(t *TC, workdir string) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HttpErrorAction) VerifyAfter(t *TC, workdir string) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HttpErrorAction) ApplyEnv(t *TC, env *Env) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h HttpErrorAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||||
|
require.Equal(t, h.Status, resp.StatusCode, "expected http status code")
|
||||||
|
require.Positive(t, resp.ContentLength, "error response must contain an explanation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileAction is an Action, which returns file content in response.
|
||||||
|
type GetFileAction struct {
|
||||||
|
Path string
|
||||||
|
File *EnvFile
|
||||||
|
Compression bool
|
||||||
|
VerifyHeaders bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GetFileAction) VerifyBefore(t *TC, workdir string) {
|
||||||
|
RequireFileContent(t, workdir, g.Path, g.File)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GetFileAction) VerifyAfter(t *TC, workdir string) {
|
||||||
|
RequireFileContent(t, workdir, g.Path, g.File)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GetFileAction) ApplyEnv(t *TC, env *Env) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GetFileAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||||
|
require.Equal(t, 200, resp.StatusCode, "expected OK")
|
||||||
|
var body io.Reader = resp.Body
|
||||||
|
if g.Compression {
|
||||||
|
require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"), "expected gzip response")
|
||||||
|
gz, err := gzip.NewReader(resp.Body)
|
||||||
|
require.NoError(t, err, "expected valid gzip response")
|
||||||
|
defer gz.Close()
|
||||||
|
body = gz
|
||||||
|
} else {
|
||||||
|
require.Empty(t, resp.Header.Get("Content-Encoding"), "unexpected content encoding")
|
||||||
|
require.Equal(t, g.File.Size, resp.ContentLength, "expected content length")
|
||||||
|
}
|
||||||
|
if g.VerifyHeaders && g.File.Size > 0 {
|
||||||
|
_, err := parseContentType(resp.Header.Get("Content-Type"))
|
||||||
|
require.NoError(t, err, "expected valid content type")
|
||||||
|
}
|
||||||
|
require.NoError(t, CompareFileContent(t, body, g.File), "file content mismatch")
|
||||||
|
remaining, err := io.Copy(io.Discard, body)
|
||||||
|
require.NoError(t, err, "failed to read remaining response body")
|
||||||
|
require.Zero(t, remaining, "response contains extra file content")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDirAction is an Action, which returns directory listing in response.
|
||||||
|
type GetDirAction struct {
|
||||||
|
Path string
|
||||||
|
Dir *EnvDir
|
||||||
|
Compression bool
|
||||||
|
VerifyHeaders bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GetDirAction) VerifyBefore(t *TC, workdir string) {
|
||||||
|
RequireDir(t, workdir, g.Path, g.Dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GetDirAction) VerifyAfter(t *TC, workdir string) {
|
||||||
|
RequireDir(t, workdir, g.Path, g.Dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GetDirAction) ApplyEnv(t *TC, env *Env) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g GetDirAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||||
|
require.Equal(t, 200, resp.StatusCode, "expected OK")
|
||||||
|
var body io.Reader = resp.Body
|
||||||
|
if g.Compression {
|
||||||
|
require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"), "expected gzip response")
|
||||||
|
gz, err := gzip.NewReader(resp.Body)
|
||||||
|
require.NoError(t, err, "expected valid gzip response")
|
||||||
|
defer gz.Close()
|
||||||
|
body = gz
|
||||||
|
} else {
|
||||||
|
require.Empty(t, resp.Header.Get("Content-Encoding"), "unexpected content encoding")
|
||||||
|
}
|
||||||
|
content, err := io.ReadAll(body)
|
||||||
|
require.NoError(t, err, "expected response to be read without errors")
|
||||||
|
if g.VerifyHeaders && len(content) > 0 {
|
||||||
|
mediaType, err := parseContentType(resp.Header.Get("Content-Type"))
|
||||||
|
require.NoError(t, err, "expected valid content type")
|
||||||
|
require.Contains(t, []string{"text/plain", "text/html"}, mediaType, "expected directory listing content type")
|
||||||
|
}
|
||||||
|
|
||||||
|
strlist := string(content)
|
||||||
|
for name := range g.Dir.Listing {
|
||||||
|
require.Contains(t, strlist, name, "expected dir listing to contain child %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDirAction is an Action, which creates a directory.
|
||||||
|
type CreateDirAction struct {
|
||||||
|
Path string
|
||||||
|
Name string
|
||||||
|
Parent *EnvDir
|
||||||
|
PathOnDisk string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CreateDirAction) VerifyBefore(t *TC, workdir string) {
|
||||||
|
RequireNotExists(t, workdir, c.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CreateDirAction) VerifyAfter(t *TC, workdir string) {
|
||||||
|
RequireDir(t, workdir, c.Path, c.Parent)
|
||||||
|
entries, err := os.ReadDir(c.PathOnDisk)
|
||||||
|
require.NoError(t, err, "expected to read dir %s without errors", c.PathOnDisk)
|
||||||
|
require.Empty(t, entries, "expected dir %s to be empty", c.PathOnDisk)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CreateDirAction) ApplyEnv(t *TC, env *Env) bool {
|
||||||
|
_, exist := c.Parent.CreateDir(c.Name)
|
||||||
|
require.False(t, exist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CreateDirAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, resp.StatusCode, "expected successful creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFileAction is an Action, which creates a file.
|
||||||
|
type CreateFileAction struct {
|
||||||
|
Path string
|
||||||
|
Name string
|
||||||
|
Parent *EnvDir
|
||||||
|
Content *EnvFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CreateFileAction) VerifyBefore(t *TC, workdir string) {
|
||||||
|
RequireNotExists(t, workdir, c.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CreateFileAction) VerifyAfter(t *TC, workdir string) {
|
||||||
|
RequireFileContent(t, workdir, c.Path, c.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CreateFileAction) ApplyEnv(t *TC, env *Env) bool {
|
||||||
|
c.Parent.Listing[c.Name] = c.Content
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CreateFileAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, resp.StatusCode, "expected successful creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceFileAction is an Action, which replaces file content.
|
||||||
|
type ReplaceFileAction struct {
|
||||||
|
Path string
|
||||||
|
Name string
|
||||||
|
Parent *EnvDir
|
||||||
|
OldContent *EnvFile
|
||||||
|
NewContent *EnvFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ReplaceFileAction) VerifyBefore(t *TC, workdir string) {
|
||||||
|
RequireFileContent(t, workdir, r.Path, r.OldContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ReplaceFileAction) VerifyAfter(t *TC, workdir string) {
|
||||||
|
RequireFileContent(t, workdir, r.Path, r.NewContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ReplaceFileAction) ApplyEnv(t *TC, env *Env) bool {
|
||||||
|
r.Parent.Listing[r.Name] = r.NewContent
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ReplaceFileAction) VerifyResponse(t *TC, req *http.Request, resp *http.Response) {
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusNoContent}, resp.StatusCode, "expected successful update")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAction is an Action, which deletes a file or directory.
|
||||||
|
type DeleteAction struct {
|
||||||
|
Path string
|
||||||
|
Parent *EnvDir
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DeleteAction) VerifyBefore(t *TC, workdir string) {
|
||||||
|
RequireExists(t, workdir, d.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DeleteAction) VerifyAfter(t *TC, workdir string) {
|
||||||
|
RequireNotExists(t, workdir, d.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DeleteAction) ApplyEnv(t *TC, env *Env) bool {
|
||||||
|
delete(d.Parent.Listing, d.Name)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DeleteAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) {
|
||||||
|
require.Equal(t, 200, resp.StatusCode, "expected OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action returns action for the query, or nil if query shouldn't do anything.
|
||||||
|
func (q *Query) Action(env *Env, opts *RunOpts) Action {
|
||||||
|
if !strings.EqualFold(q.HostHeader, opts.ServerDomain) {
|
||||||
|
return HttpErrorAction{
|
||||||
|
Status: 400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent, child := env.Lookup(q.Path)
|
||||||
|
|
||||||
|
switch q.Method {
|
||||||
|
case "GET":
|
||||||
|
if child == nil {
|
||||||
|
return HttpErrorAction{
|
||||||
|
Status: 404,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f, ok := child.(*EnvFile); ok {
|
||||||
|
return GetFileAction{
|
||||||
|
Path: q.Path,
|
||||||
|
File: f,
|
||||||
|
Compression: q.Gzip,
|
||||||
|
VerifyHeaders: q.VerifyHeaders,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d, ok := child.(*EnvDir); ok {
|
||||||
|
return GetDirAction{
|
||||||
|
Path: q.Path,
|
||||||
|
Dir: d,
|
||||||
|
Compression: q.Gzip,
|
||||||
|
VerifyHeaders: q.VerifyHeaders,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldn't get here
|
||||||
|
return nil
|
||||||
|
case "POST":
|
||||||
|
if child != nil {
|
||||||
|
return HttpErrorAction{
|
||||||
|
Status: 409,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parent == nil {
|
||||||
|
return HttpErrorAction{Status: http.StatusNotFound}
|
||||||
|
}
|
||||||
|
_, name := path.Split(q.Path)
|
||||||
|
if q.CreateDirectory {
|
||||||
|
return CreateDirAction{
|
||||||
|
Path: q.Path,
|
||||||
|
Name: name,
|
||||||
|
Parent: parent,
|
||||||
|
PathOnDisk: path.Join(opts.WorkingDirectory, q.Path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CreateFileAction{
|
||||||
|
Path: q.Path,
|
||||||
|
Name: name,
|
||||||
|
Parent: parent,
|
||||||
|
Content: q.FileContent,
|
||||||
|
}
|
||||||
|
case "PUT":
|
||||||
|
if child == nil {
|
||||||
|
return HttpErrorAction{Status: http.StatusNotFound}
|
||||||
|
}
|
||||||
|
f, ok := child.(*EnvFile)
|
||||||
|
if !ok {
|
||||||
|
return HttpErrorAction{
|
||||||
|
Status: 409,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, name := path.Split(q.Path)
|
||||||
|
return ReplaceFileAction{
|
||||||
|
Path: q.Path,
|
||||||
|
Name: name,
|
||||||
|
Parent: parent,
|
||||||
|
OldContent: f,
|
||||||
|
NewContent: q.FileContent,
|
||||||
|
}
|
||||||
|
case "DELETE":
|
||||||
|
if child == env.RootDir {
|
||||||
|
return HttpErrorAction{Status: http.StatusForbidden}
|
||||||
|
}
|
||||||
|
if child == nil {
|
||||||
|
return HttpErrorAction{Status: http.StatusNotFound}
|
||||||
|
}
|
||||||
|
_, ok := child.(*EnvDir)
|
||||||
|
if ok && !q.RemoveDirectory {
|
||||||
|
return HttpErrorAction{
|
||||||
|
Status: 406,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, name := path.Split(q.Path)
|
||||||
|
return DeleteAction{
|
||||||
|
Path: q.Path,
|
||||||
|
Parent: parent,
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LargePutPolicy keeps a small number of guaranteed successful writes above
|
||||||
|
// the solution's memory limit, while bounding the remaining PUT bodies.
|
||||||
|
type LargePutPolicy struct {
|
||||||
|
RunSeed int64
|
||||||
|
LargeSizes []int64
|
||||||
|
OtherMaxBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueriesGen contains config for generating queries.
|
||||||
|
type QueriesGen struct {
|
||||||
|
// Number of queries to generate.
|
||||||
|
Count int
|
||||||
|
|
||||||
|
GetFile bool // Allow GET requests for files.
|
||||||
|
GetFileNoErrors bool // Disable requests for non-existing files.
|
||||||
|
GetDirectory bool // Allow GET requests for directories.
|
||||||
|
Compression bool // Allow compression in GET requests.
|
||||||
|
Post bool // Allow POST requests.
|
||||||
|
Put bool // Allow PUT requests.
|
||||||
|
Delete bool // Allow DELETE requests.
|
||||||
|
AllHeaders bool // Allow extra headers.
|
||||||
|
LargePuts *LargePutPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *QueriesGen) Generate(t *TC, env *Env, gen *EnvGen, seeds []int64, opts RunOpts, runSeed int64) []Query {
|
||||||
|
env = env.Clone()
|
||||||
|
largePuts := 0
|
||||||
|
|
||||||
|
stats := &Stats{}
|
||||||
|
env.RootDir.Stats("", stats)
|
||||||
|
stats.Normalize()
|
||||||
|
|
||||||
|
var methods []string
|
||||||
|
if g.GetFile || g.GetDirectory {
|
||||||
|
methods = append(methods, "GET")
|
||||||
|
}
|
||||||
|
if g.Post {
|
||||||
|
methods = append(methods, "POST")
|
||||||
|
}
|
||||||
|
if g.Put {
|
||||||
|
methods = append(methods, "PUT")
|
||||||
|
}
|
||||||
|
if g.Delete {
|
||||||
|
methods = append(methods, "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
var queries []Query
|
||||||
|
for _, seed := range seeds {
|
||||||
|
r := rand.New(rand.NewSource(seed))
|
||||||
|
method := methods[r.Intn(len(methods))]
|
||||||
|
|
||||||
|
var genPaths []string
|
||||||
|
if len(stats.DirPaths) > 0 && !(method == "GET" && !g.GetDirectory) {
|
||||||
|
genPaths = append(genPaths, stats.DirPaths[r.Intn(len(stats.DirPaths))])
|
||||||
|
genPaths = append(genPaths, stats.DirPaths[r.Intn(len(stats.DirPaths))])
|
||||||
|
}
|
||||||
|
if len(stats.FilePaths) > 0 && !(method == "GET" && !g.GetFile) {
|
||||||
|
genPaths = append(genPaths, stats.FilePaths[r.Intn(len(stats.FilePaths))])
|
||||||
|
genPaths = append(genPaths, stats.FilePaths[r.Intn(len(stats.FilePaths))])
|
||||||
|
}
|
||||||
|
if !(method == "GET" && g.GetFileNoErrors) {
|
||||||
|
// TODO: better non-existing path generator
|
||||||
|
randomPath := gen.FilenameGen(r) + "/" + gen.FilenameGen(r)
|
||||||
|
genPaths = append(genPaths, randomPath)
|
||||||
|
|
||||||
|
if len(stats.DirPaths) > 0 {
|
||||||
|
randomDir := stats.DirPaths[r.Intn(len(stats.DirPaths))]
|
||||||
|
num := 2
|
||||||
|
if method == "POST" {
|
||||||
|
num = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < num; i++ {
|
||||||
|
genPaths = append(genPaths, randomDir+"/"+gen.FilenameGen(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(genPaths) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hostHeader := opts.ServerDomain
|
||||||
|
if g.AllHeaders && r.Intn(5) == 0 {
|
||||||
|
// to get 400
|
||||||
|
hostHeader = "hse.ru"
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(genPaths[r.Intn(len(genPaths))], "/")
|
||||||
|
if path == "" && method != "GET" {
|
||||||
|
// Keep the existing large-file scenarios and RNG sequence.
|
||||||
|
// Requests targeting the root are covered on a small tree in contract.go.
|
||||||
|
path = "MISSING_ROOT_TARGET"
|
||||||
|
}
|
||||||
|
gzipRequested := g.Compression && r.Intn(2) == 1
|
||||||
|
query := Query{
|
||||||
|
Seed: seed,
|
||||||
|
Method: method,
|
||||||
|
Path: path,
|
||||||
|
Gzip: method == "GET" && gzipRequested,
|
||||||
|
CreateDirectory: method == "POST" && (r.Intn(2) == 1),
|
||||||
|
RemoveDirectory: method == "DELETE" && (r.Intn(2) == 1),
|
||||||
|
HostHeader: hostHeader,
|
||||||
|
VerifyHeaders: g.AllHeaders,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "POST" && !query.CreateDirectory) || method == "PUT" {
|
||||||
|
query.FileContent = gen.GenerateFile(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
action := query.Action(env, &opts)
|
||||||
|
if query.Method == "PUT" && g.LargePuts != nil {
|
||||||
|
policy := g.LargePuts
|
||||||
|
if _, ok := action.(ReplaceFileAction); ok && runSeed == policy.RunSeed && largePuts < len(policy.LargeSizes) {
|
||||||
|
query.FileContent.Size = policy.LargeSizes[largePuts]
|
||||||
|
largePuts++
|
||||||
|
} else if query.FileContent.Size > policy.OtherMaxBytes {
|
||||||
|
query.FileContent.Size = policy.OtherMaxBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queries = append(queries, query)
|
||||||
|
|
||||||
|
if action != nil {
|
||||||
|
changed := action.ApplyEnv(t, env)
|
||||||
|
if changed {
|
||||||
|
stats = &Stats{}
|
||||||
|
env.RootDir.Stats("", stats)
|
||||||
|
stats.Normalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g.LargePuts != nil && runSeed == g.LargePuts.RunSeed {
|
||||||
|
require.Equal(t, len(g.LargePuts.LargeSizes), largePuts, "not enough successful PUT queries for large-file coverage")
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWaitForServerUsesConfiguredHost(t *testing.T) {
|
||||||
|
t.Setenv("SOLUTION_HOST", "127.0.0.1")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
domain string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"localhost", http.StatusOK},
|
||||||
|
{"files.example.com", http.StatusNotFound},
|
||||||
|
{"redirect.example.com", http.StatusFound},
|
||||||
|
} {
|
||||||
|
t.Run(tc.domain, func(t *testing.T) {
|
||||||
|
type probe struct{ method, target, host string }
|
||||||
|
received := make(chan probe, 16)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case received <- probe{r.Method, r.RequestURI, r.Host}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if tc.status == http.StatusFound {
|
||||||
|
w.Header().Set("Location", "/redirected")
|
||||||
|
}
|
||||||
|
w.WriteHeader(tc.status)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
opts := RunOpts{
|
||||||
|
Port: server.Listener.Addr().(*net.TCPAddr).Port,
|
||||||
|
ServerDomain: tc.domain,
|
||||||
|
}
|
||||||
|
if err := WaitForServer(NewTestContext(t), opts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-received:
|
||||||
|
if want := (probe{"GET", "/", tc.domain}); got != want {
|
||||||
|
t.Fatalf("readiness request = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("server was marked ready without receiving a request")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case extra := <-received:
|
||||||
|
t.Fatalf("unexpected extra readiness request: %+v", extra)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResponseEndChecksWireBytes(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"valid body", "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndata", true},
|
||||||
|
{"extra body bytes", "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndataextra", false},
|
||||||
|
{"valid empty body", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", true},
|
||||||
|
{"body after zero length", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\nextra", false},
|
||||||
|
{"valid no content", "HTTP/1.1 204 No Content\r\n\r\n", true},
|
||||||
|
{"body after no content", "HTTP/1.1 204 No Content\r\n\r\nextra", false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
go func() {
|
||||||
|
server, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
_, _ = io.WriteString(server, tc.response)
|
||||||
|
}()
|
||||||
|
client, err := net.Dial("tcp", listener.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
reader := bufio.NewReader(client)
|
||||||
|
response, err := http.ReadResponse(reader, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if _, err := io.Copy(io.Discard, response.Body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := checkResponseEnd(reader, false); (err == nil) != tc.valid {
|
||||||
|
t.Fatalf("valid=%v, checkResponseEnd error: %v", tc.valid, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseContentTypeRequiresSubtype(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
value string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"application/octet-stream", true},
|
||||||
|
{"text/plain; charset=utf-8", true},
|
||||||
|
{"nonsense", false},
|
||||||
|
{"text/", false},
|
||||||
|
{"", false},
|
||||||
|
} {
|
||||||
|
_, err := parseContentType(tc.value)
|
||||||
|
if (err == nil) != tc.valid {
|
||||||
|
t.Errorf("content type %q: valid=%v, error=%v", tc.value, tc.valid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunQueryAcceptsEarlyErrorResponse(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
go func() {
|
||||||
|
server, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
reader := bufio.NewReader(server)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil || line == "\r\n" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(server, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 5\r\n\r\nerror")
|
||||||
|
}()
|
||||||
|
t.Setenv("SOLUTION_HOST", "127.0.0.1")
|
||||||
|
RunQuery(NewTestContext(t), &Env{}, t.TempDir(), RunOpts{
|
||||||
|
Port: listener.Addr().(*net.TCPAddr).Port,
|
||||||
|
ServerDomain: "localhost",
|
||||||
|
}, Query{
|
||||||
|
Method: "PUT",
|
||||||
|
Path: "item",
|
||||||
|
HostHeader: "other.example",
|
||||||
|
FileContent: &EnvFile{GenSeed: 42, Size: 8 * 1024 * 1024},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunQueryWaitsForLargeRequestBeforeCloseTimeout(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
server, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
request, err := http.ReadRequest(bufio.NewReader(server))
|
||||||
|
if err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(server, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 5\r\n\r\nerror"); err != nil {
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(2500 * time.Millisecond)
|
||||||
|
_, err = io.Copy(io.Discard, request.Body)
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
t.Setenv("SOLUTION_HOST", "127.0.0.1")
|
||||||
|
RunQuery(NewTestContext(t), &Env{}, t.TempDir(), RunOpts{
|
||||||
|
Port: listener.Addr().(*net.TCPAddr).Port,
|
||||||
|
ServerDomain: "localhost",
|
||||||
|
}, Query{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "item",
|
||||||
|
HostHeader: "other.example",
|
||||||
|
FileContent: &EnvFile{GenSeed: 42, Size: 160 << 20},
|
||||||
|
})
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdleDeadlineWriter(t *testing.T) {
|
||||||
|
t.Run("stalled write", func(t *testing.T) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer server.Close()
|
||||||
|
writer := &idleDeadlineWriter{Conn: client, Timeout: 50 * time.Millisecond}
|
||||||
|
_, err := writer.Write([]byte("x"))
|
||||||
|
var netErr net.Error
|
||||||
|
if !errors.As(err, &netErr) || !netErr.Timeout() {
|
||||||
|
t.Fatalf("expected write timeout, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("request body timeout", func(t *testing.T) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
defer client.Close()
|
||||||
|
defer server.Close()
|
||||||
|
release := make(chan struct{})
|
||||||
|
defer close(release)
|
||||||
|
go func() {
|
||||||
|
_, _ = http.ReadRequest(bufio.NewReader(server))
|
||||||
|
<-release
|
||||||
|
}()
|
||||||
|
writer := &idleDeadlineWriter{Conn: client, Timeout: 50 * time.Millisecond}
|
||||||
|
request := (&Query{Method: "POST", FileContent: &EnvFile{GenSeed: 42, Size: 1 << 20}}).
|
||||||
|
CreateRequest(NewTestContext(t), "http://localhost/item")
|
||||||
|
if err := request.Write(writer); err == nil {
|
||||||
|
t.Fatal("expected request write to time out")
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
if !errors.As(writer.lastError, &netErr) || !netErr.Timeout() {
|
||||||
|
t.Fatalf("expected underlying write timeout, got %v", writer.lastError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompressedGetResponseValidation(t *testing.T) {
|
||||||
|
file := &EnvFile{GenSeed: 42, Size: 1024}
|
||||||
|
directory := &EnvDir{Listing: map[string]EnvNode{"item": file}}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
body io.Reader
|
||||||
|
validate func(*TC, *http.Response)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
body: file.Open(),
|
||||||
|
validate: func(t *TC, resp *http.Response) {
|
||||||
|
GetFileAction{File: file, Compression: true, VerifyHeaders: true}.VerifyResponse(t, nil, resp)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directory",
|
||||||
|
body: bytes.NewBufferString("item\n"),
|
||||||
|
validate: func(t *TC, resp *http.Response) {
|
||||||
|
GetDirAction{Dir: directory, Compression: true, VerifyHeaders: true}.VerifyResponse(t, nil, resp)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(tt *testing.T) {
|
||||||
|
testContext := NewTestContext(tt)
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
writer := gzip.NewWriter(&encoded)
|
||||||
|
_, err := io.Copy(writer, tc.body)
|
||||||
|
if err != nil {
|
||||||
|
tt.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
tt.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
ContentLength: int64(encoded.Len()),
|
||||||
|
Header: http.Header{
|
||||||
|
"Content-Length": {strconv.Itoa(encoded.Len())},
|
||||||
|
"Content-Encoding": {"gzip"},
|
||||||
|
"Content-Type": {"text/plain"},
|
||||||
|
"Server": {"test-server"},
|
||||||
|
"Connection": {"close"},
|
||||||
|
},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(encoded.Bytes())),
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
q := Query{Gzip: true, VerifyHeaders: true}
|
||||||
|
req := q.CreateRequest(testContext, "http://localhost/item")
|
||||||
|
if got := req.Header.Get("Accept-Encoding"); got != "gzip" {
|
||||||
|
tt.Fatalf("expected Accept-Encoding: gzip, got %q", got)
|
||||||
|
}
|
||||||
|
q.CommonValidate(testContext, req, resp)
|
||||||
|
tc.validate(testContext, resp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareFileContentBlocks(t *testing.T) {
|
||||||
|
file := &EnvFile{GenSeed: 17, Size: 64*1024 + 17}
|
||||||
|
context := NewTestContext(t)
|
||||||
|
if err := CompareFileContent(context, file.Open(), file); err != nil {
|
||||||
|
t.Fatalf("valid file was rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := CompareFileContent(context, io.LimitReader(file.Open(), file.Size-1), file); err == nil {
|
||||||
|
t.Fatal("truncated file was accepted")
|
||||||
|
}
|
||||||
|
content, err := io.ReadAll(file.Open())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content[len(content)-1] ^= 1
|
||||||
|
if err := CompareFileContent(context, bytes.NewReader(content), file); err == nil {
|
||||||
|
t.Fatal("changed final byte was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunOpts contains command line arguments for the solution.
|
||||||
|
// Those are passed to the template.
|
||||||
|
type RunOpts struct {
|
||||||
|
// Solution options
|
||||||
|
Port int
|
||||||
|
WorkingDirectory string
|
||||||
|
ListenAddr string // Host
|
||||||
|
ServerDomain string
|
||||||
|
|
||||||
|
// Full run config that will be provided to the solution.
|
||||||
|
CommandLineArgs string
|
||||||
|
Env []string
|
||||||
|
|
||||||
|
// Full run config if solution will be running in docker.
|
||||||
|
DockerCommandLineArgs string
|
||||||
|
DockerEnvArgs string
|
||||||
|
DockerPortArgs string
|
||||||
|
DockerVolumeArgs string
|
||||||
|
|
||||||
|
// Hack to get exitcode of the solution.
|
||||||
|
ExitCode chan int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *RunOpts) Address() string {
|
||||||
|
host := os.Getenv("SOLUTION_HOST")
|
||||||
|
if host == "" {
|
||||||
|
host = "localhost"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("http://%s:%d", host, o.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *RunOpts) GenerateRunConfig(t *TC, r *rand.Rand, gen *EnvGen) {
|
||||||
|
o.CommandLineArgs, o.Env = o.BuildConfig(r, gen)
|
||||||
|
|
||||||
|
dirsInDocker := []string{
|
||||||
|
"/files",
|
||||||
|
"/files0",
|
||||||
|
"/files1",
|
||||||
|
"/files2",
|
||||||
|
"/files3",
|
||||||
|
}
|
||||||
|
dirInDocker := dirsInDocker[r.Intn(len(dirsInDocker))]
|
||||||
|
|
||||||
|
if o.WorkingDirectory == "" {
|
||||||
|
dirInDocker = ""
|
||||||
|
} else {
|
||||||
|
o.DockerVolumeArgs = fmt.Sprintf(`-v "%s:%s"`, o.WorkingDirectory, dirInDocker)
|
||||||
|
}
|
||||||
|
|
||||||
|
o.DockerPortArgs = fmt.Sprintf("-p %d:%d", o.Port, o.Port)
|
||||||
|
|
||||||
|
dockerArgs, dockerEnv := RunOpts{
|
||||||
|
ListenAddr: o.ListenAddr,
|
||||||
|
Port: o.Port,
|
||||||
|
WorkingDirectory: dirInDocker,
|
||||||
|
ServerDomain: o.ServerDomain,
|
||||||
|
}.BuildConfig(r, gen)
|
||||||
|
|
||||||
|
o.DockerEnvArgs = ""
|
||||||
|
for _, env := range dockerEnv {
|
||||||
|
o.DockerEnvArgs += "--env \"" + env + "\" "
|
||||||
|
}
|
||||||
|
|
||||||
|
o.DockerCommandLineArgs = dockerArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildConfig uses ListenAddr, Port, WorkingDirectory, ServerDomain.
|
||||||
|
func (o RunOpts) BuildConfig(r *rand.Rand, gen *EnvGen) (args string, env []string) {
|
||||||
|
if o.ListenAddr == "0.0.0.0" && r.Intn(2) == 1 {
|
||||||
|
// can omit default value
|
||||||
|
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
||||||
|
// use env
|
||||||
|
env = append(env, fmt.Sprintf("SERVER_HOST=%s", o.ListenAddr))
|
||||||
|
} else if o.ListenAddr != "" {
|
||||||
|
// use plain cmdline args
|
||||||
|
args += fmt.Sprintf(" \"--host=%s\"", o.ListenAddr)
|
||||||
|
|
||||||
|
if r.Intn(2) == 1 {
|
||||||
|
// pass dummy env
|
||||||
|
env = append(env, fmt.Sprintf("SERVER_HOST=%s", "8.8.8.8"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.Port == 8080 && r.Intn(2) == 1 {
|
||||||
|
// can omit default value
|
||||||
|
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
||||||
|
// use env
|
||||||
|
env = append(env, fmt.Sprintf("SERVER_PORT=%d", o.Port))
|
||||||
|
} else {
|
||||||
|
// use plain cmdline args
|
||||||
|
args += fmt.Sprintf(" \"--port=%d\"", o.Port)
|
||||||
|
|
||||||
|
if r.Intn(2) == 1 {
|
||||||
|
// pass dummy env
|
||||||
|
env = append(env, fmt.Sprintf("SERVER_PORT=%d", 80))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.WorkingDirectory == "" && r.Intn(2) == 1 {
|
||||||
|
// can omit default value
|
||||||
|
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
||||||
|
// use env
|
||||||
|
env = append(env, fmt.Sprintf("SERVER_WORKING_DIRECTORY=%s", o.WorkingDirectory))
|
||||||
|
} else if o.WorkingDirectory != "" {
|
||||||
|
// use plain cmdline args
|
||||||
|
args += fmt.Sprintf(" \"--working-directory=%s\"", o.WorkingDirectory)
|
||||||
|
|
||||||
|
if r.Intn(2) == 1 {
|
||||||
|
// pass dummy env
|
||||||
|
env = append(env, fmt.Sprintf("SERVER_WORKING_DIRECTORY=%s", "/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.ServerDomain == "localhost" && r.Intn(2) == 1 {
|
||||||
|
// can omit default value
|
||||||
|
} else if gen.AllowEnv && r.Intn(3) == 1 {
|
||||||
|
// use env
|
||||||
|
env = append(env, fmt.Sprintf("SERVER_DOMAIN=%s", o.ServerDomain))
|
||||||
|
} else if o.ServerDomain != "" {
|
||||||
|
// use plain cmdline args
|
||||||
|
args += fmt.Sprintf(" \"--server-domain=%s\"", o.ServerDomain)
|
||||||
|
|
||||||
|
if r.Intn(2) == 1 {
|
||||||
|
// pass dummy env
|
||||||
|
env = append(env, fmt.Sprintf("SERVER_DOMAIN=%s", "example.com"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args, env
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner is a helper for running HTTP server solution.
|
||||||
|
type Runner interface {
|
||||||
|
// Run the solution with the given options.
|
||||||
|
// Returns a function that can be used to stop the solution.
|
||||||
|
Run(t *TC, opts RunOpts) (stop func(), err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CmdRunner runs command based on template.
|
||||||
|
type CmdRunner struct {
|
||||||
|
tmpl template.Template
|
||||||
|
useDocker bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdRunner(tmpl *template.Template, useDocker bool) *CmdRunner {
|
||||||
|
return &CmdRunner{
|
||||||
|
tmpl: *tmpl,
|
||||||
|
useDocker: useDocker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitCommand preserves quoted arguments without invoking a shell.
|
||||||
|
func splitCommand(command string) ([]string, error) {
|
||||||
|
var args []string
|
||||||
|
var arg strings.Builder
|
||||||
|
quoted := false
|
||||||
|
started := false
|
||||||
|
for _, char := range command {
|
||||||
|
switch {
|
||||||
|
case char == '"':
|
||||||
|
quoted = !quoted
|
||||||
|
started = true
|
||||||
|
case unicode.IsSpace(char) && !quoted:
|
||||||
|
if started {
|
||||||
|
args = append(args, arg.String())
|
||||||
|
arg.Reset()
|
||||||
|
started = false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
arg.WriteRune(char)
|
||||||
|
started = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quoted {
|
||||||
|
return nil, fmt.Errorf("unclosed quote in launch command")
|
||||||
|
}
|
||||||
|
if started {
|
||||||
|
args = append(args, arg.String())
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty launch command")
|
||||||
|
}
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CmdRunner) Run(t *TC, opts RunOpts) (stop func(), err error) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
err = r.tmpl.Execute(&b, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to execute template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
envOpts := opts.Env
|
||||||
|
if r.useDocker {
|
||||||
|
envOpts = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdParts, err := splitCommand(b.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cidDir, cidFile string
|
||||||
|
if r.useDocker {
|
||||||
|
if len(cmdParts) < 2 || !strings.EqualFold(filepath.Base(cmdParts[0]), "docker") || cmdParts[1] != "run" {
|
||||||
|
return nil, fmt.Errorf("Docker launch template must start with docker run")
|
||||||
|
}
|
||||||
|
cidDir, err = os.MkdirTemp("", "hw3-container-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create container ID directory: %w", err)
|
||||||
|
}
|
||||||
|
cidFile = filepath.Join(cidDir, "id")
|
||||||
|
cmdParts = append(append([]string{}, cmdParts[:2]...), append([]string{"--cidfile", cidFile}, cmdParts[2:]...)...)
|
||||||
|
}
|
||||||
|
Info(t, "Running command", zap.Strings("command", cmdParts), zap.Strings("env", envOpts))
|
||||||
|
|
||||||
|
cmd := exec.Command(cmdParts[0], cmdParts[1:]...)
|
||||||
|
cmd.Env = append(os.Environ(), envOpts...)
|
||||||
|
cmd.Stdout = NewProxyWriter(os.Stderr)
|
||||||
|
cmd.Stderr = NewProxyWriter(os.Stderr)
|
||||||
|
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
if cidDir != "" {
|
||||||
|
_ = os.Remove(cidDir)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to run command: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := cmd.Wait()
|
||||||
|
if err != nil && err.Error() != "signal: killed" && err.Error() != "exit status 1" {
|
||||||
|
Warn(t, "Command finished with error", zap.Error(err))
|
||||||
|
}
|
||||||
|
if opts.ExitCode != nil {
|
||||||
|
if e, ok := err.(*exec.ExitError); ok {
|
||||||
|
opts.ExitCode <- e.ExitCode()
|
||||||
|
} else {
|
||||||
|
opts.ExitCode <- 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||||
|
Error(t, "Failed to kill command", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.useDocker {
|
||||||
|
id, err := os.ReadFile(cidFile)
|
||||||
|
if err == nil && len(strings.TrimSpace(string(id))) > 0 {
|
||||||
|
output, removeErr := exec.Command("docker", "rm", "-f", strings.TrimSpace(string(id))).CombinedOutput()
|
||||||
|
if removeErr != nil && !strings.Contains(string(output), "No such container") && !strings.Contains(string(output), "is already in progress") {
|
||||||
|
Error(t, "Failed to remove Docker container", zap.Error(removeErr), zap.ByteString("output", output))
|
||||||
|
}
|
||||||
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
Error(t, "Failed to read container ID", zap.Error(err))
|
||||||
|
}
|
||||||
|
_ = os.Remove(cidFile)
|
||||||
|
_ = os.Remove(cidDir)
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProxyWriter is used to forward solution output to standard output.
|
||||||
|
type ProxyWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
disable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyWriter(w io.Writer) *ProxyWriter {
|
||||||
|
return &ProxyWriter{
|
||||||
|
w: w,
|
||||||
|
disable: boolFromEnv("DISABLE_SOLUTION_OUTPUT", false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ProxyWriter) Write(b []byte) (n int, err error) {
|
||||||
|
if w.disable {
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
// TODO: if there will be sync problems, we can take a global lock
|
||||||
|
// and read until \n, then flush and apply color
|
||||||
|
return w.w.Write(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitCommand(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
command string
|
||||||
|
want []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "working directory with spaces",
|
||||||
|
command: `python3 "../solution with spaces/server.py" "--working-directory=C:\Users\student name\files"`,
|
||||||
|
want: []string{"python3", "../solution with spaces/server.py", `--working-directory=C:\Users\student name\files`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Docker volume with spaces",
|
||||||
|
command: `docker run -v "C:\Users\student name\files:/files" hw3img`,
|
||||||
|
want: []string{"docker", "run", "-v", `C:\Users\student name\files:/files`, "hw3img"},
|
||||||
|
},
|
||||||
|
{name: "empty command", command: " ", wantErr: true},
|
||||||
|
{name: "unclosed quote", command: `python3 "unterminated`, wantErr: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := splitCommand(test.command)
|
||||||
|
if (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("splitCommand(%q) error = %v, wantErr %v", test.command, err, test.wantErr)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, test.want) {
|
||||||
|
t.Fatalf("splitCommand(%q) = %#v, want %#v", test.command, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Run from the assignment directory: python -m unittest discover -s tests -p test_starter.py."""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
STARTER = Path(__file__).resolve().parents[1] / "solution" / "server.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("hw3_starter", STARTER)
|
||||||
|
starter = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = starter
|
||||||
|
spec.loader.exec_module(starter)
|
||||||
|
ENV_NAMES = ("SERVER_HOST", "SERVER_PORT", "SERVER_DOMAIN", "SERVER_WORKING_DIRECTORY")
|
||||||
|
|
||||||
|
|
||||||
|
class StarterTests(unittest.TestCase):
|
||||||
|
def run_config(self, args, env):
|
||||||
|
clean_env = {k: v for k, v in os.environ.items() if k not in ENV_NAMES}
|
||||||
|
clean_env.update(env)
|
||||||
|
with patch.dict(os.environ, clean_env, clear=True), patch.object(starter.socket, "socket") as factory, patch.object(starter, "HTTPServer") as server:
|
||||||
|
factory.return_value.accept.side_effect = OSError("stop after bind")
|
||||||
|
result = CliRunner().invoke(starter.main, args)
|
||||||
|
return result, factory, server
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
result, factory, server = self.run_config(["--working-directory", str(STARTER.parent)], {})
|
||||||
|
self.assertEqual(result.exit_code, 0, result.output)
|
||||||
|
factory.return_value.bind.assert_called_once_with(("0.0.0.0", 8080))
|
||||||
|
self.assertEqual(server.call_args.args[2:], ("localhost", STARTER.parent))
|
||||||
|
|
||||||
|
def test_environment(self):
|
||||||
|
env = dict(zip(ENV_NAMES, ("127.0.0.1", "9090", "example.com", str(STARTER.parent))))
|
||||||
|
result, factory, server = self.run_config([], env)
|
||||||
|
self.assertEqual(result.exit_code, 0, result.output)
|
||||||
|
factory.return_value.bind.assert_called_once_with(("127.0.0.1", 9090))
|
||||||
|
self.assertEqual(server.call_args.args[2:], ("example.com", STARTER.parent))
|
||||||
|
|
||||||
|
def test_cli_overrides_environment(self):
|
||||||
|
env = dict(zip(ENV_NAMES, ("8.8.8.8", "80", "wrong.example", "wrong-directory")))
|
||||||
|
result, factory, server = self.run_config(
|
||||||
|
["--host", "127.0.0.1", "--port", "9091", "--server-domain", "example.com",
|
||||||
|
"--working-directory", str(STARTER.parent)], env)
|
||||||
|
self.assertEqual(result.exit_code, 0, result.output)
|
||||||
|
factory.return_value.bind.assert_called_once_with(("127.0.0.1", 9091))
|
||||||
|
self.assertEqual(server.call_args.args[2:], ("example.com", STARTER.parent))
|
||||||
|
|
||||||
|
def test_missing_directory(self):
|
||||||
|
for args, env in [([], {}), ([], {"SERVER_WORKING_DIRECTORY": ""}),
|
||||||
|
(["--working-directory", ""], {"SERVER_WORKING_DIRECTORY": str(STARTER.parent)})]:
|
||||||
|
with self.subTest(args=args, env=env):
|
||||||
|
result, factory, _ = self.run_config(args, env)
|
||||||
|
self.assertEqual(result.exit_code, 1, result.output)
|
||||||
|
factory.assert_not_called()
|
||||||
|
|
||||||
|
def test_tcp_startup(self):
|
||||||
|
with socket.socket() as probe:
|
||||||
|
probe.bind(("127.0.0.1", 0))
|
||||||
|
port = probe.getsockname()[1]
|
||||||
|
with tempfile.TemporaryDirectory() as workdir:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[sys.executable, str(STARTER), "--host", "127.0.0.1", "--port", str(port),
|
||||||
|
"--working-directory", workdir],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while True:
|
||||||
|
self.assertIsNone(process.poll(), "starter exited before accepting a connection")
|
||||||
|
try:
|
||||||
|
client = socket.create_connection(("127.0.0.1", port), timeout=0.5)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
self.fail("starter did not bind within 5 seconds")
|
||||||
|
time.sleep(0.05)
|
||||||
|
with client:
|
||||||
|
client.sendall(b"GET / HTTP/1.1\r\n")
|
||||||
|
# The starter has no HTTP handler yet, but its TCP lifecycle works.
|
||||||
|
self.assertEqual(client.recv(1), b"")
|
||||||
|
finally:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TC implements *testing.T and context.Context at the same time.
|
||||||
|
// Also has some helper methods.
|
||||||
|
type TC struct {
|
||||||
|
*testing.T
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestContext(t *testing.T) *TC {
|
||||||
|
return &TC{
|
||||||
|
ctx: context.Background(),
|
||||||
|
T: t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TC) Errorf(format string, args ...any) {
|
||||||
|
str := fmt.Sprintf(format, args...)
|
||||||
|
if strings.HasPrefix(str, "\n\tError Trace:") {
|
||||||
|
_, after, ok := strings.Cut(str, "\tError:")
|
||||||
|
if ok {
|
||||||
|
str = "\n\tError:" + after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Error(tc, str)
|
||||||
|
tc.T.Errorf(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TC) RunByName(name string, f func(*TC)) bool {
|
||||||
|
return tc.Run(name, func(t *testing.T) {
|
||||||
|
f(tc.derive(t))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TC) RunBySeed(seed int64, f func(*TC)) bool {
|
||||||
|
return tc.Run(strconv.FormatInt(seed, 10), func(t *testing.T) {
|
||||||
|
f(tc.derive(t))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TC) Done() <-chan struct{} {
|
||||||
|
return tc.ctx.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TC) Err() error {
|
||||||
|
return tc.ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TC) Value(key any) any {
|
||||||
|
return tc.ctx.Value(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// derive copies *TC and replaces *testing.T
|
||||||
|
func (tc TC) derive(t *testing.T) *TC {
|
||||||
|
tc.T = t
|
||||||
|
return &tc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get bool from envvars
|
||||||
|
func boolFromEnv(key string, def bool) bool {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if value == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
value = strings.ToLower(value)
|
||||||
|
switch value {
|
||||||
|
case "t":
|
||||||
|
return true
|
||||||
|
case "true":
|
||||||
|
return true
|
||||||
|
case "y":
|
||||||
|
return true
|
||||||
|
case "yes":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFreePort asks the kernel for a free open port that is ready to use.
|
||||||
|
func GetFreePort() (int, error) {
|
||||||
|
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := net.ListenTCP("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
return l.Addr().(*net.TCPAddr).Port, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package hw3test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunTestEmptyWorkDir will check that server does exit(1) if working directory is empty.
|
||||||
|
func RunTestEmptyWorkDir(t *TC, seed int64, runner Runner) {
|
||||||
|
t.RunBySeed(seed, func(t *TC) {
|
||||||
|
r := rand.New(rand.NewSource(seed))
|
||||||
|
|
||||||
|
port, err := GetFreePort()
|
||||||
|
require.NoError(t, err, "failed to get free port for the server")
|
||||||
|
runOpts := RunOpts{
|
||||||
|
Port: port,
|
||||||
|
WorkingDirectory: "",
|
||||||
|
ServerDomain: "localhost",
|
||||||
|
ListenAddr: "0.0.0.0",
|
||||||
|
ExitCode: make(chan int),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the solution.
|
||||||
|
runOpts.GenerateRunConfig(t, r, &EnvGen{})
|
||||||
|
stop, err := runner.Run(t, runOpts)
|
||||||
|
require.NoError(t, err, "failed to start solution")
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(time.Second * 10):
|
||||||
|
require.FailNow(t, "Server didn't exit(1) in 10 seconds")
|
||||||
|
case ec := <-runOpts.ExitCode:
|
||||||
|
require.Equal(t, 1, ec, "Server exited with wrong code")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunTests(t *TC, seed int64, runner Runner, envGen *EnvGen, queriesGen *QueriesGen) {
|
||||||
|
failed := !t.RunBySeed(seed, func(t *TC) {
|
||||||
|
env, err := envGen.Generate(seed)
|
||||||
|
require.NoError(t, err, "failed to generate environment")
|
||||||
|
|
||||||
|
r := rand.New(rand.NewSource(seed))
|
||||||
|
|
||||||
|
queriesSeeds := make([]int64, queriesGen.Count)
|
||||||
|
for i := range queriesSeeds {
|
||||||
|
queriesSeeds[i] = r.Int63n(100000)
|
||||||
|
}
|
||||||
|
|
||||||
|
envDirName := GenFilename(r, 16)
|
||||||
|
envDir := filepath.Join(envGen.TempDirectory, envDirName)
|
||||||
|
|
||||||
|
runOpts := RunOpts{
|
||||||
|
WorkingDirectory: envDir,
|
||||||
|
ServerDomain: "localhost",
|
||||||
|
ListenAddr: "0.0.0.0",
|
||||||
|
}
|
||||||
|
if queriesGen.AllHeaders {
|
||||||
|
possibleDomains := []string{
|
||||||
|
"localhost",
|
||||||
|
"cs.hse.ru",
|
||||||
|
"example.com",
|
||||||
|
"z0r.de",
|
||||||
|
"distsys-course.homework.net",
|
||||||
|
}
|
||||||
|
runOpts.ServerDomain = possibleDomains[r.Intn(len(possibleDomains))]
|
||||||
|
}
|
||||||
|
|
||||||
|
queries := queriesGen.Generate(t, env, envGen, queriesSeeds, runOpts, seed)
|
||||||
|
if envGen.SparseUnusedFiles {
|
||||||
|
needed := make(map[string]bool)
|
||||||
|
for _, query := range queries {
|
||||||
|
if query.Method == http.MethodGet || query.Method == http.MethodPut {
|
||||||
|
needed[path.Clean(query.Path)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = env.RootDir.WriteToDiskSelected(envDir, needed)
|
||||||
|
} else {
|
||||||
|
err = env.RootDir.WriteToDisk(envDir)
|
||||||
|
}
|
||||||
|
require.NoError(t, err, "failed to write environment to disk")
|
||||||
|
defer os.RemoveAll(envDir)
|
||||||
|
port, err := GetFreePort()
|
||||||
|
require.NoError(t, err, "failed to get free port for the server")
|
||||||
|
runOpts.Port = port
|
||||||
|
|
||||||
|
// Start the solution.
|
||||||
|
runOpts.GenerateRunConfig(t, r, envGen)
|
||||||
|
stop, err := runner.Run(t, runOpts)
|
||||||
|
require.NoError(t, err, "failed to start solution")
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
// Await server to bind to port.
|
||||||
|
err = WaitForServer(t, runOpts)
|
||||||
|
require.NoError(t, err, "failed to wait for server")
|
||||||
|
|
||||||
|
// Run the queries.
|
||||||
|
for i, query := range queries {
|
||||||
|
query := query
|
||||||
|
ok := t.RunBySeed(query.Seed, func(t *TC) {
|
||||||
|
RunQuery(t, env, envDir, runOpts, query)
|
||||||
|
})
|
||||||
|
shouldAbort := !ok
|
||||||
|
if shouldAbort {
|
||||||
|
Warn(t, "Skipping next queries because of the failed query", zap.Int("skipped", len(queries)-1-i), zap.String("failed", fmt.Sprintf("%s/%v", t.Name(), query.Seed)))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
shouldAbort := failed
|
||||||
|
if shouldAbort {
|
||||||
|
Warn(t, "Skipping next tests in a group because last test has failed")
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WaitForServer(t *TC, opts RunOpts) error {
|
||||||
|
client := &http.Client{
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; time.Until(deadline) > 0; attempt++ {
|
||||||
|
probeTimeout := time.Until(deadline)
|
||||||
|
if probeTimeout > time.Second {
|
||||||
|
probeTimeout = time.Second
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(t, probeTimeout)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", opts.Address(), nil)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return fmt.Errorf("failed to create context request: %w", err)
|
||||||
|
}
|
||||||
|
req.Host = opts.ServerDomain
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
lastErr = err
|
||||||
|
if attempt == 0 || attempt%10 == 0 {
|
||||||
|
Debug(t, "Waiting for server startup", zap.Int("attempt", attempt), zap.Error(err))
|
||||||
|
}
|
||||||
|
if remaining := time.Until(deadline); remaining > 0 {
|
||||||
|
if remaining > 100*time.Millisecond {
|
||||||
|
remaining = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
time.Sleep(remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Warn(t, "Server didn't get up in time, aborting", zap.Error(lastErr))
|
||||||
|
return fmt.Errorf("server did not start in 10 seconds: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunQuery runs a single query. Server address and its configuration is taken from runOpts.
|
||||||
|
// Initial environment is described by env, but it may be changed with queries. Actual
|
||||||
|
// environment is located in envDir. Query describes the query itself.
|
||||||
|
func RunQuery(t *TC, env *Env, envDir string, opts RunOpts, query Query) {
|
||||||
|
queryURL := fmt.Sprintf("%s/%s", opts.Address(), query.Path)
|
||||||
|
Debug(
|
||||||
|
t,
|
||||||
|
"Sending query",
|
||||||
|
zap.String("method", query.Method),
|
||||||
|
zap.String("path", query.Path),
|
||||||
|
zap.String("url", queryURL),
|
||||||
|
)
|
||||||
|
|
||||||
|
action := query.Action(env, &opts)
|
||||||
|
if action != nil {
|
||||||
|
action.VerifyBefore(t, envDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := query.CreateRequest(t, queryURL)
|
||||||
|
req.Close = true
|
||||||
|
conn, err := net.DialTimeout("tcp", req.URL.Host, 10*time.Second)
|
||||||
|
require.NoError(t, err, "failed to connect to server")
|
||||||
|
defer conn.Close()
|
||||||
|
writeDone := make(chan error, 1)
|
||||||
|
writer := &idleDeadlineWriter{Conn: conn, Timeout: 15 * time.Second}
|
||||||
|
go func() { writeDone <- req.Write(writer) }()
|
||||||
|
reader := bufio.NewReader(&idleDeadlineReader{Conn: conn, Timeout: 30 * time.Second})
|
||||||
|
|
||||||
|
resp, err := http.ReadResponse(reader, req)
|
||||||
|
require.NoError(t, err, "failed to run query on server")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body := &countingBody{ReadCloser: resp.Body}
|
||||||
|
resp.Body = body
|
||||||
|
|
||||||
|
query.CommonValidate(t, req, resp)
|
||||||
|
|
||||||
|
if action != nil {
|
||||||
|
action.VerifyResponse(t, req, resp)
|
||||||
|
}
|
||||||
|
_, err = io.Copy(io.Discard, resp.Body)
|
||||||
|
require.NoError(t, err, "failed to read response body")
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
require.Equal(t, resp.ContentLength, body.Size, "Content-Length differs from response body size")
|
||||||
|
}
|
||||||
|
endDone := make(chan error, 1)
|
||||||
|
go func() { endDone <- checkResponseEnd(reader, req.Body != nil) }()
|
||||||
|
var endErr, writeErr error
|
||||||
|
writeCompleted := false
|
||||||
|
select {
|
||||||
|
case endErr = <-endDone:
|
||||||
|
// The server closed the connection before the request writer finished.
|
||||||
|
case writeErr = <-writeDone:
|
||||||
|
writeCompleted = true
|
||||||
|
var netErr net.Error
|
||||||
|
// http.Request.Write wraps body write errors without preserving Unwrap.
|
||||||
|
// Inspect the original socket error captured by the writer instead.
|
||||||
|
if errors.As(writer.lastError, &netErr) && netErr.Timeout() {
|
||||||
|
require.NoError(t, writer.lastError, "request upload stalled")
|
||||||
|
}
|
||||||
|
// A server may finish reading a large request after sending its response.
|
||||||
|
// Start the close timeout only after the request writer has finished.
|
||||||
|
closeTimeout := 2 * time.Second
|
||||||
|
if req.Body != nil {
|
||||||
|
closeTimeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
require.NoError(t, conn.SetReadDeadline(time.Now().Add(closeTimeout)))
|
||||||
|
endErr = <-endDone
|
||||||
|
}
|
||||||
|
require.NoError(t, endErr, "response contains bytes beyond Content-Length or connection was not closed")
|
||||||
|
// The server may reply without consuming the whole request body. In that
|
||||||
|
// case the response and the file-system checks determine correctness.
|
||||||
|
if req.Body == nil {
|
||||||
|
if !writeCompleted {
|
||||||
|
writeErr = <-writeDone
|
||||||
|
}
|
||||||
|
require.NoError(t, writeErr, "failed to send query to server")
|
||||||
|
}
|
||||||
|
if query.VerifyHeaders && body.Size > 0 {
|
||||||
|
_, err = parseContentType(resp.Header.Get("Content-Type"))
|
||||||
|
require.NoError(t, err, "expected valid content type")
|
||||||
|
}
|
||||||
|
if action != nil {
|
||||||
|
action.VerifyAfter(t, envDir)
|
||||||
|
action.ApplyEnv(t, env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// idleDeadlineWriter bounds pauses in sending a request without limiting the
|
||||||
|
// total time for a large request that continues to make progress.
|
||||||
|
type idleDeadlineWriter struct {
|
||||||
|
net.Conn
|
||||||
|
Timeout time.Duration
|
||||||
|
lastError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *idleDeadlineWriter) Write(p []byte) (int, error) {
|
||||||
|
if err := w.Conn.SetWriteDeadline(time.Now().Add(w.Timeout)); err != nil {
|
||||||
|
w.lastError = err
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n, err := w.Conn.Write(p)
|
||||||
|
if err != nil {
|
||||||
|
w.lastError = err
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkResponseEnd(reader *bufio.Reader, requestHasBody bool) error {
|
||||||
|
_, err := reader.ReadByte()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Closing a connection with an unread request body can produce a TCP reset
|
||||||
|
// after a complete error response. Its error code differs by platform.
|
||||||
|
var netErr *net.OpError
|
||||||
|
if requestHasBody && errors.As(err, &netErr) && netErr.Op == "read" && !netErr.Timeout() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unexpected byte after response body")
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingBody struct {
|
||||||
|
io.ReadCloser
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *countingBody) Read(p []byte) (int, error) {
|
||||||
|
n, err := b.ReadCloser.Read(p)
|
||||||
|
b.Size += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Docker exposes the filesystem source after the separator in mountinfo.
|
||||||
|
# A bind mount whose host source is an ext4 filesystem on zram retains that
|
||||||
|
# source; a plain host directory on the HDD must not pass this check.
|
||||||
|
awk '
|
||||||
|
$5 == "/hw/tests/tmp" {
|
||||||
|
for (i = 6; i <= NF - 2; i++) {
|
||||||
|
if ($i == "-" && $(i + 1) == "ext4" && $(i + 2) ~ /^\/dev\/zram[0-9]+$/)
|
||||||
|
found = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END { exit !found }
|
||||||
|
' /proc/self/mountinfo
|
||||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,347 @@
|
|||||||
|
# Семинар 2. TCP/UDP, Protobuf и gRPC
|
||||||
|
|
||||||
|
## Задача: положить число в Storage
|
||||||
|
|
||||||
|
Допустим, у нас есть простой сервис **Storage**. Он хранит одно число, умеет обновлять его через `PutValue` и возвращать через `GetValue`. Клиент хочет положить туда `42`. Казалось бы, очень простая задача. Что может пойти не так?
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Первый вариант — сервер вообще недоступен: процесс упал, машина выключена или ещё не начала принимать соединения. Второй — сервер жив, но запрос потерялся по дороге.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Но есть более неприятные случаи. Сервер получил запрос и начал его выполнять, а потом сломался. Успел он записать число или нет? Клиент этого не знает. Или сервер всё выполнил и отправил ответ, но уже ответ потерялся в сети. Число записано, а клиент об этом не узнает.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Клиент не может отличить «сервер ничего не сделал» от «сервер всё сделал, но не ответил».** Таймаут сообщает только, что мы не дождались ответа – он не дает понять состояние сервера.
|
||||||
|
|
||||||
|
Можно попробовать ещё раз положить `42`: с точки зрения самого числа повторное присваивание даст тот же результат, если между попытками его никто не менял. Это идея идемпотентности. А вот с инкрементом такой приём уже не сработает: повтор увеличит число ещё раз. Можно спросить сервер, что в нём сейчас лежит, но и на этот запрос мы можем не получить ответа.
|
||||||
|
|
||||||
|
Как с этим жить? Для начала, хотелось бы вообще не думать о том, что пакеты могут теряться, дублироваться, фрагментироваться и изменяться. Посмотрим, какие абстракции для этого дают UDP и TCP, и какие проблемы всё равно останутся.
|
||||||
|
|
||||||
|
## Что дают UDP и TCP
|
||||||
|
|
||||||
|
### Откуда берутся проблемы с пакетами
|
||||||
|
|
||||||
|
Сеть ненадёжна по вполне конкретным причинам:
|
||||||
|
|
||||||
|
- **Переполнение буферов.** В маршрутизатор приходит 10 Гбит/с, а передать дальше он может только 1 Гбит/с. Очередь маршрутизатора конечна: когда она заполнится, новые пакеты придётся отбрасывать. Очередь может переполниться и на принимающем компьютере, если он не успевает обрабатывать данные.
|
||||||
|
- **Отказ линии или устройства.** Кабель повредили при ремонте, оборудование выключилось, а маршрутизатор ещё отправляет пакеты по старому маршруту. Отправитель при этом не обязательно сразу узнает, что случилось.
|
||||||
|
- **Повреждение данных.** Помеха изменила биты. Если проверка контрольной суммы обнаружит повреждение, пакет будет отброшен.
|
||||||
|
- **Повторы и разные задержки.** Например, при потере подтверждения на одном из нижних уровней возможна повторная передача уже доставленных данных. Очереди и смена маршрута могут привести к тому, что более поздний пакет обгонит ранний. Сам IP не обещает ни порядка, ни защиты от дублей.
|
||||||
|
- **Фрагментация.** Если IP-пакет больше допустимого размера на пути — MTU, — может потребоваться разбиение на фрагменты. Если потерялся один фрагмент, исходную датаграмму собрать уже нельзя.
|
||||||
|
|
||||||
|
Приложению неудобно разбираться с каждой такой ситуацией. Поэтому поверх IP используются транспортные протоколы. UDP сохраняет целостность отдельных сообщений, а TCP гарантирует порядок и надежность передачи.
|
||||||
|
|
||||||
|
### UDP
|
||||||
|
|
||||||
|
**UDP — User Datagram Protocol.** Мы отправляем датаграмму — отдельный массив байтов. На принимающей стороне сохраняется граница сообщений, даже вопреки фрагментации IP пакетов.
|
||||||
|
|
||||||
|
А вот **доставку, порядок и отсутствие дублей UDP не гарантирует**. Сам протокол не ждёт подтверждений и не повторяет потерянные датаграммы. Поэтому для нашей операции `PutValue` пришлось бы отдельно решать, как понять, дошёл ли запрос и что делать при потере.
|
||||||
|
|
||||||
|
Для других задач такие свойства подходят. Например, в конференции Zoom не обязательно, чтобы дошёл каждый кусочек голоса: небольшой пропуск наш мозг часто может восстановить по смыслу.
|
||||||
|
|
||||||
|
### TCP
|
||||||
|
|
||||||
|
**TCP — Transmission Control Protocol** — даёт абстракцию надёжного упорядоченного потока байтов. Можно представить канал: мы пишем в него байты, а другая сторона читает их в том же порядке, без повторов, дублей и потерь. Ниже этой абстракции все еще остается фрагментация пакетов, потери и повторы – но TCP все это скрывает от пользователя.
|
||||||
|
|
||||||
|
Сначала стороны устанавливают TCP-соединение. Каждая сторона должна сообщить ISN и другие свои параметры и получить подтверждение. Логически это четыре действия, но ответ сервера и его собственное начало обмена объединяются в одно сообщение. Получается три пакета: **SYN → SYN + ACK → ACK**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
После этого обе стороны хранят состояние: какие байты уже отправлены, какие подтверждены, какие получены и чего ещё не хватает. Соединение двунаправленное: клиент и сервер могут одновременно передавать данные, у каждого направления свой независимый поток байтов.
|
||||||
|
|
||||||
|
- **Sequence number (`seq`)** — номер первого байта в сегменте.
|
||||||
|
- **Acknowledgment number (`ack`)** — номер следующего ожидаемого байта. Он подтверждает весь непрерывный префикс перед ним.
|
||||||
|
- **Окно приёма** показывает, сколько данных получатель готов принять. Это позволяет отправлять несколько сегментов, не ожидая подтверждения каждого отдельно, но не переполнять буфер получателя. Дополнительно отправитель ограничивает объём данных в пути с учётом перегрузки сети.
|
||||||
|
|
||||||
|
Возьмём условную нумерацию с единицы. Пришли байты 4–6, а 1–3 потерялись. Получатель может сохранить 4–6 в буфере, но продолжает подтверждать, что ждёт байт 1. Отправитель обнаружит потерю по таймеру или повторным подтверждениям и повторит передачу. Когда появятся 1–3, образуется непрерывная последовательность 1–6: её можно отдать приложению, а в `ack` указать 7.
|
||||||
|
|
||||||
|
Если потерялось само подтверждение, отправитель тоже может повторить данные. Получатель узнает дубликат по номерам байтов и не отдаст его приложению второй раз. На схеме также упомянут **SACK**: он позволяет дополнительно сообщить, какие участки уже получены после пропуска.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
За порядок приходится платить ожиданием: байты 4–6 уже у нас, но приложение не увидит их, пока не придут 1–3. Это **head-of-line blocking**.
|
||||||
|
|
||||||
|
## RPC
|
||||||
|
|
||||||
|
Теперь хочется избавиться от ручной работы: не собирать сообщения `PutValue`, не разбирать ответы и не писать всю сериализацию/десериализацию сообщений самостоятельно.
|
||||||
|
|
||||||
|
Посмотрим на Storage как на обычный объект. У него есть методы `PutValue` и `GetValue`: мы передаём запрос и получаем результат. Можно сделать так, чтобы клиентский код выглядел именно как вызов метода, а отправка сообщения на сервер происходила внутри, практически прозрачно для пользователя.
|
||||||
|
|
||||||
|
Такую астракцию дает **RPC — Remote Procedure Call, удалённый вызов процедуры**. Она находится на уровень выше TCP и UDP. Пока всё работает, нам почти не приходится думать о сети. Но при ошибке нужно вспомнить, что вызов шел через сеть, и сервер мог не ответить по разным причинам.
|
||||||
|
|
||||||
|
Операцию и её параметры можно записать в JSON. Но каждый раз передавать названия полей довольно неэффективно. В нашем примере используются **Protobuf** для описания и сериализации сообщений и **gRPC** — широко используемый фреймворк удалённых вызовов. Показанный gRPC-сервис работает поверх HTTP/2 и TCP. Protobuf определяет, как представить наши данные, а gRPC связывает вызов клиентского метода с обработчиком на сервере.
|
||||||
|
|
||||||
|
## Описываем Storage в `.proto`
|
||||||
|
|
||||||
|
Откроем [storage.proto](grpc-practice/go-server/proto/storage.proto). В начале указаны версия `proto3`, пакет `storage` и настройка `go_package` для генерации Go-кода. Дальше описан сам сервис:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package storage;
|
||||||
|
|
||||||
|
option go_package = "hsegrpc/;storagepb";
|
||||||
|
|
||||||
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
|
service Storage {
|
||||||
|
rpc PutValue(PutRequest) returns (PutResponse);
|
||||||
|
rpc GetValue(GetRequest) returns (GetResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
message Value {
|
||||||
|
uint64 payload = 1;
|
||||||
|
optional google.protobuf.Timestamp updated_at = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PutRequest {
|
||||||
|
Value value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PutResponse {
|
||||||
|
uint64 value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetRequest {
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetResponse {
|
||||||
|
Value value = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Здесь `rpc` объявляет метод. `PutValue` принимает `PutRequest` и возвращает `PutResponse`; с `GetValue` всё аналогично.
|
||||||
|
|
||||||
|
`PutRequest` содержит `Value` — значение, которое хотим положить. У `Value` есть само число `payload` и время обновления `updated_at`. `GetRequest` пустой, а `GetResponse` содержит `Value`. В `PutResponse` возвращается записанное число.
|
||||||
|
|
||||||
|
**Что означают единички и двойки после полей?** Это номера, по которым поля узнаются при сериализации. Вместо строки `payload` можно передавать номер `1`, а получатель по своей схеме знает, что это за поле. Номер относится к конкретному типу сообщения: `value = 1` в `PutRequest` и `payload = 1` в `Value` друг другу не мешают. Это не значение поля и не его позиция в исходном файле.
|
||||||
|
|
||||||
|
Эти номера важны для совместимости. Допустим, мы решили убрать `updated_at` и добавить другое поле. Нельзя просто отдать новому полю номер `2`: у пользователей старой версии под этим номером всё ещё описан `Timestamp`. Новому полю нужно дать новый номер, например `3`. Тогда это будет именно новое поле, а не другое значение на месте старого.
|
||||||
|
|
||||||
|
## Из `.proto` получаем код
|
||||||
|
|
||||||
|
Из одного `.proto` можно сгенерировать код для Go, Python, C++ и других языков. На семинаре мы смотрели Go; инструкции для разных языков есть на [grpc.io](https://grpc.io/docs/languages/).
|
||||||
|
|
||||||
|
В первой строке [main.go](grpc-practice/go-server/main.go) приведена команда `protoc` для генерации. Из каталога `grpc-practice/go-server` она выглядит так:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
protoc --go_out=. --go_opt=paths=source_relative \
|
||||||
|
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
proto/storage.proto
|
||||||
|
```
|
||||||
|
|
||||||
|
Для неё нужны `protoc` и Go-плагины `protoc-gen-go`, `protoc-gen-go-grpc`. Команда создаёт два файла:
|
||||||
|
|
||||||
|
- [`storage.pb.go`](grpc-practice/go-server/proto/storage.pb.go) — структуры `Value`, `PutRequest`, `PutResponse`, `GetRequest`, `GetResponse` и вспомогательные методы;
|
||||||
|
- [`storage_grpc.pb.go`](grpc-practice/go-server/proto/storage_grpc.pb.go) — клиентский и серверный интерфейсы, а также код для работы с gRPC.
|
||||||
|
|
||||||
|
### Сообщения становятся структурами
|
||||||
|
|
||||||
|
В `storage.pb.go` каждому `message` соответствует Go-структура. Вот все типы из нашего примера; для наглядности здесь опущены служебные поля генератора, теги и вспомогательные методы:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Value struct {
|
||||||
|
Payload uint64
|
||||||
|
UpdatedAt *timestamppb.Timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
type PutRequest struct {
|
||||||
|
Value *Value
|
||||||
|
}
|
||||||
|
|
||||||
|
type PutResponse struct {
|
||||||
|
Value uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetRequest struct{}
|
||||||
|
|
||||||
|
type GetResponse struct {
|
||||||
|
Value *Value
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Можно прямо сопоставить их с `.proto`: `payload` превратился в `Payload`, `updated_at` — в `UpdatedAt`, а вложенное сообщение `Value` — в поле типа `*Value`. Поэтому запрос «положить 42» мы собираем как обычную структуру:
|
||||||
|
|
||||||
|
```go
|
||||||
|
request := &storagepb.PutRequest{
|
||||||
|
Value: &storagepb.Value{
|
||||||
|
Payload: 42,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Клиент
|
||||||
|
|
||||||
|
Наверное, самый сложный момент: **`StorageClient`**. Имея его экземпляр, пользователь может вызвать методы `PutValue` и `GetValue` сервиса `Storage`. Его интерфейс сгенерирован в `storage_grpc.pb.go` (упрощенно):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StorageClient interface {
|
||||||
|
PutValue(in *PutRequest) (*PutResponse, error)
|
||||||
|
GetValue(in *GetRequest) (*GetResponse, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Получить такой клиент позволяет сгенерированный `NewStorageClient`. Если `conn` — уже созданное gRPC-подключение, клиентский код выглядит так:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client := storagepb.NewStorageClient(conn)
|
||||||
|
|
||||||
|
response, err := client.PutValue(request)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println(response.GetValue()) // 42
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сервер
|
||||||
|
|
||||||
|
**`StorageServer`** задаёт интерфейс, который серверсу предстоит реализовать. Генератор не знает, как именно наш сервис `Storage` хранит число. Есть заготовка `UnimplementedStorageServer`: её методы отвечают, что операция не реализована. Дальше мы пишем собственные `PutValue` и `GetValue`. Здесь проходит важная граница: описание сервиса и сетевой обмен можно сгенерировать, а решение, что делать с пришедшим значением, остаётся за нами.
|
||||||
|
|
||||||
|
Вот интерфейс и один из методов заготовки из `storage_grpc.pb.go` (упрощенно):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StorageServer interface {
|
||||||
|
PutValue(*PutRequest) (*PutResponse, error)
|
||||||
|
GetValue(*GetRequest) (*GetResponse, error)
|
||||||
|
mustEmbedUnimplementedStorageServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnimplementedStorageServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedStorageServer) PutValue(
|
||||||
|
_ *PutRequest,
|
||||||
|
) (*PutResponse, error) {
|
||||||
|
return nil, status.Errorf(
|
||||||
|
codes.Unimplemented, "method PutValue not implemented",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Свою реализацию мы пишем в [storage/server.go](grpc-practice/go-server/storage/server.go). Наш `Server` включает `UnimplementedStorageServer`, хранит значение и mutex. Клиенты могут обращаться одновременно, поэтому доступ к значению защищён блокировкой:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Value struct {
|
||||||
|
payload uint64
|
||||||
|
updatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
storagepb.UnimplementedStorageServer
|
||||||
|
valueLocker sync.RWMutex
|
||||||
|
value Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer() *Server {
|
||||||
|
return &Server{}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Здесь `Value` — наша внутренняя структура для хранения числа; `storagepb.Value` из предыдущих листингов — сообщение для передачи по сети. Встраивание `UnimplementedStorageServer` даёт заготовки методов и служебный метод из интерфейса, а нужные `PutValue` и `GetValue` мы определяем у своего `Server`.
|
||||||
|
|
||||||
|
В `PutValue` приходит сгенерированный `PutRequest`. Сначала проверяем, передал ли клиент `value`: он может его не указать, и тогда `request.GetValue()` вернёт `nil`. Если поле нужно для операции, эту проверку делаем сами. Иначе записываем число, выставляем время обновления и возвращаем ответ:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) PutValue(
|
||||||
|
request *storagepb.PutRequest,
|
||||||
|
) (*storagepb.PutResponse, error) {
|
||||||
|
s.valueLocker.Lock()
|
||||||
|
defer s.valueLocker.Unlock()
|
||||||
|
|
||||||
|
if request.GetValue() == nil {
|
||||||
|
return nil, errors.New("missed value")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.value = Value{
|
||||||
|
payload: request.GetValue().GetPayload(),
|
||||||
|
updatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &storagepb.PutResponse{
|
||||||
|
Value: s.value.payload,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GetValue` берёт блокировку на чтение и возвращает текущее значение. Наш вспомогательный метод `toProto` перекладывает его в сгенерированное Protobuf-сообщение:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) GetValue(
|
||||||
|
_ *storagepb.GetRequest,
|
||||||
|
) (*storagepb.GetResponse, error) {
|
||||||
|
s.valueLocker.RLock()
|
||||||
|
defer s.valueLocker.RUnlock()
|
||||||
|
|
||||||
|
return &storagepb.GetResponse{
|
||||||
|
Value: s.value.toProto(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Value) toProto() *storagepb.Value {
|
||||||
|
return &storagepb.Value{
|
||||||
|
Payload: v.payload,
|
||||||
|
UpdatedAt: timestamppb.New(v.updatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Соединяем реализацию с gRPC-сервером
|
||||||
|
|
||||||
|
Остаётся запустить сервер, используя `main.go`. Ниже основной фрагмент запуска; `addr` — адрес, который в нашем примере по умолчанию равен `0.0.0.0:51000`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
lis, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcServer := grpc.NewServer()
|
||||||
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
|
storageService := storage.NewServer()
|
||||||
|
storagepb.RegisterStorageServer(grpcServer, storageService)
|
||||||
|
|
||||||
|
err = grpcServer.Serve(lis)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("server failed")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`net.Listen("tcp", addr)` открывает TCP-listener. `grpc.NewServer()` создаёт сервер gRPC, а `storage.NewServer()` — нашу реализацию Storage. Вызов `RegisterStorageServer(grpcServer, storageService)` связывает их: теперь gRPC знает, какому объекту передавать вызовы `PutValue` и `GetValue`. `reflection.Register` позволит инструментам вроде `grpcurl` узнать описание сервиса. Наконец, `grpcServer.Serve(lis)` начинает принимать соединения.
|
||||||
|
|
||||||
|
Получается полный путь одного запроса: клиентский метод получает `PutRequest` → сообщение сериализуется и передаётся по сети → сервер gRPC разбирает его → вызывает наш `Server.PutValue` → тот меняет значение и возвращает `PutResponse` → ответ уходит клиенту. Сериализацию и передачу обеспечивает готовый код, а изменение значения написано нами в `server.go`.
|
||||||
|
|
||||||
|
## Проверяем через grpcurl и Postman
|
||||||
|
|
||||||
|
Для этого примера нужны Go и установленный [`grpcurl`](https://github.com/fullstorydev/grpcurl). В каталоге `grpc-practice/go-server` запускаем сервер:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
В другом терминале смотрим список сервисов и описание Storage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grpcurl -plaintext localhost:51000 list
|
||||||
|
grpcurl -plaintext localhost:51000 describe storage.Storage
|
||||||
|
```
|
||||||
|
|
||||||
|
`-plaintext` отключает TLS: в этом примере работаем без шифрования. `list` показывает доступные сервисы, а `describe` — методы Storage и типы их запросов и ответов. Узнать это у работающего сервера позволяет включённый в `main.go` сервис reflection: `grpcurl` получает описание интерфейса, хотя мы не передавали ему `.proto` отдельным файлом.
|
||||||
|
|
||||||
|
Теперь положим `100500`, как в демонстрации, и прочитаем значение обратно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grpcurl -plaintext -d '{"value":{"payload":100500}}' \
|
||||||
|
localhost:51000 storage.Storage/PutValue
|
||||||
|
grpcurl -plaintext -d '{}' localhost:51000 storage.Storage/GetValue
|
||||||
|
```
|
||||||
|
|
||||||
|
В запросе повторяется структура из `.proto`: внутри `value` находится `payload`. `PutValue` возвращает записанное число, `GetValue` — текущее значение и время обновления. В терминале мы вводим JSON, потому что это удобно человеку; `grpcurl` по описанию сервиса преобразует его в Protobuf-сообщение.
|
||||||
|
|
||||||
|
У `PutValue` ожидается ответ `{"value":"100500"}`, а у `GetValue` — вложенный объект `value` с полями `payload` и `updatedAt`. Время выставляет наш сервер. Если к нему одновременно обращается другой пишущий клиент, чтение может уже показать его значение.
|
||||||
|
|
||||||
|
Если не хочется писать команды в консоли, можно использовать **Postman**, как в конце семинара: импортировать `storage.proto`, указать адрес `localhost:51000`, выбрать `PutValue`, заполнить сообщение и нажать **Invoke**. Затем вызвать `GetValue` и увидеть записанное число.
|
||||||
|
|
||||||
|
Получилось, что мы описали сервис в `.proto`, сгенерировали код и дописали небольшую реализацию — и у нас уже есть работающий сервис. Все файлы и команды находятся в [материалах практики](grpc-practice/go-server/README.md).
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
### Семинар
|
### Семинар
|
||||||
|
|
||||||
|
- [Презентация](02-communication-seminar.pptx)
|
||||||
|
- [Конспект: TCP/UDP, gRPC и Protobuf](notes.md)
|
||||||
- [Практика с gRPC](grpc-practice)
|
- [Практика с gRPC](grpc-practice)
|
||||||
- [Пример gRPC Streaming](grpc-streaming)
|
- [Пример gRPC Streaming](grpc-streaming)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
## 3. Протокол HTTP, REST и веб-сервисы.
|
||||||
|
|
||||||
|
### Лекция
|
||||||
|
|
||||||
|
- [Презентация](03-http.pdf)
|
||||||
|
- См. также материалы в конце
|
||||||
|
|
||||||
|
### Семинар
|
||||||
|
|
||||||
|
- [Материалы семинара](seminar/readme.md)
|
||||||
|
- [Пример WebSocket-сервера](seminar/websockets/README.md) — факультативный пример, который не разбирался на семинаре.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Семинар 3. HTTP на практике
|
||||||
|
|
||||||
|
На семинаре мы проследили путь HTTP-запроса: от `curl`, браузера или Python-клиента через Nginx до Flask-приложения и обратно. Ниже — основные понятия и способы повторить демонстрации на [стенде](website/docker-compose.yaml).
|
||||||
|
|
||||||
|
## HTTP: запрос и ответ
|
||||||
|
|
||||||
|
**Запрос** содержит метод, адрес ресурса (URI), версию HTTP, заголовки и, при необходимости, тело. **Ответ** содержит версию HTTP, код состояния, заголовки и тело. Пустая строка отделяет заголовки от тела.
|
||||||
|
|
||||||
|
На запущенном стенде выполните:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -v http://localhost:8080/const
|
||||||
|
```
|
||||||
|
|
||||||
|
В подробном выводе `curl` строки с `>` относятся к запросу, с `<` — к ответу, а с `*` описывают работу самого клиента. `/const` возвращает заранее заданный текст непосредственно из Nginx.
|
||||||
|
|
||||||
|
Например, при `curl -4sv http://localhost:8080/` на запущенном стенде получили такой фрагмент вывода:
|
||||||
|
|
||||||
|
```text
|
||||||
|
> GET / HTTP/1.1
|
||||||
|
> Host: localhost:8080
|
||||||
|
> User-Agent: curl/8.7.1
|
||||||
|
> Accept: */*
|
||||||
|
>
|
||||||
|
< HTTP/1.1 200 OK
|
||||||
|
< Server: nginx/1.31.6
|
||||||
|
< Content-Type: text/html; charset=utf-8
|
||||||
|
< Content-Length: 17
|
||||||
|
< Connection: keep-alive
|
||||||
|
< X-Upstream: 172.18.0.2:5002
|
||||||
|
<
|
||||||
|
Hello from app v2
|
||||||
|
```
|
||||||
|
|
||||||
|
Здесь Nginx передал запрос одному из Flask-серверов и вернул его ответ клиенту. `X-Upstream` показывает адрес выбранного сервера внутри сети Docker; при другом запуске адрес, версия приложения и версия Nginx могут отличаться. Флаги `-4` и `-s` в примере убирают попытку соединиться по IPv6 и индикатор прогресса, а `-v` показывает обмен запросом и ответом.
|
||||||
|
|
||||||
|
Заголовки, которые разбирали на занятии:
|
||||||
|
|
||||||
|
| Заголовок | Где смотреть | Что показывает |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Host` | Запрос | К какому сайту или приложению обращается клиент, в том числе когда один адрес и порт обслуживают несколько сайтов. |
|
||||||
|
| `User-Agent` | Запрос | Сведения о клиенте, например `curl`. |
|
||||||
|
| `Accept` | Запрос | Какие форматы ответа клиент готов принять. |
|
||||||
|
| `Accept-Encoding` | Запрос браузера | Какие способы сжатия ответа клиент поддерживает. |
|
||||||
|
| `Server` | Ответ | Какой сервер отправил ответ; в демонстрации через proxy виден Nginx. |
|
||||||
|
| `Content-Type` | Ответ | Формат тела ответа, например обычный текст, HTML или JSON. |
|
||||||
|
| `Connection: keep-alive` | Ответ в примере | TCP-соединение можно использовать повторно, не устанавливая его заново перед каждым запросом. |
|
||||||
|
|
||||||
|
**Браузер тоже HTTP-клиент.** Во вкладке **Network** в DevTools найдите запрос к `/const` и сравните его заголовки и ответ с выводом `curl`. Браузер может посылать дополнительные заголовки: два клиента не обязаны формировать запрос одинаково.
|
||||||
|
|
||||||
|
## HTTP запросы из кода
|
||||||
|
|
||||||
|
На примере Python-библиотеки `requests` разобрали GET-запрос, query-параметры, timeout и проверку статуса.
|
||||||
|
|
||||||
|
- **Query-параметры** — пары «ключ — значение» после `?` в URI. Передавайте их через аргумент `params`, а не собирайте URI вручную: символ `&` внутри значения иначе можно принять за разделитель параметров.
|
||||||
|
- `timeout=(1, 3)` в показанном примере задаёт 1 секунду на установление соединения и 3 секунды на ожидание данных при чтении ответа. Второе число — не общий предел времени на получение всего ответа: пока сервер регулярно присылает данные, запрос может длиться дольше. Без timeout вызов зависимого сервиса может ждать слишком долго.
|
||||||
|
- `raise_for_status()` помогает обнаружить ответ с ошибочным HTTP-статусом и обработать его в коде.
|
||||||
|
- Если после timeout или ошибки зависимого сервиса нужных данных нет, приложение может вернуть заранее предусмотренный **fallback**. На семинаре обсуждали fallback-текст и запасную картинку кота.
|
||||||
|
|
||||||
|
## Что происходит в Flask-приложении
|
||||||
|
|
||||||
|
В [приложении стенда](website/kittens/app.py) Flask связывает пути `/` и `/kittens` с функциями-обработчиками через `@app.route(...)`.
|
||||||
|
|
||||||
|
- `/` возвращает текст с версией приложения: `v1` или `v2`.
|
||||||
|
- `/kittens` запрашивает JSON у внешнего Cat API, извлекает URL изображения и подставляет его в [HTML-шаблон](website/kittens/templates/index.html) через `render_template`.
|
||||||
|
- Получив HTML, браузер делает **ещё один HTTP-запрос** за самой картинкой. Оба обращения можно увидеть в DevTools Network.
|
||||||
|
|
||||||
|
Этот пример не рассматривали как готовое production-приложение. Исходящий запрос к Cat API сделан без явного timeout и проверки HTTP-статуса. Если внешний сервис зависнет, ответит ошибкой или пришлёт неожиданные данные, нужно обработать сбой. В качестве возможного ответа на семинаре предложили заранее сохранённую картинку.
|
||||||
|
|
||||||
|
## Как устроен демонстрационный стенд
|
||||||
|
|
||||||
|
[Docker Compose](website/docker-compose.yaml) запускает два экземпляра Flask-приложения и Nginx. Запись `порт_хоста:порт_контейнера` означает, что слева указан порт для обращения с вашей машины, справа — порт внутри контейнера.
|
||||||
|
|
||||||
|
| Сервис | Внутри контейнера | С вашей машины | Назначение |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `server1` | `5001` | `9001` | Flask-приложение с `APP_VERSION=v1`. |
|
||||||
|
| `server2` | `5002` | `9002` | То же приложение с `APP_VERSION=v2`. |
|
||||||
|
| `proxy` | `80` | `8080` | Nginx: proxy, redirect и `/const`. |
|
||||||
|
| `proxy` | `10000` | `8081` | Nginx: раздача файлов из `static`. |
|
||||||
|
|
||||||
|
Compose подключает [конфигурацию Nginx](website/nginx.conf) и каталог `static` внутрь контейнера `proxy`. Суффикс `:ro` у файла конфигурации задаёт доступ только для чтения.
|
||||||
|
|
||||||
|
Чтобы поднять стенд, перейдите из корня репозитория в каталог с `docker-compose.yaml`. Понадобятся работающий Docker и команда `docker compose`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd materials/03-http/seminar/website
|
||||||
|
docker compose up --build -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
`up --build` собирает образ Flask-приложения и запускает сервисы; `-d` оставляет контейнеры работать в фоне. `docker compose ps` показывает их состояние. Современный Compose может предупредить, что поле `version` в файле устарело; это предупреждение не мешает запуску. Закончив с примерами, остановите и удалите контейнеры стенда командой `docker compose down` из того же каталога.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Что такое Docker и как в стенде связаны порты</summary>
|
||||||
|
|
||||||
|
Docker запускает приложения в контейнерах — изолированных окружениях с нужными зависимостями. Образ Flask-приложения собирается по [Dockerfile](website/kittens/Dockerfile), а для Nginx Compose использует готовый образ `nginx`. Compose запускает три контейнера, задаёт их переменные окружения и публикует нужные порты на вашей машине.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ваша машина Контейнеры
|
||||||
|
localhost:9001 ── 9001:5001 ──▶ server1:5001 (Flask v1)
|
||||||
|
localhost:9002 ── 9002:5002 ──▶ server2:5002 (Flask v2)
|
||||||
|
localhost:8080 ── 8080:80 ──▶ proxy:80 (Nginx)
|
||||||
|
└─▶ server1:5001 или server2:5002
|
||||||
|
localhost:8081 ── 8081:10000 ─▶ proxy:10000 (файлы из static)
|
||||||
|
```
|
||||||
|
|
||||||
|
Слева от `:` в Compose указан порт вашей машины, справа — порт контейнера. Когда Nginx обращается к `server1:5001` или `server2:5002`, он использует внутренние адреса сервисов, а не порты `9001` и `9002` вашей машины.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Возможности Nginx и файл его конфигурации
|
||||||
|
|
||||||
|
В этом стенде Nginx принимает запросы как единая точка входа, распределяет их между копиями приложения, возвращает redirect и сам отдаёт текст или файлы. Какую из этих задач выполнять, определяет его [конфигурация](website/nginx.conf).
|
||||||
|
|
||||||
|
**Reverse proxy** принимает запрос клиента и сам обращается к backend. В группе **upstream** стенда перечислены `server1:5001` и `server2:5002`. Для `/` и `/kittens` Nginx выбирает один из них и добавляет в ответ `X-Upstream` с адресом выбранного backend.
|
||||||
|
|
||||||
|
Если один backend перестаёт отвечать, Nginx временно исключает его из балансировки и направляет новые запросы к оставшимся живым репликам. При ошибке соединения он может попробовать другую реплику и для текущего запроса, поэтому отказ одного сервера не обязательно приводит к ошибке у клиента.
|
||||||
|
|
||||||
|
Когда мы несколько раз отправили запрос к `/` через Nginx, он по очереди направил запросы к двум Flask-серверам: ответы `v1` и `v2` чередовались. Так мы увидели **round-robin** — простой алгоритм выбора backend по очереди. Для трёх серверов порядок мог бы выглядеть так: `3 → 1 → 2 → 3 → 1 → 2`. Алгоритм не оценивает, сколько работы потребует конкретный запрос. Если серверы различаются по мощности или сетевой задержке, равное число запросов может дать им разную нагрузку; в обсуждении упоминали веса backend-серверов. При этом клиент обращается к одной точке входа — Nginx — и не выбирает реплику сам.
|
||||||
|
|
||||||
|
При **redirect** Nginx возвращает клиенту код 3xx и заголовок `Location` с новым URI. Следующий запрос по этому URI делает уже клиент; при reverse proxy Nginx обращается к backend сам. Правило `/search/` на стенде перенаправляет запрос в поиск Google: браузер следует переходу автоматически, а `curl` можно передать флаг `-L`. Как прикладной случай обсудили перенаправление со старого URI `/promotions` на новый `/discounts`.
|
||||||
|
|
||||||
|
Кроме проксирования и redirect, Nginx сам отдаёт текст по `/const` и статические файлы через порт `8081`. Для каталога `static` включён `autoindex`, поэтому в браузере можно переходить по его содержимому.
|
||||||
|
|
||||||
|
## Как повторить примеры
|
||||||
|
|
||||||
|
Команды ниже рассчитаны на запущенный [стенд](website/docker-compose.yaml). `localhost` означает машину, на которой опубликованы порты контейнеров. Флаг `-i` показывает заголовки ответа.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Посмотреть запрос, ответ и заголовки.
|
||||||
|
curl -v http://localhost:8080/const
|
||||||
|
|
||||||
|
# Обратиться напрямую к двум экземплярам Flask-приложения.
|
||||||
|
curl -i http://localhost:9001/
|
||||||
|
curl -i http://localhost:9002/
|
||||||
|
|
||||||
|
# Обратиться через Nginx; повторите команду и сравните ответ и X-Upstream.
|
||||||
|
curl -i http://localhost:8080/
|
||||||
|
|
||||||
|
# Посмотреть redirect на поиск Google, затем пройти по нему.
|
||||||
|
curl -i http://localhost:8080/search/cats
|
||||||
|
curl -L http://localhost:8080/search/cats
|
||||||
|
|
||||||
|
# Получить файл, который отдаёт сам Nginx.
|
||||||
|
curl -i http://localhost:8081/greetings.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Ещё два опыта удобно провести в браузере: откройте `http://localhost:8080/const` и сравните его запрос с `curl` во вкладке Network; затем откройте `http://localhost:9001/kittens` и найдите отдельную загрузку изображения. `/kittens` зависит от внешнего Cat API, а переход на Google — от доступности Google.
|
||||||
|
|
||||||
|
## API заказов: набросок контракта
|
||||||
|
|
||||||
|
В конце занятия мы наметили операции интернет-магазина: создать заказ, получить его данные, изменить адрес или другую часть заказа. Для создания предложили `POST`, для чтения — `GET`, для частичного изменения — `PATCH`; в зависимости от контракта приложение может использовать `PUT`. Упомянули и системы, где для упрощения чтение реализуют через `POST` с телом запроса, но не представляли это как универсальное правило.
|
||||||
|
|
||||||
|
В результате можно предложить следующий контракт:
|
||||||
|
|
||||||
|
| Операция | Запрос | Успешный ответ | Почему так |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Создать заказ | `POST /api/orders` с данными заказа в JSON | `201 Created`, заголовок `Location` с URI заказа | Запрос отправляют коллекции заказов; сервер обрабатывает данные и создаёт новый заказ с собственным ID. |
|
||||||
|
| Получить заказ | `GET /api/orders/{id}` | `200 OK` и данные заказа в JSON | Клиент обращается к уже известному заказу по его ID и читает данные, не создавая новую сущность. |
|
||||||
|
| Частично изменить заказ | `PATCH /api/orders/{id}` с JSON, например с новым адресом | `200 OK` и обновлённый заказ в JSON | Клиент указывает существующий заказ и передаёт только те данные, которые нужно изменить. |
|
||||||
|
| Удалить заказ | `DELETE /api/orders/{id}` | `204 No Content` | Клиент указывает конкретный заказ, который нужно удалить; тело успешного ответа не требуется. |
|
||||||
|
|
||||||
|
Если заказ не найден, для чтения, изменения или удаления предусмотрен `404 Not Found`; если данные для создания неверны — ошибка клиента, например `400 Bad Request`. Для долгой обработки допускается отдать `202 Accepted` и отдельный ресурс операции.
|
||||||
|
|
||||||
|
### Повторы запросов и идемпотентность
|
||||||
|
|
||||||
|
`GET` только читает заказ, поэтому его повтор не создаёт новую сущность. С `POST /api/orders` иначе: если клиент не получил ответ и повторил запрос, он может случайно создать ещё один заказ. Сервису нужно понять, относится ли повтор к той же логической операции. Для этого можно использовать токен идемпотентности: повтор с тем же токеном сервис распознаёт и не создаёт второй заказ. На семинаре обсудили два способа получить такой идентификатор.
|
||||||
|
|
||||||
|
- **Ключ создаёт клиент.** Например, фронтенд генерирует токен и передаёт его в заголовке или в JSON-теле запроса. Сервис сохраняет токен, чтобы определить возможные повторы запросов. Если ключ нужен лишь на время нескольких попыток и позже клиент его не воспроизводит, хранить его бессрочно незачем: подойдёт кэш на стороне сервиса с ограниченным сроком жизни, например несколько минут. Важно, чтобы на протяжении этого срока повторные попытки запроса приходили с тем же ключом.
|
||||||
|
- **Ключ создаёт сервис.** Если предметная область такова, что у сущности имеется некоторый уникальный идентификатор (например, `order.id` в случае создания заказа), то сервис может использовать этот ID как основу ключа идемпотентности. Поскольку `order.id` скорее всего хранится в базе вместе с заказом, такой ключ идемпотентности может жить столько же, сколько запись о заказе; отдельное короткое время жизни, как у временного ключа в кэше, ему не обязательно.
|
||||||
|
|
||||||
|
Таким образом, выбор способа и срока хранения ключа идемпотентности зависит от контракта API и предметной области.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
server1:
|
||||||
|
build:
|
||||||
|
context: ./kittens
|
||||||
|
environment:
|
||||||
|
APP_VERSION: 'v1'
|
||||||
|
BIND_PORT: 5001
|
||||||
|
ports:
|
||||||
|
- 9001:5001
|
||||||
|
|
||||||
|
server2:
|
||||||
|
build:
|
||||||
|
context: ./kittens
|
||||||
|
environment:
|
||||||
|
APP_VERSION: 'v2'
|
||||||
|
BIND_PORT: 5002
|
||||||
|
ports:
|
||||||
|
- 9002:5002
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
image: nginx
|
||||||
|
ports:
|
||||||
|
- 8080:80
|
||||||
|
- 8081:10000
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./static:/static
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
docker-compose.yaml
|
||||||
|
Dockerfile
|
||||||
|
__pycache__
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.11.5-slim-bullseye
|
||||||
|
|
||||||
|
WORKDIR /application
|
||||||
|
|
||||||
|
# Copy the requirements file and download the dependencies.
|
||||||
|
COPY requirements.txt requirements.txt
|
||||||
|
RUN pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
# Copy other data.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD [ "python3", "app.py"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from flask import Flask, render_template, request
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
APP_VERSION = os.getenv('APP_VERSION')
|
||||||
|
BIND_HOST = os.getenv('BIND_HOST') or '0.0.0.0'
|
||||||
|
BIND_PORT = os.getenv('BIND_PORT') or '8000'
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def log_request():
|
||||||
|
app.logger.info(
|
||||||
|
'method=%s path=%s host=%s remote_addr=%s',
|
||||||
|
request.method, request.path, request.host, request.remote_addr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def home():
|
||||||
|
return f'Hello from app {APP_VERSION}'
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/kittens')
|
||||||
|
def kittens():
|
||||||
|
# Get a URL to a random kitten photo.
|
||||||
|
response = requests.get('https://api.thecatapi.com/v1/images/search?api_')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Response structure is as follows:
|
||||||
|
# [{"id":"bL3lrUi1A","url":"ex.com/bL3lrUi1A.jpg","width":1280,"height":720}]
|
||||||
|
data = response.json()
|
||||||
|
kitten_url = data[0]['url']
|
||||||
|
|
||||||
|
return render_template('index.html', kitten_url=kitten_url)
|
||||||
|
except Exception as e:
|
||||||
|
return f'Failed to fetch a kitten image :(\n {e}'
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host=BIND_HOST, port=BIND_PORT)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Flask==2.3.3
|
||||||
|
requests==2.28.2
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>DistSys kittens</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="{{ kitten_url }}">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
events {}
|
||||||
|
|
||||||
|
http {
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name distsys.baliuk.me;
|
||||||
|
|
||||||
|
location /kittens {
|
||||||
|
proxy_pass http://backends;
|
||||||
|
add_header X-Upstream $upstream_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = / {
|
||||||
|
proxy_pass http://backends;
|
||||||
|
add_header X-Upstream $upstream_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /search/ {
|
||||||
|
rewrite ^/search/(.*)$ https://google.com/search?q=$1 redirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /const {
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 "Any preset text!";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream backends {
|
||||||
|
server server1:5001;
|
||||||
|
server server2:5002;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 10000;
|
||||||
|
|
||||||
|
autoindex on; # enable directory listing output
|
||||||
|
autoindex_exact_size off; # output file sizes rounded to kilobytes, megabytes, and gigabytes
|
||||||
|
autoindex_localtime on; # output local times in the directory
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello, distributed systems!
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style="background-color:#fff" viewBox="-.5 -.5 310 191">
|
||||||
|
<rect width="100%" height="100%" fill="#FFF"/>
|
||||||
|
<rect width="297" height="87" x="7" y="58" fill="#e6e6e6" pointer-events="all" rx="6.96" ry="6.96"/>
|
||||||
|
<g stroke="#000" stroke-miterlimit="10">
|
||||||
|
<path fill="none" d="M32.62 58.64 30.9 30" pointer-events="stroke"/>
|
||||||
|
<path d="m32.93 63.88-2.75-6.84 2.44 1.6 2.22-1.88Z" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
<path fill="none" d="M0 0h60v30H0z" pointer-events="all"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:58px;height:1px;padding-top:15px;margin-left:1px">
|
||||||
|
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
|
||||||
|
<div style="font-size:16px">Method</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="30" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Method</text>
|
||||||
|
</switch>
|
||||||
|
<path fill="none" d="M56 0h60v30H56z" pointer-events="all"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:58px;height:1px;padding-top:15px;margin-left:57px">
|
||||||
|
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">Path</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="86" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Path</text>
|
||||||
|
</switch>
|
||||||
|
<path fill="none" d="M113 0h122v30H113z" pointer-events="all"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:120px;height:1px;padding-top:15px;margin-left:114px">
|
||||||
|
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">Protocol version</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="174" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Protocol version</text>
|
||||||
|
</switch>
|
||||||
|
<path fill="none" d="M127 161h60v30h-60z" pointer-events="all"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:58px;height:1px;padding-top:176px;margin-left:128px">
|
||||||
|
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
|
||||||
|
<div style="font-size:16px">Headers</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="157" y="181" font-family="Helvetica" font-size="16" text-anchor="middle">Headers</text>
|
||||||
|
</switch>
|
||||||
|
<g stroke="#000" stroke-miterlimit="10">
|
||||||
|
<path fill="none" d="M79.45 58.2 83.73 30" pointer-events="stroke"/>
|
||||||
|
<path d="m78.67 63.39-1.26-7.27 2.04 2.08 2.57-1.38Z" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#000" stroke-miterlimit="10">
|
||||||
|
<path fill="none" d="M153.71 58.24 166.96 30" pointer-events="stroke"/>
|
||||||
|
<path d="m151.48 62.99.86-7.33 1.37 2.58 2.85-.6Z" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
<rect width="36" height="21" x="15" y="65" fill="#f2fffc" pointer-events="all" rx="1.68" ry="1.68"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:76px;margin-left:19px">
|
||||||
|
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:left">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">
|
||||||
|
<p style="line-height:100%">
|
||||||
|
<font color="#509ad4" style="font-size:16px">GET</font>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="19" y="80" fill="#333" font-family="Monospace" font-size="16">GET</text>
|
||||||
|
</switch>
|
||||||
|
<rect width="17" height="21" x="70" y="64.5" fill="#f5f5ff" pointer-events="all" rx="1.36" ry="1.36"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:75px;margin-left:74px">
|
||||||
|
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:left">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">
|
||||||
|
<p style="line-height:100%">/</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="74" y="80" fill="#333" font-family="Monospace" font-size="16">/</text>
|
||||||
|
</switch>
|
||||||
|
<rect width="100" height="22" x="101" y="64" fill="#f5ebef" pointer-events="all" rx="1.76" ry="1.76"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:75px;margin-left:105px">
|
||||||
|
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:left">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">
|
||||||
|
<p style="line-height:100%">
|
||||||
|
<font color="#5d837e" style="font-size:16px">HTTP</font>/<font color="#cd5b92" style="font-size:16px">1.1</font>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="105" y="80" fill="#333" font-family="Monospace" font-size="16">HTTP/1.1</text>
|
||||||
|
</switch>
|
||||||
|
<rect width="284" height="48" x="15" y="90" fill="#cfe3f5" pointer-events="all" rx="3.84" ry="3.84"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:278px;height:1px;padding-top:114px;margin-left:19px">
|
||||||
|
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:left">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
|
||||||
|
<div style="font-size:16px">
|
||||||
|
<font color="#5d837e" style="font-size:16px">Host</font>: developer.mozilla.org</div>
|
||||||
|
<div style="font-size:16px">
|
||||||
|
<font color="#5d837e" style="font-size:16px">Accept-Language</font>: fr</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="19" y="119" fill="#333" font-family="Monospace" font-size="16">Host: developer.mozilla.org...</text>
|
||||||
|
</switch>
|
||||||
|
<g stroke="#000" stroke-miterlimit="10">
|
||||||
|
<path fill="none" d="M157 144.37V161" pointer-events="stroke"/>
|
||||||
|
<path d="m157 139.12 2.33 7-2.33-1.75-2.33 1.75Z" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,121 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style="background-color:#fff" viewBox="-0.5 -0.5 422 205">
|
||||||
|
<rect width="100%" height="100%" fill="#FFF"/>
|
||||||
|
<rect width="381" height="105" x="30" y="51" fill="#e6e6e6" pointer-events="all" rx="3.15" ry="3.15"/>
|
||||||
|
<path fill="none" d="M233 0h137v30H233z" pointer-events="all"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:135px;height:1px;padding-top:15px;margin-left:234px">
|
||||||
|
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">Status message</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="302" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Status message</text>
|
||||||
|
</switch>
|
||||||
|
<path fill="none" d="M137 0h94v30h-94z" pointer-events="all"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:92px;height:1px;padding-top:15px;margin-left:138px">
|
||||||
|
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">Status code</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="184" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Status code</text>
|
||||||
|
</switch>
|
||||||
|
<path fill="none" d="M0 0h118v30H0z" pointer-events="all"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:116px;height:1px;padding-top:15px;margin-left:1px">
|
||||||
|
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">Protocol version</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="59" y="20" font-family="Helvetica" font-size="16" text-anchor="middle">Protocol version</text>
|
||||||
|
</switch>
|
||||||
|
<path fill="none" d="M192 175h60v30h-60z" pointer-events="all"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:58px;height:1px;padding-top:190px;margin-left:193px">
|
||||||
|
<div data-drawio-colors="color: rgb(0, 0, 0);" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
|
||||||
|
<div style="font-size:16px">Headers</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="222" y="195" font-family="Helvetica" font-size="16" text-anchor="middle">Headers</text>
|
||||||
|
</switch>
|
||||||
|
<rect width="36" height="21" x="197" y="56" fill="#f2fffc" pointer-events="all" rx="1.68" ry="1.68"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:66px;margin-left:215px">
|
||||||
|
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">OK</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="215" y="71" fill="#333" font-family="Monospace" font-size="16" text-anchor="middle">OK</text>
|
||||||
|
</switch>
|
||||||
|
<rect width="42" height="21" x="139" y="56" fill="#f5f5ff" pointer-events="all" rx="1.68" ry="1.68"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:66px;margin-left:160px">
|
||||||
|
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">200</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="160" y="71" fill="#333" font-family="Monospace" font-size="16" text-anchor="middle">200</text>
|
||||||
|
</switch>
|
||||||
|
<rect width="92" height="22" x="38" y="55" fill="#f5ebef" pointer-events="all" rx="1.76" ry="1.76"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:66px;margin-left:84px">
|
||||||
|
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:center">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:nowrap">
|
||||||
|
<p style="line-height:100%">
|
||||||
|
<font color="#5d837e" style="font-size:16px">HTTP</font>/<font color="#cd5b92" style="font-size:16px">1.1</font>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="84" y="71" fill="#333" font-family="Monospace" font-size="16" text-anchor="middle">HTTP/1.1</text>
|
||||||
|
</switch>
|
||||||
|
<rect width="368" height="63" x="38" y="83" fill="#cfe3f5" pointer-events="all" rx="1.89" ry="1.89"/>
|
||||||
|
<switch transform="translate(-.5 -.5)">
|
||||||
|
<foreignObject width="100%" height="100%" pointer-events="none" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow:visible;text-align:left">
|
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:362px;height:1px;padding-top:115px;margin-left:42px">
|
||||||
|
<div data-drawio-colors="color: #333333;" style="box-sizing:border-box;font-size:0;text-align:left">
|
||||||
|
<div style="display:inline-block;font-size:16px;font-family:monospace;color:#333;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
|
||||||
|
<div style="font-size:16px">
|
||||||
|
<font color="#5d837e" style="font-size:16px">date</font>: Tue, 18 Jun 2024 10:03:55 GMT</div>
|
||||||
|
<div style="font-size:16px">
|
||||||
|
<font color="#5d837e" style="font-size:16px">cache-control</font>: public, max-age=3600</div>
|
||||||
|
<div style="font-size:16px">
|
||||||
|
<font color="#5d837e" style="font-size:16px">content-type</font>: text/html</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
<text x="42" y="119" fill="#333" font-family="Monospace" font-size="16">date: Tue, 18 Jun 2024 10:03:55 GMT...</text>
|
||||||
|
</switch>
|
||||||
|
<g stroke="#000" stroke-miterlimit="10">
|
||||||
|
<path fill="none" d="M222 152.37V175" pointer-events="stroke"/>
|
||||||
|
<path d="m222 147.12 2.33 7-2.33-1.75-2.33 1.75Z" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#000" stroke-miterlimit="10">
|
||||||
|
<path fill="none" d="M229.63 53.02 273.15 30" pointer-events="stroke"/>
|
||||||
|
<path d="m224.99 55.48 5.09-5.34-.45 2.88 2.64 1.25Z" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#000" stroke-miterlimit="10">
|
||||||
|
<path fill="none" d="m163.22 50.5 12-20.5" pointer-events="stroke"/>
|
||||||
|
<path d="m160.56 55.04 1.53-7.22 1.13 2.68 2.89-.33Z" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#000" stroke-miterlimit="10">
|
||||||
|
<path fill="none" d="M60.68 48.64 59.75 30" pointer-events="stroke"/>
|
||||||
|
<path d="m60.94 53.88-2.68-6.87 2.42 1.63 2.25-1.86Z" pointer-events="all"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
# websockets
|
||||||
|
|
||||||
|
В данной директории находится простая реализация websocket сервера на Go.
|
||||||
|
|
||||||
|
## Управление
|
||||||
|
|
||||||
|
Запуск сервера: `go run .`.
|
||||||
|
|
||||||
|
Для подключения к серверу предлагается использовать утилиту `wscat`:
|
||||||
|
```bash
|
||||||
|
wscat --connect 'localhost:8000/connect?author=igor'
|
||||||
|
# В параметре author указываем наш никнейм
|
||||||
|
```
|
||||||
|
|
||||||
|
Далее можно отправить JSON сообщение с полем `text`, после чего это сообщение будет доставлено всем
|
||||||
|
клиентам сервера:
|
||||||
|
```bash
|
||||||
|
~ wscat --connect 'localhost:8000/connect?author=igor'
|
||||||
|
> {"text": "privet"}
|
||||||
|
< {"Text":"privet","Author":"igor","SendTime":"2024-09-24T01:49:50.623671+03:00"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Бонус
|
||||||
|
|
||||||
|
Чтобы открыть свой сервер, запущенный локально, всему интернету,
|
||||||
|
можно использовать ngrok:
|
||||||
|
```
|
||||||
|
ngrok http 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
После выполнения вам будет выдан публичный домен, по которому будет доступен
|
||||||
|
порт на вашей локальной системе.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module ws
|
||||||
|
|
||||||
|
go 1.20
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.3.1
|
||||||
|
github.com/gorilla/websocket v1.5.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||||
|
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
Connect client 1:
|
||||||
|
|
||||||
|
wscat --connect 'localhost:8000/connect?author=lupa'
|
||||||
|
|
||||||
|
Connect client 2:
|
||||||
|
|
||||||
|
wscat --connect 'localhost:8000/connect?author=lupa'
|
||||||
|
Message: {"Text": "hi there"}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageStream struct {
|
||||||
|
Text string
|
||||||
|
Author string
|
||||||
|
SendTime *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessengerServer struct {
|
||||||
|
history chan *MessageStream
|
||||||
|
toSend map[string]chan *MessageStream
|
||||||
|
}
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MessengerServer) Connect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Extract author from the URL query parameters
|
||||||
|
author := r.URL.Query().Get("author")
|
||||||
|
if author == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to upgrade: %s\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.New()
|
||||||
|
s.toSend[id.String()] = make(chan *MessageStream, 1000)
|
||||||
|
log.Printf("Connected: %v\n", id)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
var msg MessageStream
|
||||||
|
err := conn.ReadJSON(&msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Failed to decode a message:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Received from %s: %v\n", author, msg)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
msg.SendTime = &now
|
||||||
|
msg.Author = author
|
||||||
|
s.history <- &msg
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
mes := <-s.toSend[id.String()]
|
||||||
|
err := conn.WriteJSON(mes)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Deleted %v stream, sending error: %v", id.String(), err)
|
||||||
|
delete(s.toSend, id.String())
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Sent to stream %v: %v\n", id, mes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MessengerServer) fanout() {
|
||||||
|
for {
|
||||||
|
mes := <-s.history
|
||||||
|
|
||||||
|
log.Printf("Got from history: %v", mes)
|
||||||
|
|
||||||
|
for _, ch := range s.toSend {
|
||||||
|
ch <- mes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := os.Getenv("MESSENGER_SERVER_PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8000"
|
||||||
|
log.Println("Missing MESSENGER_SERVER_PORT, using default value: " + port)
|
||||||
|
}
|
||||||
|
server := MessengerServer{
|
||||||
|
history: make(chan *MessageStream, 1000),
|
||||||
|
toSend: make(map[string]chan *MessageStream),
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/connect", server.Connect)
|
||||||
|
|
||||||
|
go server.fanout()
|
||||||
|
|
||||||
|
log.Fatal(http.ListenAndServe("0.0.0.0:"+port, nil))
|
||||||
|
}
|
||||||