diff --git a/bin/SHA256SUMS b/bin/SHA256SUMS index 69e0ae2..e6a8783 100644 --- a/bin/SHA256SUMS +++ b/bin/SHA256SUMS @@ -1,4 +1,4 @@ -a6d853b6f203593d45e50b77ef674aac534140cb2efb9a33336902769cc0c81e control-api +b5d1ae5c625c1ab12a42b91d0e7dbfe5eb15897fbc46e1b94db21aca5f84e77f control-api 48c9b99fa88be751d9badfba8b7d80f894e326a743a90e85478f1d2251ce4ab6 validator-agent 43fb660b78179b204b57388241a508345881aa16f3531d77b8fd75af60e7f2d8 prober -6dd6a3de14079030bfca1ad0255e580baf11b6c54b51d570f03c66cd9d72a434 admin-dashboard +4e1e550bf026b9f9b4aa60a9bf38e1e409693f7ed8d4fd1853175f882ba349ca admin-dashboard diff --git a/bin/admin-dashboard b/bin/admin-dashboard index bbc3d17..e40d372 100755 Binary files a/bin/admin-dashboard and b/bin/admin-dashboard differ diff --git a/bin/control-api b/bin/control-api index cc55357..8fedd04 100755 Binary files a/bin/control-api and b/bin/control-api differ diff --git a/configs/control-api.example.yaml b/configs/control-api.example.yaml index c6e44ad..b979ce0 100644 --- a/configs/control-api.example.yaml +++ b/configs/control-api.example.yaml @@ -45,6 +45,15 @@ orchestrator: max_retries: 3 lease_ttl_seconds: 180 heartbeat_timeout_seconds: 30 + # Pause (seconds) between FIP association and the start of self-check — + # gives the OpenStack data plane time to start forwarding traffic + # through the newly attached floating IP. 0 = no pause (default). + # This is only the one-time seed value used the first time control-api + # starts against an empty database; after that it's managed at runtime + # via PUT /api/v1/admin/config/orchestrator (or the dashboard's + # /settings page) and this field is ignored. Must satisfy + # fip_settle_seconds + self_check_timeout_seconds < lease_ttl_seconds. + fip_settle_seconds: 0 aggregation: missing_counts_as_fail: true diff --git a/docs/API.md b/docs/API.md index 685766c..a2db31d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -455,6 +455,28 @@ curl -s -X POST "$BASE/api/v1/admin/ips/clear" | PUT | `/api/v1/admin/config/check-types/{name}` | `{"enabled","targets":["group",...]}` | `200` | `400`, если названа несуществующая группа | | DELETE | `/api/v1/admin/config/check-types/{name}` | — | `200` | `404` | +### Настройки оркестратора: `/api/v1/admin/config/orchestrator` + +Единственный на сегодня параметр — `fip_settle_seconds`: пауза между +привязкой Floating IP к валидатору и моментом, когда self-check по этому +адресу становится доступен агенту (`GET +/api/v1/agents/{id}/assignment` до истечения паузы отдаёт `204`, как если +бы валидатору просто нечего было делать — никаких изменений в протоколе +агента). Нужна, чтобы дать data plane OpenStack время реально начать +пропускать трафик через только что привязанный адрес, прежде чем +запускать по нему проверки. `0` — без паузы (поведение по умолчанию, как +до появления этого параметра). + +| Метод | Путь | Тело | Успех | Ошибки | +|---|---|---|---|---| +| GET | `/api/v1/admin/config/orchestrator` | — | `{"fip_settle_seconds":N}` | | +| PUT | `/api/v1/admin/config/orchestrator` | `{"fip_settle_seconds":N}` | `200` | `400`, если `N < 0`, или если `fip_settle_seconds + self_check_timeout_seconds >= lease_ttl_seconds` (пауза не должна съедать весь лизинг адреса — иначе self-check не успеет пройти до истечения `lease_ttl_seconds`, и адрес будет вечно возвращаться в очередь) | + +Как и остальные разделы этой группы, YAML-поле `orchestrator. +fip_settle_seconds` в `control-api.yaml` — только одноразовый bootstrap +для пустой БД; дальше источник истины — сама база, менять значение нужно +через `PUT` выше (или страницу `/settings` в дашборде). + ### Пример: конфигурация целиком через API, без единой строки в YAML ```bash @@ -509,6 +531,13 @@ queued ──(control-api сам, без вызова API)──▶ assigning_fi `orchestrator.poll_interval_seconds`), явного HTTP-метода для их запуска нет — это фоновый цикл (`Tick`), а не запрос/ответ. +Вход в `awaiting_self_check` не означает мгновенную видимость агенту: если +настроена пауза (`fip_settle_seconds`, см. +[«Настройки оркестратора»](#настройки-оркестратора-apiv1adminconfigorchestrator) +выше), `GET /api/v1/agents/{id}/assignment` продолжает отдавать `204` до +истечения паузы, и только потом начинает отдавать assignment — состояние +в БД при этом уже `awaiting_self_check`. + Площадки (`siteN_complete`) — опциональны: сколько их учитывается, целиком определяется текущим списком `sites` (0–3 записи, управляется через `/api/v1/admin/config/sites` — см. diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index c12d4e2..a9821cc 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -49,12 +49,13 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml | Страница | Назначение | |---|---| | `/overview` | Сводная статистика: счётчики по состояниям, «текущая проверка» (live-снимок всех IP не в терминальном состоянии) и «последние N завершённых» (по умолчанию 20, `overview.last_completed_count`) с разбивкой pass/partial/fail/cancelled. Обновляется каждые `overview.poll_interval_seconds` секунд без перезагрузки страницы. | -| `/ips` | Полная очередь. Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний), и всегда — «Удалить» (безвозвратно, в отличие от «Отменить», см. ниже). Чекбоксы у строк + кнопка «Удалить выбранные» удаляют список одним вызовом; «Очистить всё» удаляет вообще всё, включая активные проверки — обе операции требуют явного подтверждения. | +| `/ips` | Полная очередь. Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний), и всегда — «Удалить» (безвозвратно, в отличие от «Отменить», см. ниже). Чекбоксы у строк + кнопка «Удалить выбранные» удаляют список одним вызовом; «Очистить всё» удаляет вообще всё, включая активные проверки — обе операции требуют явного подтверждения. Пока не истекла настроенная на `/settings` пауза (`fip_settle_seconds`), только что привязавший Floating IP адрес показывает отдельный бейдж «прогрев FIP» вместо обычного статуса. | | `/ips/{ip}` | Детали одного адреса: все проверки текущей попытки и вся история событий. | | `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. | | `/sites` | Три фиксированных слота площадок (1/2/3) — назначить/сменить/освободить `site_id`. | | `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. | | `/check-types` | Типы проверок (`https`/`icmp`/`ssh`/...), включение/выключение, привязка к группам целей. | +| `/settings` | Единственная настройка на сегодня — `fip_settle_seconds`, пауза (в секундах) между привязкой Floating IP и началом self-check («прогрев» дата-плейна OpenStack, см. [USAGE.md](USAGE.md#пауза-перед-self-check-fip_settle_seconds)). | ### «Текущая» и «последняя завершённая» проверка diff --git a/docs/PLAN_FIP_SETTLE_DELAY.md b/docs/PLAN_FIP_SETTLE_DELAY.md new file mode 100644 index 0000000..ab57f78 --- /dev/null +++ b/docs/PLAN_FIP_SETTLE_DELAY.md @@ -0,0 +1,323 @@ +# План: пауза перед self-check после привязки Floating IP (`fip_settle_seconds`) + +> Статус: **реализовано**. Актуальное описание — +> [docs/API.md](API.md#настройки-оркестратора-apiv1adminconfigorchestrator), +> [docs/USAGE.md](USAGE.md#пауза-перед-self-check-fip_settle_seconds), +> [docs/DASHBOARD.md](DASHBOARD.md). + +## Context + +Сейчас, как только `orchestrator.associateFIP` успешно привязывает Floating +IP к порту валидатора, адрес немедленно становится виден агенту +(`state=awaiting_self_check`), и агент запускает self-check на ближайшем +опросе `GET /assignment` (по умолчанию раз в 5с, `poll_interval_seconds`). +Никакой паузы нет — а data plane OpenStack может не успеть реально начать +пропускать трафик через только что привязанный FIP к этому моменту, +из-за чего self-check ложно проваливается по причине, не связанной с +самой привязкой. + +Нужна управляемая администратором пауза между привязкой FIP и началом +self-check. Решено: +- без новых состояний `ip_queue` — пауза реализуется тем, что сервер + *придерживает* назначение (agent просто продолжает получать `204` и + поллить дальше — это уже штатное поведение, доработок на стороне + `validator-agent` не требуется); +- значение — единый глобальный параметр, управляемый не только через + YAML-бутстрап, но и **на лету через admin API и dashboard** (по + аналогии с уже существующими `validators`/`sites`/`targets`/ + `check_types`, но это не CRUD над именованными сущностями, а один + скаляр — поэтому отдельная таблица-синглтон, а не переиспользование + существующего паттерна); +- жёсткая валидация: `fip_settle_seconds + self_check_timeout_seconds` + должно быть `< lease_ttl_seconds`, иначе связка «пауза + сам self-check» + не влезает в лизинг, и адрес будет вечно уходить в reclaim + (`sweepExpiredLeases`), так и не успев пройти self-check; +- в dashboard: отдельная страница `/settings` (задел на будущие + orchestrator-wide параметры) + видимость паузы в таблице `/ips` + (отдельный бейдж «прогрев FIP» вместо обычного статуса, пока пауза не + истекла). + +## 1. `internal/db` — схема, модели, запросы + +**Новая миграция** `internal/db/migrations/0003_fip_settle_delay.sql`: +```sql +ALTER TABLE ip_queue ADD COLUMN fip_associated_at TIMESTAMP; + +-- Синглтон-строка под единственный на сегодня скаляр-параметр; не +-- generic key-value — если параметров станет больше, добавлять колонки +-- сюда, а не заводить схему "на вырост". +CREATE TABLE settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + fip_settle_seconds INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +``` +`internal/db/db.go`: `//go:embed migrations/0003_fip_settle_delay.sql` → +новая переменная, добавить `{3, ...}` в конец слайса `migrations` (тот же +паттерн, что уже даёт `{1, initSchema}`, `{2, dynamicConfigSchema}`). + +**`internal/db/models.go`**: в `IPQueueItem` — новое поле +`FIPAssociatedAt *time.Time` (после `AssignedAt`, по хронологии: +claim → associate → aggregate → release). `AssignedAt` для этой цели не +подходит — его пишет `ClaimNextQueued` в момент захвата очереди, **до** +привязки FIP, а нужен именно момент привязки. + +**`internal/db/queries_ipqueue.go`**: +- `ipQueueSelect` — добавить колонку `fip_associated_at`. +- `scanIPQueueItem` — добавить сканирование через `sql.NullString` + + `nullStringToTimePtr`, зеркально `AssignedAt`/`AggregatedAt`/ + `FIPReleasedAt` чуть выше в этом же файле. +- `SetFIPAssociated` — писать `fip_associated_at=?` (текущий `now`) + вместе с уже существующими `state`/`fip_id`/`lease_expires_at`/ + `updated_at`. +- `RequeueOrFail` (ветка возврата в `queued`) и `SubmitIPs` (ветка + `done`/`failed → queued`) — добавить `fip_associated_at=NULL` в списки + сброса полей (рядом с уже существующими `assigned_at=NULL` и т.п.) — + новая попытка не должна наследовать таймер паузы от предыдущей. + +**Новый файл `internal/db/queries_settings.go`**: +- `type Settings struct { FIPSettleSeconds int; CreatedAt, UpdatedAt time.Time }`. +- `GetSettings(ctx) (Settings, error)` — `SELECT ... FROM settings WHERE id=1`. +- `SetFIPSettleSeconds(ctx, seconds int) error` — проверяет `seconds >= 0` + (`ErrValidation` иначе), затем `UPDATE settings SET + fip_settle_seconds=?, updated_at=? WHERE id=1`. Кросс-валидацию против + `lease_ttl_seconds`/`self_check_timeout_seconds` здесь не делать — у + этого пакета нет доступа к `config.OrchestratorConfig`, она живёт в + `orchestrator` (см. ниже). + +**`internal/db/bootstrap.go`**: новая `bootstrapSettings(ctx, fipSettleSeconds int) error` +по образцу `bootstrapSites`/`bootstrapCheckTypes` (тот же паттерн: `SELECT +COUNT(*) FROM settings`, если `>0` — no-op, иначе один `INSERT ... VALUES +(1, ?, now, now)`), вызвать из `BootstrapFromConfig` рядом с остальными +четырьмя bootstrap-вызовами, перед `SeedQueue`. + +## 2. `internal/config` + +`internal/config/config.go`, `OrchestratorConfig`: новое поле +`FIPSettleSeconds int` с тегом `` yaml:"fip_settle_seconds" `` — без +дефолта в `LoadControlAPI` (в отличие от остальных полей структуры): `0` — +сам по себе корректный и обратно-совместимый дефолт (без паузы), задавать +что-то другое было бы неверно. Это поле используется только как +одноразовое seed-значение при бутстрапе пустой `settings`-таблицы — +дальше источник истины БД, как и у validators/sites/targets/check_types. + +## 3. `internal/orchestrator` + +**`internal/orchestrator/orchestrator.go`**, `AssignmentForValidator`: +после существующей проверки состояния +(`if item.State != db.IPAwaitingSelfCheck && item.State != db.IPChecking { return nil, nil, nil }`) +добавить проверку паузы, применимую только к `awaiting_self_check` (уже +прошедший self-check `checking` она не касается): + +```go +if item.State == db.IPAwaitingSelfCheck { + settled, err := o.isFIPSettled(ctx, item) + if err != nil { + return nil, nil, err + } + if !settled { + return nil, nil, nil + } +} +``` + +Новый приватный `isFIPSettled(ctx, item *db.IPQueueItem) (bool, error)`: +читает `o.DB.GetSettings(ctx)` заново при каждом вызове (тот же принцип +"читать из БД на каждое использование", что уже применён к +`check_types`/`sites` в этом файле — правки админа применяются мгновенно +даже к уже ожидающему адресу). Возвращает `true`, если +`settings.FIPSettleSeconds <= 0` **или** `item.FIPAssociatedAt == nil` +(это и есть гарантия обратной совместимости — см. ниже), иначе сравнивает +`db.Now()` с `item.FIPAssociatedAt.Add(settle)`. + +Новый экспортируемый `SetFIPSettleSeconds(ctx, seconds int) error` — +именно здесь, а не в `internal/db`, потому что нужен доступ к +`o.Cfg.SelfCheckTimeoutSeconds`/`o.Cfg.LeaseTTLSeconds`: +```go +if seconds < 0 { ... ErrValidation } +if seconds+o.Cfg.SelfCheckTimeoutSeconds >= o.Cfg.LeaseTTLSeconds { + ... ErrValidation ("fip_settle_seconds (%d) + self_check_timeout_seconds (%d) must be < lease_ttl_seconds (%d)") +} +return o.DB.SetFIPSettleSeconds(ctx, seconds) +``` +GET-путь отдельного метода на `Orchestrator` не получает — HTTP-хендлер +читает `s.DB.GetSettings` напрямую, как уже делают read-only +`handleConfigList*` в `handlers_config.go`. + +`associateFIP` (строка со `SetFIPAssociated`) не меняется — новую колонку +проставляет сам DB-слой. + +## 4. `internal/httpapi` + +`dto_admin.go`: `orchestratorSettingsDTO{FIPSettleSeconds int}` с тегом +`` json:"fip_settle_seconds" `` (используется и для GET-ответа, и как тело +PUT-запроса — по образцу однополевых DTO вроде `putSiteRequest`). + +`handlers_config.go`: `handleConfigGetOrchestratorSettings` (GET, зовёт +`s.DB.GetSettings`) и `handleConfigPutOrchestratorSettings` (PUT, зовёт +`s.Orch.SetFIPSettleSeconds`, ошибку — через уже существующий +`writeDBError`, который `db.ErrValidation` уже мапит на `400`). + +`routes.go`: `GET /api/v1/admin/config/orchestrator`, `PUT +/api/v1/admin/config/orchestrator` — рядом с остальным блоком +`/api/v1/admin/config/*`. + +`handlers_agent.go` не меняется: `handleAgentAssignment` уже превращает +`item == nil` в `204 No Content` — как только `AssignmentForValidator` +начнёт возвращать `nil` во время паузы, это автоматически попадёт в уже +существующую ветку. + +## 5. `internal/dashboard` + +`dto.go`: `FIPAssociatedAt *time.Time` в `ipQueueItem` (после +`AssignedAt` — важно совпадение имени с `db.IPQueueItem`, т.к. эти DTO +декодируются по имени поля без JSON-тегов); плюс +`orchestratorSettingsDTO{FIPSettleSeconds int}`. + +`client.go`: `GetOrchestratorSettings(ctx)`/`PutOrchestratorSettings(ctx, +seconds int)` — по образцу существующих методов, бьют в +`/api/v1/admin/config/orchestrator`. + +**Новая страница `/settings`** (не переиспользуем `sites.html`'s +3-слотовый layout — это одна форма с одним числовым полем, ближе по духу +к форме «Добавить/перепроверить» на `/ips`, чем к табличным +CRUD-страницам): +- новый `internal/dashboard/handlers_settings.go`: + `handleSettingsPage` (GET, зовёт `s.CA.GetOrchestratorSettings`, + `ActiveNav="settings"`, рендерит `settings_page`), + `handleSettingsPut` (PUT, `r.ParseForm()` + + `strconv.Atoi(r.PostFormValue("fip_settle_seconds"))` → на ошибку + парсинга `apiErr{400}`, иначе `s.CA.PutOrchestratorSettings`, дальше + переиспользовать `renderFragment` с фрагментом формы, тем же принципом, + что `renderIPsTable`: перечитать текущее значение через GET и + отрендерить независимо от исхода мутации). +- новый `internal/dashboard/templates/settings.html`: `settings_page` / + `settings_content` / `settings_form` (структура head/sidebar/topbar — + один в один с `ips.html`/`sites.html`), внутри — один `