# План: пауза перед 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`), внутри — один `
` с `` и кнопкой «Сохранить», плюс поясняющий `

` о смысле паузы и об ограничении `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`: новая `` (по образцу пяти уже существующих) между пунктом «Типы проверок» и закрывающим ``, с `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 (работающий процесс со старым бинарником не знает новых маршрутов).