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

21 KiB
Raw Permalink Blame History

План: пауза перед self-check после привязки Floating IP (fip_settle_seconds)

Статус: реализовано. Актуальное описание — docs/API.md, docs/USAGE.md, docs/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:

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 она не касается):

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:

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 (работающий процесс со старым бинарником не знает новых маршрутов).