Files

323 lines
21 KiB
Markdown
Raw Permalink Normal View History

2026-08-24 10:29:08 +03:00
# План: пауза перед 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 (работающий процесс со старым бинарником
не знает новых маршрутов).