Compare commits

..
1 Commits
Author SHA1 Message Date
syakovlev 11f14108b4 Add sem2 summary 2026-09-23 00:35:36 +03:00
83 changed files with 0 additions and 6224 deletions
-5
View File
@@ -1,5 +0,0 @@
target
**/target
**/__pycache__
**/.pytest_cache
*.pyc
@@ -1,125 +0,0 @@
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
-296
View File
@@ -1,296 +0,0 @@
# Мессенджер на 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).
@@ -1 +0,0 @@
# TODO: Write Docker file for client
@@ -1,5 +0,0 @@
syntax = "proto3";
package mes_grpc;
// TODO: Add messages and service
@@ -1 +0,0 @@
# TODO: Write Docker file for server
@@ -1,11 +0,0 @@
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"]
@@ -1,12 +0,0 @@
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
)
@@ -1,80 +0,0 @@
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)
}
}
@@ -1,3 +0,0 @@
module github.com/distsys-course/grpc-messenger/grpc
go 1.23
@@ -1,7 +0,0 @@
syntax = "proto3";
package mes_grpc;
option go_package = "proto/;mes_grpc";
// TODO: Add messages and service
@@ -1,60 +0,0 @@
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)
@@ -1,3 +0,0 @@
aiohttp==3.12.15
grpcio==1.75.0
grpcio-tools==1.75.0
@@ -1,11 +0,0 @@
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"]
@@ -1,102 +0,0 @@
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()
@@ -1,2 +0,0 @@
grpcio==1.75.0
grpcio-tools==1.75.0
@@ -1,5 +0,0 @@
syntax = "proto3";
package mes_grpc;
// TODO: Add messages and service
@@ -1,21 +0,0 @@
# 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"]
-109
View File
@@ -1,109 +0,0 @@
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())
@@ -1,5 +0,0 @@
grpcio==1.75.0
grpcio-tools==1.75.0
protobuf==6.32.1
pytest==8.4.2
requests==2.32.5
@@ -1,179 +0,0 @@
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
@@ -1,140 +0,0 @@
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'),
)
@@ -1,285 +0,0 @@
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
-236
View File
@@ -1,236 +0,0 @@
# 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 балл.
@@ -1,10 +0,0 @@
FROM python:3.12-slim
WORKDIR /http/server
COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt
COPY . .
ENTRYPOINT ["python3", "server.py"]
@@ -1,124 +0,0 @@
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",
}
@@ -1 +0,0 @@
click==8.3.0
@@ -1,86 +0,0 @@
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()
@@ -1,7 +0,0 @@
target
**/target
**/__pycache__
**/.pytest_cache
*.pyc
tmp/
tests.log
-1
View File
@@ -1 +0,0 @@
tmp
-18
View File
@@ -1,18 +0,0 @@
# 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"]
-124
View File
@@ -1,124 +0,0 @@
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)
}
@@ -1,60 +0,0 @@
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)
}
}
}
})
}
}
-301
View File
@@ -1,301 +0,0 @@
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
}
@@ -1,144 +0,0 @@
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)
}
}
}
}
-128
View File
@@ -1,128 +0,0 @@
#!/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
-233
View File
@@ -1,233 +0,0 @@
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:]
}
}
-73
View File
@@ -1,73 +0,0 @@
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
}
@@ -1,44 +0,0 @@
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")
}
}
-49
View File
@@ -1,49 +0,0 @@
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)
}
@@ -1,49 +0,0 @@
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
}
}
-16
View File
@@ -1,16 +0,0 @@
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
)
-28
View File
@@ -1,28 +0,0 @@
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=
-309
View File
@@ -1,309 +0,0 @@
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("==================================================================================")
}
@@ -1,119 +0,0 @@
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")
}
})
}
}
@@ -1,2 +0,0 @@
{{- /*gotype: hw3test.RunOpts */ -}}
python3 ../solution/server.py {{.CommandLineArgs}}
@@ -1,2 +0,0 @@
{{- /*gotype: hw3test.RunOpts */ -}}
docker run --memory=128m --memory-swap=128m --memory-swappiness=0 --rm {{.DockerVolumeArgs}} {{.DockerPortArgs}} {{.DockerEnvArgs}} hw3img {{.DockerCommandLineArgs}}
-94
View File
@@ -1,94 +0,0 @@
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()
}
-424
View File
@@ -1,424 +0,0 @@
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
}
-146
View File
@@ -1,146 +0,0 @@
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
}
@@ -1,56 +0,0 @@
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:
}
})
}
}
@@ -1,269 +0,0 @@
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")
}
}
-303
View File
@@ -1,303 +0,0 @@
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)
}
@@ -1,38 +0,0 @@
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)
}
})
}
}
@@ -1,92 +0,0 @@
"""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()
-103
View File
@@ -1,103 +0,0 @@
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
}
-308
View File
@@ -1,308 +0,0 @@
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
}
@@ -1,15 +0,0 @@
#!/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
-1
View File
@@ -7,7 +7,6 @@
### Семинар
- [Презентация](02-communication-seminar.pptx)
- [Конспект: TCP/UDP, gRPC и Protobuf](notes.md)
- [Практика с gRPC](grpc-practice)
- [Пример gRPC Streaming](grpc-streaming)
Binary file not shown.
-11
View File
@@ -1,11 +0,0 @@
## 3. Протокол HTTP, REST и веб-сервисы.
### Лекция
- [Презентация](03-http.pdf)
- См. также материалы в конце
### Семинар
- [Материалы семинара](seminar/readme.md)
- [Пример WebSocket-сервера](seminar/websockets/README.md) — факультативный пример, который не разбирался на семинаре.
-172
View File
@@ -1,172 +0,0 @@
# Семинар 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 и предметной области.
@@ -1,30 +0,0 @@
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
@@ -1,3 +0,0 @@
docker-compose.yaml
Dockerfile
__pycache__
@@ -1,12 +0,0 @@
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"]
@@ -1,45 +0,0 @@
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)
@@ -1,2 +0,0 @@
Flask==2.3.3
requests==2.28.2
@@ -1,8 +0,0 @@
<html>
<head>
<title>DistSys kittens</title>
</head>
<body>
<img src="{{ kitten_url }}">
</body>
</html>
@@ -1,49 +0,0 @@
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;
}
}
}
@@ -1 +0,0 @@
Hello, distributed systems!
@@ -1,127 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 9.1 KiB

@@ -1,121 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -1,32 +0,0 @@
# 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
```
После выполнения вам будет выдан публичный домен, по которому будет доступен
порт на вашей локальной системе.
@@ -1,8 +0,0 @@
module ws
go 1.20
require (
github.com/google/uuid v1.3.1
github.com/gorilla/websocket v1.5.0
)
@@ -1,4 +0,0 @@
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=
@@ -1,118 +0,0 @@
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))
}