feat(httpapi): harden control plane and speed up prefix refresh

Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
DenozordecandCursor committed 2026-11-02 17:23:59 +07:00
1 parent 924f111e48
commit f8e0520817
197 files changed
+18655 -1416

No files matched your search

+213 -9
View File
@@ -10,6 +10,20 @@ info:
**Соглашения:** префикс `/v1`; идентификаторы - UUID v7 или ULID (строки); время - ISO 8601 UTC.
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
**Общий предел request body:** после аутентификации каждый непубличный `/v1/...`
запрос проверяется целиком до выполнения handler: максимум 8 MiB (8388608 bytes),
независимо от HTTP-метода, Content-Type и наличия Content-Length. Превышение,
включая whitespace после JSON, возвращает `413 application/problem+json`
до мутаций. Ошибка чтения тела возвращает `400`, отмена/таймаут чтения — `408`.
Пустые node GET/pull и streaming SSE responses не меняются. CSV дополнительно
сохраняет ограничения числа строк и полей своей операции.
Запросы с телом занимают ограниченный слот API instance до окончания handler:
по умолчанию 4 одновременно, `EVOBGP_API_BODY_MAX_CONCURRENT` задаёт 1..64.
Свободных слотов нет — немедленный `429` с `Retry-After: 1`, без очереди ожидания.
Пустые запросы (`http.NoBody`) слота не занимают.
Если CRUD-изменение сохранено, но автоматическая refresh/reconcile задача не поставлена,
успешный ответ содержит `X-EvoBGP-Job-Status: enqueue-failed`; ошибка также попадает в аудит.
Изменение уже сохранено: следует явно запустить refresh/reconcile, а не повторять создание объекта.
**Аутентификация (dual):**
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
@@ -226,6 +240,16 @@ components:
Основные значения: `module_refresh`, `peer_reconcile`, `deploy_apply`, `revision_rollback`, `bird_reload`.
responses:
ExpensiveOperationLimited:
description: Локальная квота процесса API — по умолчанию 4 активных preview/lookup, 2 на tenant; 120 lookup и по 30 CDN/revision preview в минуту на tenant. Квоты умножаются на число реплик. Повторить после Retry-After, без очереди ожидания.
headers:
Retry-After:
description: Минимальная задержка повторной попытки в секундах; не гарантия свободного места.
schema: {type: integer, minimum: 1}
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
Unauthorized:
description: Нет или невалидный Bearer-токен.
content:
@@ -263,7 +287,10 @@ components:
schema:
$ref: "#/components/schemas/Problem"
DefaultProblem:
description: Ошибка (см. тело Problem).
description: >
Ошибка (см. тело Problem). Общие для authenticated `/v1` ответы middleware:
413 при request body свыше 8 MiB, 400 при ошибке чтения тела, 408 при отмене/таймауте чтения.
429 с Retry-After: 1 при занятых слотах обработки request body.
content:
application/problem+json:
schema:
@@ -903,6 +930,21 @@ components:
connected_speaker_label:
type: string
description: Человекочитаемая метка ноды из live-опроса.
last_known_session_state:
type: ["string", "null"]
description: Последнее сохранённое состояние; не является текущим наблюдением.
session_state:
type: ["string", "null"]
description: >
При live-ответе текущее наблюдение: BGP state, absent после успешного
пустого опроса или null, если актуальное состояние неизвестно.
live_observation:
$ref: "#/components/schemas/LiveObservation"
description: >
Качество BGP-наблюдений при GET /v1/peers?live=1. Для назначенного
пира учитывается только bgp_speaker_id; для общего — все speakers.
Смешанный успех и сбой не дают quality=ok или session_state=absent.
Timestamps относятся только к ожидаемым speakers.
session_on_speakers:
type: array
description: Состояние протокола пира на каждой опрошенной ноде.
@@ -915,6 +957,10 @@ components:
type: string
state:
type: string
poll_error:
type: string
observation:
$ref: "#/components/schemas/LiveObservation"
established_on_speakers:
type: array
description: Ноды, где сессия в состоянии Established (один пир может быть на нескольких).
@@ -1001,7 +1047,11 @@ components:
description: Человекочитаемая метка ноды (agent domain или CP master).
agent_ok:
type: boolean
description: true если agent /v1/agent/health успешен (master — local birdc poll).
description: true только после успешного agent /v1/agent/health (master — local birdc poll).
agent_observation:
$ref: "#/components/schemas/LiveObservation"
bgp_observation:
$ref: "#/components/schemas/LiveObservation"
agent_error:
type: string
agent_last_sync_at:
@@ -1015,15 +1065,49 @@ components:
bgp_poll_error:
type: string
bgp_sessions_total:
type: integer
type: ["integer", "null"]
description: null при отсутствии успешного опроса.
bgp_established:
type: integer
type: ["integer", "null"]
description: null при отсутствии успешного опроса.
sessions:
type: array
items:
$ref: "#/components/schemas/BgpSessionLive"
additionalProperties: true
LiveObservation:
type: object
description: >
Метаданные конкретного опроса. observed_at — время завершения опроса;
received_at — время получения результата CP; last_success_at — время
последнего успешного опроса. Старые агенты не обязаны передавать эти поля:
CP формирует их по собственным часам. При отсутствии опроса timestamps null.
required: [observed_at, received_at, last_success_at, source, quality]
properties:
observed_at:
type: ["string", "null"]
format: date-time
received_at:
type: ["string", "null"]
format: date-time
last_success_at:
type: ["string", "null"]
format: date-time
expires_at:
type: ["string", "null"]
format: date-time
description: Срок актуальности snapshot по часам CP и настроенному интервалу collector; клиент не должен считать cached quality=ok актуальным после этого времени. Для агрегата — самый ранний срок источников.
source:
type: string
enum: [none, cp_birdc, agent_bird_protocols, agent_health, mixed]
quality:
type: string
enum: [ok, stale, unreachable, unknown, disabled]
error_code:
type: string
enum: [not_configured, not_polled, poll_failed, reported_unhealthy, snapshot_expired, configuration_changed, ambiguous_authority]
BgpSessionLive:
type: object
properties:
@@ -1052,9 +1136,12 @@ components:
ok:
type: boolean
session_count:
type: integer
type: ["integer", "null"]
description: null, если успешного опроса не было.
poll_error:
type: string
observation:
$ref: "#/components/schemas/LiveObservation"
additionalProperties: true
PostgresOverview:
@@ -2108,6 +2195,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/LookupResponse"
"429":
$ref: "#/components/responses/ExpensiveOperationLimited"
"400":
$ref: "#/components/responses/BadRequest"
"401":
@@ -2115,6 +2204,29 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/telemetry/refresh:
parameters:
- $ref: "#/components/parameters/TenantId"
post:
tags: [Speakers]
summary: Запросить фоновое обновление телеметрии tenant
description: Требует bgp:network:read. Объединяет повторные запросы в пределах 10 секунд; HTTP handler не опрашивает агентов или BIRD. GET speakers/peers/discoveries читает сохранённые snapshots, включая live=1. Запрос во время выполняющегося poll сохраняется для следующего цикла. Не изменяет конфигурацию speaker и не требует нового протокола агента.
operationId: requestTelemetryRefresh
responses:
"202":
description: Запрос принят либо объединён с уже принятым.
content:
application/json:
schema:
type: object
required: [requested]
properties:
requested:
type: boolean
description: true при постановке нового запроса; false при объединении или отсутствии целей.
default:
$ref: "#/components/responses/DefaultProblem"
/v1/router-lists/catalog:
get:
tags: [Modules]
@@ -2789,6 +2901,9 @@ paths:
- `ipRange,community`
В поле `community` можно передавать либо ID community, либо её значение.
Предел запроса — 8 MiB (8388608 байт), 10000 строк данных без заголовка и 64 поля в записи.
Превышение возвращает 413 до создания записей; большой импорт следует разбить на части.
Ошибка в данных или БД после начала создания записей может оставить частичный импорт.
operationId: importModuleEntriesCsv
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
@@ -2812,6 +2927,12 @@ paths:
minimum: 0
module_type:
type: string
"413":
description: Превышен лимит байт, строк или полей CSV; записи не созданы.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"404":
$ref: "#/components/responses/NotFound"
"422":
@@ -2898,6 +3019,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/CdnPreviewResponse"
"429":
$ref: "#/components/responses/ExpensiveOperationLimited"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
@@ -3146,7 +3269,7 @@ paths:
type: string
enum: ["1"]
description: >
Опрос birdc на CP и GET /v1/agent/bird/protocols на репликах; обогащает session_state и connected_speaker_*.
Сохранённые результаты фонового опроса birdc на CP и agent protocols на репликах; обогащает session_state и connected_speaker_*. GET не запускает опрос; live=1 сохранён для совместимости.
responses:
"200":
description: Успешно.
@@ -3167,7 +3290,7 @@ paths:
live_speaker_poll:
type: array
description: >
При live=1 — результат опроса каждой ноды (CP birdc + agent protocols).
Сохранённый результат фонового опроса каждой ноды (CP birdc + agent protocols); live=1 не запускает новый опрос.
items:
$ref: "#/components/schemas/LiveSpeakerPoll"
default:
@@ -3204,7 +3327,7 @@ paths:
summary: Список обнаруженных (неодобренных) пиров
description: >
Dynamic BGP-сессии (`evobgp_dyn_*`), которых ещё нет в `bgp_peer`.
По умолчанию возвращает `status=pending`. При листинге выполняет live-опрос birdc/agent и upsert pending.
По умолчанию возвращает `status=pending`. Читает сохранённые discoveries. Фоновый collector выполняет опрос и fenced upsert; GET не опрашивает ноды и не изменяет данные.
operationId: listDiscoveredPeers
parameters:
- $ref: "#/components/parameters/TenantId"
@@ -3380,7 +3503,7 @@ paths:
type: string
enum: ["1"]
description: >
Live-опрос agent /v1/agent/health и BGP protocols на репликах; CP — local birdc.
Сохранённые результаты фоновых опросов agent health/BGP и local birdc. GET не запускает опрос.
Обогащает каждый item полем `live`.
responses:
"200":
@@ -3640,6 +3763,8 @@ paths:
description: Только чтение, без apply.
operationId: getRevisionPreview
responses:
"429":
$ref: "#/components/responses/ExpensiveOperationLimited"
"200":
description: Текст или структурированное представление - формат задаётся реализацией.
content:
@@ -3881,6 +4006,58 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/jobs/{job_id}/report:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/JobId"
get:
tags: [Jobs]
summary: Отчёт задачи и сохранённый прогресс доставки
description: Требует bgp:operations:read. Прогресс хранится независимо от meta задачи. Для reconciliation без собственного прогресса возвращается прогресс исходной задачи в том же tenant; после начала восстановления приоритет имеет собственный прогресс. agent_reported не означает проверку состояния BIRD.
operationId: getJobReport
responses:
"200":
description: Успешно.
content:
application/json:
schema:
type: object
required: [job_id, kind, status, deployment_progress]
properties:
job_id: {type: string}
kind: {type: string}
status: {type: string}
meta: {type: [object, "null"], additionalProperties: true}
error: {type: [string, "null"]}
created_at: {type: string, format: date-time}
revision_id: {type: string}
log_entries: {type: array, items: {type: object, additionalProperties: true}}
log_total: {type: integer}
source_deployment_job_id: {type: string}
deployment_progress:
type: array
items:
type: object
required: [job_id, speaker_id, revision_id, generation, state, confirmation, dispatch_attempts, retry_round]
properties:
job_id: {type: string}
speaker_id: {type: string}
revision_id: {type: string}
generation: {type: integer, format: int64}
state: {type: string}
confirmation: {type: string}
error: {type: string}
dispatch_attempts: {type: integer}
retry_round: {type: integer}
health_received_at: {type: [string, "null"], format: date-time}
health_revision_id: {type: string}
health_source_at: {type: string}
health_error: {type: string}
"404":
$ref: "#/components/responses/NotFound"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/jobs/{job_id}/cancel:
parameters:
- $ref: "#/components/parameters/TenantId"
@@ -3904,6 +4081,33 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/jobs/{job_id}/retry:
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/JobId"
post:
tags: [Jobs]
summary: Повторить завершившуюся ошибкой задачу
description: Операторский повтор сохраняет ID и исходный запрос, сбрасывая бюджет попыток.
operationId: postJobRetry
responses:
"202":
description: Повтор поставлен в очередь.
content:
application/json:
schema:
$ref: "#/components/schemas/Job"
"409":
$ref: "#/components/responses/Conflict"
"429":
description: Очередь задач заполнена.
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/speakers/{speaker_id}/revisions/latest:
parameters:
- $ref: "#/components/parameters/SpeakerId"