# План: пауза перед 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`), внутри — один `