21 KiB
План: пауза перед 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'sfakeControlAPI— дополнить 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.gointernal/httpapi/handlers_config.go,dto_admin.go,routes.gointernal/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
Проверка
go build ./... && go vet ./... && go test ./....- Ручной прогон: поставить
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. - Пересобрать
bin/control-apiиbin/admin-dashboard(go build -trimpath -ldflags="-s -w" ..., обновитьbin/SHA256SUMS) — без этого шага повторится та же проблема, что уже была с 405 при удалении IP (работающий процесс со старым бинарником не знает новых маршрутов).