Дизайн-документ будущего серверного режима (issue #140, #156, #157). Не реализация: описывает целевые границы, контракты и требования безопасности, не меняя текущий Python-код. Решение «двигаться к server mode» и его альтернативы зафиксированы отдельно как ADR-0001; этот документ — техническая спецификация, на которую ADR ссылается.
Контекст на сегодня: единственный сетевой слой — локальный
--serve(127.0.0.1, без OS-sandbox), см. web-current.md § Безопасность и SECURITY.md § Server / IDE-режим. Форма результата, которую сервер обязан сохранить, — в result-contract.md.
- Зачем и границы
- Runner-слой (issue #140, реализация — #136/#137/#138)
- Контракт API удалённого исполнения (issue #156)
- Sandbox и сетевая изоляция (issue #157)
- Фазовая миграция
- Non-goals
Локальный грейдер запускает доверенный код (свой или скачанный из Stepik as-is) без изоляции ФС/сети — это by design (configuration.md § Ограничения и безопасность). Любой переход к исполнению чужого кода (общий сервер, удалённый прогон, онлайн-проверка) меняет threat model и требует настоящего sandbox. Server mode — это именно такой переход, поэтому он проектируется отдельно и не включается по умолчанию.
Ключевой инвариант, переносимый из локального дизайна: ядро остаётся
библиотекой. Server mode — ещё один адаптер над core/* (как CLI и Web),
а не переписывание грейдинга. DAG остаётся ацикличным
(architecture.md).
Статус:
Runner/LocalRunnerреализованы —src/stepik_grader/core/runner.py.grader_core.run_single_test()делегирует subprocess-запускLocalRunnerбез изменения поведения. Ниже — контракт как есть в коде (не только дизайн).
Раньше исполнение было размазано между core/grader_core.py (subprocess
одного кейса) и core/microbench_runner.py (timeit). Runner — явная абстракция запуска,
чтобы сменить механизм изоляции (будущий SandboxRunner), не трогая грейдинг.
Слой (реализовано в core/runner.py):
grader_core.run_single_test(...) → Runner.run(spec) -> RunOutcome
RunSpec— что запустить: путь решения (или сгенерированного wrapper-скрипта — режим stdin/function определяется доRunner, вgrader_core.py), stdin,timeout,measure_memory,max_memory_mb.RunOutcome— сырой итог запуска:stdout/stderr(bytes),returncode,elapsed,peak_memory_mb,timed_out,launch_error(заполнен приOSErrorна spawn). Маппится в case result (result-contract.md) выше по стеку (grader_core.py) — самRunnerвердиктов не выносит.
Иерархия реализаций:
| Runner | Изоляция | Статус | Где уместен |
|---|---|---|---|
LocalRunner |
subprocess + таймаут + best-effort лимит памяти (POSIX) | реализован (issue #138) | локальный CLI/Web (доверенный код) |
SandboxRunner |
ОС-уровень: неймспейсы/seccomp/квоты, сеть выключена, tmp-каталог | дизайн, issue #157; локальный MVP реализован, issue #266 (--sandbox, core/sandbox/) |
server mode (недоверенный код) / локальный opt-in CLI |
Локальный MVP уже есть (issue #266) —
core/sandbox/реализует bubblewrap (Linux) /sandbox-exec(macOS) / Job Objects (Windows) за флагом--sandbox, тем же паттерном, что async job model (issue #262) выше: покрывает часть требований этого раздела, но не заменяет будущий сетевой server mode — работает только локально, без аутентификации/multi-tenancy/очереди. Полная таблица гарантий по ОС (асимметрия — не баг) и явные пробелы (нет сетевой изоляции на Windows, нет строгой ФС-изоляции на Windows, nsjail-fallback на Linux не реализован) — SECURITY.md §--sandbox.
Границы SandboxRunner (что он гарантирует, а что нет):
- Гарантирует: сеть недоступна из исполняемого кода на Linux/macOS (на Windows — нет, см. ниже); запись только во временный каталог задачи; жёсткие лимиты CPU-времени, wall-времени, памяти, размера вывода и числа процессов; уборка временного каталога после прогона.
- Не гарантирует и явно вне слоя: защита от side-channel/timing-атак, полная защита от эскалации ядра (это ответственность выбранного механизма изоляции — контейнер/VM), корректность самих тест-кейсов.
Ограничения на Windows (обновлено issue #266). Изначально этот раздел
предполагал, что POSIX-специфичные механизмы (неймспейсы, seccomp,
resource-квоты, SIGALRM) не имеют Windows-аналога и потребуют внешнего
backend'а (контейнер/микро-VM/WSL). На практике нашёлся нативный
Windows-примитив — Job Objects (CreateJobObjectW/
SetInformationJobObject/AssignProcessToJobObject) — даёт реальный
kernel-enforced лимит памяти/CPU-времени/числа процессов без внешнего
backend'а (core/sandbox/_windows.py, issue #266 --sandbox). Не закрыто:
сетевой изоляции на Windows в этом MVP нет (AppContainer непропорционально
сложен для per-run профиля — см. SECURITY.md), поэтому server mode с
недоверенным кодом на Windows по-прежнему не поддерживается — локальный
--sandbox не эквивалентен требованиям server mode этого документа (пункт
6 «Изоляция по клиентам» выше требует и сетевую изоляцию тоже).
LocalRunner-путь (без --sandbox) на Windows остаётся как был: точного
внутрипроцессного таймаута через SIGALRM нет (защита — только внешний
subprocess.run(timeout=...), см. core/runner.py), лимит памяти —
best-effort лишь на POSIX.
Реализация не привязана к Docker. SandboxRunner — интерфейс; конкретный
backend (контейнер, nsjail/bubblewrap, микро-VM, внешний сервис) — решение
этапа реализации. Docker в этом документе не выбирается и не требуется;
цель — чтобы грейдинг зависел от абстракции Runner, а не от конкретного
sandbox-механизма. Любая тяжёлая зависимость/демон — только по явному решению
(см. запрет в ../CLAUDE.md на новые зависимости).
Инварианты слоя:
Runnerживёт вcore/как протокол;LocalRunner— рефактор текущего subprocess-пути без изменения поведения (регрессий в вердиктах быть не должно).- Ни
core/grader_core.py, ни адаптеры (CLI/Web/API) не знают, какой Runner активен — выбор инжектируется (конфиг/DI), поведение результата одинаково. SandboxRunnerне ослабляет контракт результата: тот же case result, плюс аддитивный вердиктSANDBOX_VIOLATIONдля sandbox-нарушения — уже реализован (core/result.py, локальный--sandbox#266; правило 3 контракта).
Дизайн/контракт, не сервер. Ниже — форма запроса/ответа, жизненный цикл и классы ошибок для будущего HTTP API. Реализация API-сервера не входит в эту работу.
Локальный MVP уже есть (issue #262) —
POST /api/v1/runs+GET /api/v1/runs/{id}реализованы в--serve(web/runs.py/web/server.py) для bench/microbench, но это не сетевой сервер из этого раздела: in-memory реестр job'ов одного процесса, без sandbox/квот/аутентификации. Два сознательных отклонения от спекулятивного контракта ниже: (а) результат приходит ИНЛАЙН вGET /api/v1/runs/{id}(полеresult), а не через отдельныйGET /api/v1/runs/{id}/result; (б) словарь статусов —queued|running|done|error|cancelled: отмена — отдельный терминальный статусcancelled(issue #296, дополнительноmessage_id="run_cancelled"); единственное сознательное отклонение от спекулятивного контракта ниже —errorвместоfailedдля инфраструктурного сбоя. Полная документация/api/v1/runs— docs/api.md § POST /api/v1/runs.
Исполнение недоверенного кода может быть медленным и требует очереди — контракт асинхронный, а не «запрос-ответ на лету»:
POST /api/v1/runs → 202 Accepted, { "run_id", "status": "queued" }
GET /api/v1/runs/{id} → { "status": "queued|running|done|failed", ... }
GET /api/v1/runs/{id}/result → RunResult (когда status=done)
status:queued → running → done(успешное завершение прогона, даже если вердикты WA/RE) либоfailed(инфраструктурный сбой, не провал решения).- Результат в
done— тот же Run/Solution/Case result, сериализованный в JSON. Вердикты AC/WA/TLE/RE решения не являютсяfailed: провал решения — успешный прогон.
Транспортные ошибки отделены от вердиктов решения:
| Класс | HTTP | Когда |
|---|---|---|
validation_error |
400 | Битый запрос: неизвестный mode/language, лимиты вне диапазона |
not_found |
404 | Неизвестный run_id/test_set_id |
quota_exceeded |
429 | Превышены квоты клиента (частота/параллелизм) |
sandbox_violation |
200 (в результате) | Код нарушил sandbox (сеть/запись/форк-бомба) — это вердикт, не HTTP-ошибка |
internal_error |
500 | Сбой инфраструктуры (не вина решения) → status=failed |
sandbox_violation сознательно попадает в тело результата как вердикт
(аддитивно к AC/WA/TLE/RE), а не в HTTP-ошибку: с точки зрения прогона это
исход исполнения, который надо показать пользователю рядом с diff'ом.
- API версионируется в пути (
/api/v1/). v1 фиксирует форму result-contract.md; ломающие изменения полей →/api/v2. - Локальный
--serve(/api/grade) не становится этим API автоматически: он остаётся неверсионированным локальным адаптером; сетевой API — отдельная поверхность (см. фазы ниже).
Требования безопасности к SandboxRunner в server mode. Это спецификация
обязательных свойств. Класс backend, закрывающего их, выбран отдельно —
OS-контейнер (namespaces + cgroups v2 + seccomp), ADR-0008;
детальное отображение каждого требования ниже на конкретные Linux-примитивы
(cgroups v2 / netns / mount ns / seccomp) — server-sandbox-design.md
(issue #153). Конкретный OCI-рантайм и оркестрация остаются этапом реализации.
- Сеть выключена. У исполняемого кода нет сетевого доступа (ни исходящего, ни слушающих сокетов). Скачивание задач/тестов делает сервер до запуска решения, не само решение.
- Только временные каталоги. Запись разрешена лишь в приватный tmp-каталог
прогона (per-run), который удаляется после завершения. Нет доступа к ФС
сервера, другим прогонам, секретам,
secrets.json. - Квоты (жёсткие лимиты). CPU-время, wall-время, память (реальный лимит, а
не best-effort как на POSIX сейчас), размер stdout/stderr, число
процессов/потоков (анти-форк-бомба), размер артефактов. Превышение →
вердикт (
TLEдля времени;sandbox_violation/специализированный для памяти/форк-бомбы). - Никаких секретов в среде sandbox. OAuth-токены,
secrets.json, переменные окружения сервера не пробрасываются в исполнение. Редакция в логах — logging.md. - Эфемерность. Каждый прогон — чистое окружение; состояние между прогонами не сохраняется.
- Изоляция по клиентам. Прогоны разных клиентов не видят друг друга (ни ФС, ни процессы, ни сеть).
Пока эти свойства не обеспечены реальным механизмом изоляции, server mode с недоверенным кодом не включается — ровно как зафиксировано в SECURITY.md.
Переход строится инкрементально, каждый шаг обратно совместим:
| Фаза | Что | Изоляция | Кому доступно |
|---|---|---|---|
| 0 — сейчас | Локальный CLI + --serve (127.0.0.1) |
нет (доверенный код) | локальный пользователь |
| 1 — Runner-абстракция | Runner/LocalRunner выделены из grader_core (issue #136/#137/#138) — готово, без смены поведения |
как в фазе 0 | локальный пользователь |
| 2 — SandboxRunner | Реализовать sandbox-backend по требованиям #157; включаем локально «на себе» | ОС-уровень | готово как локальный MVP (issue #266, --sandbox) — асимметрия гарантий по ОС, см. SECURITY.md |
| 3 — API | HTTP API /api/v1/runs (issue #156) поверх SandboxRunner, очередь, квоты |
ОС-уровень | доверенные клиенты |
| 4 — Server mode | Публичный/командный сервер онлайн-проверки | ОС-уровень + сетевые квоты | много клиентов |
Фазы 1–2 — рефактор/инфраструктура без нового продукта; продуктовый сдвиг
начинается с фазы 3. Ни одна фаза не ломает локальный --serve//api/grade.
- Не выбирать конкретный sandbox-рантайм (runc/crun/gVisor/Firecracker) — это решение деплоя. Класс backend (OS-контейнер + cgroups v2 + netns + seccomp) сужен ADR-0008, но конкретный рантайм и его установка — этап реализации.
- Не реализовывать API-сервер — только контракт.
- Не вводить зависимости/демоны в текущий код.
- Не менять локальную threat model — фаза 0 остаётся «доверенный код без sandbox», как сейчас.
- Мульти-язычность, авторизация клиентов, биллинг — вне рамок этого дизайна (относится к фазе 4+, отдельные issue).