Files
cloud-ip-validator/docs/PLAN_FIP_SETTLE_DELAY.md
T

324 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План: пауза перед 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`), внутри — один `<form
hx-put="/settings" hx-target="#settings-form-wrap"
hx-swap="innerHTML">` с `<input type="number" name="fip_settle_seconds"
min="0" value="{{.Settings.FIPSettleSeconds}}" required>` и кнопкой
«Сохранить», плюс поясняющий `<p class="muted">` о смысле паузы и об
ограничении `fip_settle_seconds + self_check_timeout_seconds <
lease_ttl_seconds` (ошибка валидации придёт баннером через уже
существующий `bannerFor`/`error_banner`).
- `internal/dashboard/routes.go`: `GET /settings`, `PUT /settings`.
- `internal/dashboard/templates/layout.html`, блок `sidebar_nav`: новая
`<a class="nav-link...">` (по образцу пяти уже существующих) между
пунктом «Типы проверок» и закрывающим `</nav>`, с `ActiveNav ==
"settings"`.
**Видимость паузы на `/ips`**:
- `handlers_ips.go`: `ipsPageData` получает поле `FIPSettleSeconds int`;
`handleIPsPage` и `renderIPsTable` дополнительно зовут
`s.CA.GetOrchestratorSettings(r.Context())` (один недорогой лишний
запрос на рендер, тот же принцип, что уже применён на странице обзора)
и прокидывают значение в `ipsPageData`, ошибку — в общий `err`/
`actionErr` перед `bannerFor`.
- `render.go`, `ipBadge`: расширить сигнатуру до `ipBadge(state, result
string, fipAssociatedAt *time.Time, settleSeconds int) Badge` — новая
ветка `case "awaiting_self_check"`: если `settleSeconds > 0 &&
fipAssociatedAt != nil && time.Now().Before(fipAssociatedAt.Add(...))`
→ `Badge{"pill-warning", "прогрев FIP"}`, иначе прежний дефолт
`Badge{"pill-info", state}`. `.pill-warning` уже есть в
`static/dashboard.css` — новых CSS-классов не требуется. Время
считается в Go-хелпере, не в шаблоне (тот же принцип, что уже у
`fmtTime`). `funcMap` регистрацию менять не нужно — то же имя, просто
шире сигнатура.
- `templates/ips.html`: `{{$b := ipBadge .State .OverallResult
.FIPAssociatedAt $.FIPSettleSeconds}}` (`$` внутри `{{range .Items}}`
корректно указывает на корневые данные шаблона `ips_table`, то есть
`ipsPageData`).
## Обратная совместимость
- `fip_associated_at` — nullable, добавляется через `ALTER TABLE`; все
существующие строки (включая активно проверяемые на момент апгрейда)
получают `NULL`.
- `isFIPSettled` явно трактует `FIPAssociatedAt == nil` как «уже
прогрелся» — так что ни один адрес, начавший цикл до апгрейда, не
будет неожиданно задержан, даже если админ уже успел выставить
ненулевую паузу. Задержке подвержены только адреса, прошедшие
`associateFIP` **после** миграции.
- Дефолт `fip_settle_seconds=0` (если не задан явно ни в YAML, ни через
API) — поведение системы для всех, кто не трогал новую настройку,
не меняется вообще.
## 6. Тесты
- **`internal/db`**: новый `queries_settings_test.go` —
`TestBootstrapSettingsSeedsOnceFromConfig` (сид только на пустой
таблице), `TestSetFIPSettleSecondsRejectsNegative`,
`TestGetSetFIPSettleSecondsRoundTrip`. Новый `queries_ipqueue_test.go` —
`TestSetFIPAssociatedStampsTimestamp`, `TestRequeueClearsFIPAssociatedAt`.
Использовать существующий `newTestDB` (`queries_dynconfig_test.go`).
- **`internal/orchestrator`** (`orchestrator_test.go`, существующие
`newTestOrchestrator`/`newTestOrchestratorWithSites`):
`TestFIPSettleDelayWithholdsAssignment` (после `Tick` назначение не
отдаётся, пока не пройдёт настроенная пауза — `time.Sleep`, как уже
делает `TestLeaseReclaim`), `TestFIPSettleDelayZeroIsNoOp`,
`TestFIPSettleDelayIgnoresNilFIPAssociatedAt` (обратная
совместимость), `TestSetFIPSettleSecondsValidation` (табличный тест на
границу с `lease_ttl_seconds`/`self_check_timeout_seconds`).
- **`internal/httpapi`** (`handlers_config_test.go`,
`newConfigTestHarness`/`fakeClient`): `TestOrchestratorSettingsGetPut`,
`TestOrchestratorSettingsPutValidation` (400 на нарушение ограничения);
сквозной тест, что `GET /agents/{id}/assignment` отдаёт `204` во время
паузы и `200` после.
- **`internal/dashboard`** (`dashboard_test.go`'s `fakeControlAPI` —
дополнить GET/PUT `/api/v1/admin/config/orchestrator` и полем
`FIPAssociatedAt` у фейковых IP; `handlers_test.go`):
`TestSettingsGetAndPut`, `TestIPsPageShowsSettleBadge`.
- В конце: `go build ./... && go vet ./... && go test ./...`.
## 7. Документация
- `docs/API.md`: новая подсекция `### Настройки оркестратора:
/api/v1/admin/config/orchestrator` рядом с остальными
`/admin/config/*`, с таблицей метод/путь/тело/ошибки и явным описанием
правила валидации; короткая ремарка в разделе «Модель состояний», что
вход в `awaiting_self_check` не означает мгновенную видимость агенту
при ненулевом `fip_settle_seconds`.
- `docs/USAGE.md`: новый короткий раздел про паузу (что это, как
настраивается — `/settings` в дашборде или API, ограничение по
`lease_ttl_seconds`/`self_check_timeout_seconds`, что при нарушении —
жёсткий отказ, не молчаливое обрезание); дополнить существующий пункт
«Адрес не выходит из `awaiting_self_check`» в «Частых проблемах»
ремаркой, что ожидаемая недолгая пауза здесь — не то же самое, что
реальный сетевой сбой, который там уже описан.
- `docs/DASHBOARD.md`: новая строка `/settings` в таблице страниц.
- `configs/control-api.example.yaml`: закомментированное
`fip_settle_seconds: 0` под `orchestrator:` с пояснением (одноразовый
seed, дальше управляется через API/dashboard, ограничение по
`lease_ttl_seconds`).
## Критичные файлы
- `internal/db/queries_ipqueue.go`, `internal/db/queries_settings.go` (новый),
`internal/db/bootstrap.go`, `internal/db/db.go`,
`internal/db/migrations/0003_fip_settle_delay.sql` (новый)
- `internal/orchestrator/orchestrator.go`
- `internal/httpapi/handlers_config.go`, `dto_admin.go`, `routes.go`
- `internal/dashboard/handlers_settings.go` (новый),
`internal/dashboard/handlers_ips.go`, `render.go`, `client.go`, `dto.go`,
`routes.go`, `templates/settings.html` (новый), `templates/layout.html`,
`templates/ips.html`
## Проверка
1. `go build ./... && go vet ./... && go test ./...`.
2. Ручной прогон: поставить `fip_settle_seconds=5` через
`PUT /api/v1/admin/config/orchestrator` (или страницу `/settings`),
поставить адрес в очередь, убедиться что `GET
/api/v1/agents/{id}/assignment` отдаёт `204` первые ~5с после привязки
FIP (видно в логах `fip_associated`) и `200` после; на `/ips`
убедиться, что строка в этот момент показывает бейдж «прогрев FIP»;
попытаться выставить значение, нарушающее
`fip_settle_seconds + self_check_timeout_seconds < lease_ttl_seconds`
— получить `400`.
3. Пересобрать `bin/control-api` и `bin/admin-dashboard`
(`go build -trimpath -ldflags="-s -w" ...`, обновить
`bin/SHA256SUMS`) — без этого шага повторится та же проблема, что уже
была с 405 при удалении IP (работающий процесс со старым бинарником
не знает новых маршрутов).