Флот-контроллер + Allocation API + матчмейкер v0 + deploy-менеджер +
наблюдаемость/операционка + gRPC AgentLink — итерации 1–4.
Спека: docs/specs/master.md (источник истины).
Что внутри v0:
- Postgres 16 — единственный state store; миграции (golang-migrate, embed) накатываются автоматически при старте;
- gRPC AgentLink (
:8444, TLS): нода создаётся заранее черезPOST /v1/nodes→ одноразовыйnode_token; агент аутентифицирует стримHello{node_token}; heartbeat ~2с; per-node очередь команд сcmd_id/Ackи ре-отправкой при реконнекте; - lease-чекер: heartbeat старше 10с → нода в
quarantine; ещё 20с — её сервера →failed; возврат heartbeat →active; - reconcile (тик 1с): дефицит buffer_ready →
INSERT servers(creating)+StartServer(first-fit: самая занятая живая нода региона); излишек ready →StopServerстарейших; crash-loop ≥3 failed (version,node) за 10 мин → пауза пары на 15 мин + событиеcrash_loop(вход — событияserver_failedс reason≠node_lost: массовые фейлы карантина ноды ложный pause не дают); зависшиеcreating>120с →failed; - Allocation API
POST /v1/allocate— атомарный claim черезFOR UPDATE SKIP LOCKED, идемпотентность поmatch_id,409 {"error":"no_capacity"}; после успешной аллокации (оба пути — REST и матчмейкер) агенту ноды уходитAllocateServer{match_id, players_expected}→ liba получает UDS-фреймallocated(итерация 2, protocol.md §1); - жизненный цикл matches (итерация 2): аллокация →
pending(матчмейкер) либо строка создаётся приmatch_start(REST-путь);match_start→running(started_at),match_end→finished|aborted(ended_at);players_peak— максимум players из heartbeat за матч; падение дедика/потеря ноды закрывает матчaborted(Hello-воскрешение сервера возвращает и матч); штатный конец одноразового дедика (match_end → exit 0) → серверreaped, слот пересоздаёт reconcile; - матчмейкер v0 (
docs/specs/master.md§4): in-memory очереди per (region, compat-bucket), тик 500мс, группы поprojects.match_size(деф. 2, правитсяPUT /v1/projects/{slug}), регион по минимальному медианному rtt группы, widen на следующий по rtt регион игрока черезwiden_after_s(30с), TTL тикета 120с →expired, анти-дубль по player_id, совместимость client_version по major.minor (ops.md§3)compat.overridesиз конфига (окна миграции; override-set входит в ключ очереди — клиенты с разной совместимостью не смешиваются),no_capacity→ тикеты ждут ретрая; join_token (HMAC) — за флагом, по умолчанию выключен;
- deploy-менеджер (итерация 3,
master.md§5):POST /v1/deploy {version_id}(скоуп deploy) → versionprepulling+ PrePull всем живым нодам регионов флита → всеPullReport pulled(таймаут 15 мин илиfailed-репорт → abort, событиеdeploy_failed) → атомарный флип (старая active→deprecated, новая→active,fleet_configs.active_version; событиеdeploy_activated); повторный вызов идемпотентен; рестарт master резюмирует prepull. Окно мультиверсий: reconcile держит полный buffer active + min(2, buffer) deprecated; матчмейкер матчит старых клиентов на deprecated, пока она жива;reap_ttl_minзакрывает окно (deprecated→disabled,version_disabled): ready-буфер реапится, живые матчи получают per-serverDrainServer{deadline_s:300}→ liba-фреймdrain, дедик доигрывает и выходит сам (событиеserver_drain).POST /v1/rollback {project?, region?}— обратный флип deprecated↔active за секунды (образы уже на тачках;deploy_rolled_back). Метрики:birdman_deploy_prepull_seconds,birdman_versions{project,env,state}; - REST (
:8100, Bearer API-ключи из таблицыapi_keys, скоупы admin/deploy/matchmaking/allocate/readonly; bcrypt + кэш):POST /v1/matchmaking/tickets{player_id, client_version, regions:[{region,rtt_ms}][, project]}→{ticket_id, status};GET /v1/matchmaking/tickets/{id}?wait=25s— long-poll (кап 30с):queued | matched{host,port,match_id,join_token?} | update_required | cancelled | expired;DELETE /v1/matchmaking/tickets/{id}— отмена;GET /v1/qos(публичный) →{"qos":[{region, host, udp_port:19999}]}из живых нод — сам UDP-echo появится в агенте в итерации 4, адреса уже правильные;- rate-limit матчмейкинга: 5 rps per player_id →
429 rate_limited;
- /metrics (Prometheus, ОТДЕЛЬНЫЙ листенер
listen_metrics, деф.127.0.0.1:9102— на порту API этой ручки нет, tracker #1003):birdman_servers{project,env,production,state,region,version}(ready-срез несёт явные нули для флотов сbuffer_ready > 0— иначе серия просто исчезает и алертready == 0не может сработать, tracker #960),birdman_allocation_duration_seconds,birdman_allocation_failures_total{reason,project},birdman_node_heartbeat_age_seconds,birdman_mm_queue_depth{region,env},birdman_mm_time_to_match_seconds,birdman_mm_tickets_total{result}; - SSE
GET /v1/events/stream(readonly+): новые строкиeventsкакid:/event: <kind>/data: <json>(курсор по id, poll ~1с), keepalive каждые 15с, реконнект через?after_id=/Last-Event-ID— без потерь при обрыве СОЕДИНЕНИЯ (про гонку коммитов см. spec §6 и tracker #1013). Лента сужается привязкой ключа так же, какGET /v1/events:?project=с чужим слагом → 403, без параметра привязанный ключ видит свой проект (tracker #999); - матчи для панели:
GET /v1/matches(фильтрыstate|region|project,limit/offset, свежие первыми; в ответе semver, host:port и живыеserver_players) иGET /v1/matches/{id}; - cookie-сессии панели:
POST /v1/session {api_key}→ HttpOnly Secure SameSite=Lax cookie (in-memory, TTL 24ч, скоупы ключа),GET /v1/session,DELETE /v1/session; Bearer работает как раньше; для не-GET по cookie обязателен заголовокX-Birdman-Csrf: 1; - встроенная админ-панель (
/, П0 read-only) — см. раздел «Панель»; - наблюдаемость + операционка (итерация 4,
master.md§6, тело вinternal/httpapi/ops.go): drain/undrain ноды (POST /v1/nodes/{id}/drain·/undrain— reconcile реапит ready на draining-ноде, allocated доигрывают), logs-proxy (GET /v1/servers/{id}/logs?follow=&tail=— стримLogChunkчерезagentlink.LogRouter, в т.ч. для умерших), self-upgrade агента (POST /v1/agent-upgrade, watchdog →agent_upgrade_succeeded/failed), read-only прокси метрик к VictoriaMetrics (GET /v1/metrics/query·/query_range) и истории логов к VictoriaLogs (GET /v1/logs/query, Логи v1); метрикиbirdman_events_total{kind,project}(вход CrashLoop-алерта),birdman_matches_running,birdman_players_online. Модель доступа всех трёх проксий (master.md§6, tracker #994 + #1007), в том порядке, в каком проверяет код (порядок ЗАМЕРЕН запуском) — разрешение РАНЬШЕ состояния апстрима: гейт скоупа (ключ без readonly/admin → 403scope readonly required) → привязка, непригодная к сужению → 403 fail-closed (единственный 403 из-за привязки: сама по себе она эти ручки не закрывает) → ЯВНО пустой URL апстрима → 503 (metrics_unconfigured/logs_unconfigured) → кривойlimitу logs-проксии → 400 (апстрима такой запрос не касается; у metrics-проксий своегоlimitнет) → только для привязанного ключа проба сужения (#1007): ручку проглотили → 503*_narrowing_unsupported, вердикта нет → 502upstream→ апстрим не отвечает на боевой запрос → 502upstream→ иначе 200. Привязанному к паре (project, env) ключу master при этом сужает запрос этой парой (extra_stream_filtersу VL, парныеextra_labelу VM): чужого он не получает, а своё видит там, где пара есть в данных, иначе 200 и пусто. Глобальный и admin-ключ ходят как раньше и пробу не оплачивают; verbatim при этом — про ДВЕ metrics-ручки из трёх, у logs-проксииlimitмастер ставит (деф. 1000) и клампит (10000) ЛЮБОМУ ключу. Дефолтvictoriametrics_url/victorialogs_urlвconfig.defaults()— НЕ пусто, а127.0.0.1:8428/:9428, поэтому «секциюmetrics:не трогали» даёт 502, а не 503.
Отложено (TODO, спеки помечены): обмен node_token → клиентский mTLS-серт,
проверка join_token на дедике (liba), деплой-хук из CI в master (master не
публичен — ops.md §2); MasterDown — внешний probe (ops.md §1).
master.example.yaml → master.yaml; env-переменные BIRDMAN_DSN,
BIRDMAN_LISTEN_API, BIRDMAN_LISTEN_GRPC, BIRDMAN_MM_JOIN_SECRET
перекрывают файл. Без TLS-сертов в конфиге master при первом старте
генерирует self-signed пару в tls.auto_cert_dir (dev-режим; gRPC всегда
TLS). Секция matchmaking (tick_ms/widen_after_s/ticket_ttl_s/
default_project/join_token) — см. master.example.yaml; секреты в файл не
кладём — join-секрет только через env.
Это дев-стенд (
master/dev-compose.yml). Для self-host — master + панель одним стеком, ввод нод, версия и первый матч — см.deploy/и docs/self-host.md (отgit cloneдо матча).
docker compose -f master/dev-compose.yml up --build -d
docker compose -f master/dev-compose.yml logs master | grep api_key # admin-ключ, печатается ОДИН раз
curl -s localhost:8100/healthzПри пустой таблице api_keys master создаёт admin-ключ и печатает его в лог
один раз — сохраните его сразу. Дальше обычный флоу:
KEY=bmk_... # admin-ключ из лога
# 1. зарегистрировать ноду → одноразовый node_token для конфига агента
curl -s -X POST localhost:8100/v1/nodes -H "Authorization: Bearer $KEY" \
-d '{"project":"game","region":"eu","hostname":"n1","public_ip":"203.0.113.10","capacity_slots":8}'
# 2. зарегистрировать версию билда (env обязателен: dev|prod|…; см. docs/specs/master.md §Окружения)
curl -s -X POST localhost:8100/v1/versions -H "Authorization: Bearer $KEY" \
-d '{"project":"game","semver":"1.0.0","image_ref":"ghcr.io/org/game:1.0.0","env":"dev"}'
# 3. включить warm pool региона (active_version можно не задавать — его
# выставит деплой; отсутствие поля НЕ сбрасывает текущую версию)
curl -s -X PUT localhost:8100/v1/fleets/eu -H "Authorization: Bearer $KEY" \
-d '{"project":"game","buffer_ready":2}'
# 3a. мягкий деплой версии (prepull → атомарный флип) и откат
curl -s -X POST localhost:8100/v1/deploy -H "Authorization: Bearer $KEY" \
-d '{"version_id":"<version_id>"}'
curl -s -X POST localhost:8100/v1/rollback -H "Authorization: Bearer $KEY" -d '{}'
# 4. аллокация (после того как агент поднял ready-сервера)
curl -s -X POST localhost:8100/v1/allocate -H "Authorization: Bearer $KEY" \
-d '{"project":"game","region":"eu","match_id":"<uuid>"}'
# 5. (опционально) размер матча проекта — по умолчанию 2
curl -s -X PUT localhost:8100/v1/projects/game -H "Authorization: Bearer $KEY" \
-d '{"match_size":2}'Проекты создаются неявно при первом упоминании slug'а в POST /v1/nodes /
POST /v1/versions / PUT /v1/projects/{slug} (уточнено в v0).
Админ-панель (П0, read-only: Overview / Флот / Матчи, обе темы, live через
SSE) — SPA из panel/, встроенная в бинарь: panel/build.sh собирает
её (npm ci + tsc + vite build) и кладёт в master/internal/panelui/static,
а internal/panelui отдаёт этот каталог через go:embed с / и
/assets/* (SPA-fallback на index.html, /v1/* остаётся JSON-API).
Как это не ломает сборку без node:
- в git закоммичен только якорь
static/.gitkeep(сам каталог в.gitignore) —go buildиmaster/test.shработают на машине без node: бинарь без панели отдаёт placeholder-страницу с подсказкой; panel/build.shиспользует локальный node ≥20, а если его нет —docker node:22;./master/build.shсобирает панель автоматически;SKIP_PANEL=1 ./master/build.sh— пропустить.
Вход в панель: API-ключ со скоупом readonly или admin (форма логина →
POST /v1/session → HttpOnly-cookie на 24ч; сессии в памяти master и не
переживают его рестарт — панель просто вернёт на логин). Дев-режим фронта:
cd panel && npm run dev (vite dev server проксирует /v1 на
localhost:8100); тесты панели: npm test (vitest) + npm run check
(tsc), CI — .github/workflows/panel.yml.
mmcli — клиент матчмейкинга для acceptance-прогонов и отладки (второй
бинарь master/dist/mmcli, собирается build.sh). Создаёт тикет,
long-poll'ит его чанками по 25с и печатает JSON результата + host:port
последней строкой (exit 0 — matched, 1 — иной исход, 2 — usage):
mmcli --master http://localhost:8100 --key $KEY request \
--player p1 --version 1.0.0 --region eu:5
# {"..."} + строка "203.0.113.10:20001" при матче--region NAME:RTT_MS повторяемый (rtt клиент меряет по GET /v1/qos);
--project нужен только если проектов несколько; --timeout — общий
дедлайн ожидания (деф. 120с). Ключ — со скоупом matchmaking (или admin).
Два запущенных mmcli request c разными --player в одном регионе дают
обоим один и тот же host:port и match_id — acceptance итерации 2.
Go на хосте не нужен — всё в docker:
./master/test.sh # postgres:16 + golang:1.24, go test -race ./...
./master/test.sh -run TestAllocate -vИнтеграционные тесты берут Postgres в таком порядке: BIRDMAN_TEST_DSN из
env (так работают test.sh и CI c service-контейнером) → сами поднимают
postgres:16 через docker CLI (запуск go test прямо на хосте с docker) →
иначе t.Skip. Каждый тест получает отдельную БД с миграциями.
./master/build.sh # панель + master/dist/{birdman-master,mmcli}
SKIP_PANEL=1 ./master/build.sh # только бинари (в них будет placeholder)Бинари — linux/amd64, static, CGO off; панель встраивается в
birdman-master (см. раздел «Панель»).
См. proto/README.md (docker + buf, версии пинованы). Сгенерированный код
закоммичен; CI пересборку не делает.