324 lines
21 KiB
Markdown
324 lines
21 KiB
Markdown
# План: пауза перед 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 (работающий процесс со старым бинарником
|
||
не знает новых маршрутов).
|