17 KiB
План: удаление адресов из очереди (точечное, массовое, полная очистка)
Статус: реализовано. Актуальное описание — docs/API.md, docs/USAGE.md, docs/DASHBOARD.md.
Context
Сейчас у оператора есть только два способа убрать адрес из активной
обработки — POST /api/v1/admin/ips/{ip}/cancel (переводит в failed с
overall_result=cancelled, но строка и вся история проверок остаются
навсегда) и косвенно POST /api/v1/admin/ips (принудительный повтор).
Настоящего удаления — чтобы адрес вообще пропал из очереди и его больше
никто не видел — нет. Нужно три операции: точечное удаление одного
адреса, удаление списка адресов одной командой, полная очистка очереди —
всё с поддержкой в UI дашборда.
Согласованные решения:
- Удаление безвозвратно: строка
ip_queueи вся её история (checks,eventsс этимip_id) удаляются физически, без возможности восстановления. Не путать сcancel— cancel сохраняет запись как историю, delete стирает её целиком. - Можно удалить адрес в любом состоянии, включая активно проверяемый
(
assigning_fip/awaiting_self_check/checking/aggregating) — удаление само выполняет то же самое, чтоForceCancelделает для освобождения ресурсов (отвязка FIP, освобождение валидатора), и сразу переходит к физическому удалению строки, не задерживаясь в промежуточном состоянииcancelled. - «Очистить список» = удалить вообще всё, включая активные проверки —
не только
queued/терминальные. - UI: чекбоксы в таблице очереди + кнопка «Удалить выбранные» для массового удаления списка; отдельная кнопка «Очистить всё» для полной очистки; кнопка «Удалить» в каждой строке для точечного удаления.
1. База данных — internal/db
Ограничение схемы, которое определяет реализацию: checks.ip_id
(NOT NULL) и events.ip_id ссылаются на ip_queue(id) без ON DELETE CASCADE (internal/db/migrations/0001_init.sql), validators.current_ip_id
тоже ссылается на ip_queue(id). При foreign_keys=ON (уже включено в
db.Open) физическое удаление строки ip_queue требует явно в той же
транзакции: очистить current_ip_id у владеющего валидатора (если есть),
удалить связанные checks, удалить связанные events — тот же паттерн,
что уже применён в DeleteValidator (internal/db/queries_validators.go)
для owner_validator_id.
internal/db/models.go: новый тип результата массовой операции —
type DeleteIPsResult struct {
Deleted []string
NotFound []string
}
internal/db/queries_ipqueue.go, новые функции:
DeleteIP(ctx, ipID int64) error— одна транзакция: освобождает владеющего валидатора (UPDATE validators SET state=idle, current_ip_id=NULL WHERE current_ip_id=?), удаляетchecks/eventsпоip_id, удаляет строкуip_queue.ErrNotFound, если строки нет (RowsAffected==0на финальном DELETE).DeleteIPs(ctx, addresses []string) (DeleteIPsResult, error)— одна транзакция на весь список: по каждому адресу резолвитid(не найден → вNotFound, не ошибка — тот же терпимый стиль, что уSubmitIPs), иначе повторяет шагиDeleteIPдля этогоid, добавляет адрес вDeleted. Используется и для точечного удаления списка, и (вызовом с полным списком адресов изListIPs) для операции «очистить всё» — отдельная функция для «удалить всё» не нужна.
2. Оркестратор — internal/orchestrator/orchestrator.go
DB-слой не ходит в OpenStack, поэтому отвязку floating IP для адресов с
непустым FIPID должен делать оркестратор — по той же best-effort схеме,
что уже в ForceCancel/aggregateAndRelease (лог при ошибке, не
прерывает операцию: DB-состояние обязано освободиться в любом случае).
// DeleteIP disassociates the floating IP if attached, then permanently
// removes the address and its history — differs from ForceCancel, which
// keeps a cancelled record instead of deleting it.
func (o *Orchestrator) DeleteIP(ctx context.Context, ipAddress string) error
// DeleteIPs does the same for a specific list in one call.
func (o *Orchestrator) DeleteIPs(ctx context.Context, addresses []string) (db.DeleteIPsResult, error)
// ClearQueue deletes every address currently in the queue, regardless of state.
func (o *Orchestrator) ClearQueue(ctx context.Context) (db.DeleteIPsResult, error)
DeleteIP: GetIPByAddress (обернуть sql.ErrNoRows в db.ErrNotFound,
как уже сделано в ForceCancel) → если FIPID != "" →
OS.DisassociateFloatingIP (best-effort) → DB.DeleteIP(ctx, item.ID).
DeleteIPs: по каждому адресу — GetIPByAddress (не найден — пропустить,
DB.DeleteIPs сам отметит его в NotFound), если FIPID != "" —
отвязать; затем один вызов DB.DeleteIPs(ctx, addresses).
ClearQueue: DB.ListIPs → для каждой с непустым FIPID отвязать →
DB.DeleteIPs(ctx, всеАдреса).
Все три логируют событие (o.event(...), event_type:
"ip_deleted"/"ips_deleted"/"queue_cleared") — но пишется общесистемное
событие с ip_id=nil и адресами в payload, а не привязанное к
конкретному ip_id: сам IP исчезнет вместе со своими событиями этим же
вызовом, так что привязка к нему бессмысленна.
3. HTTP API — internal/httpapi
Три новых маршрута под /api/v1/admin/ips, по аналогии с уже
реализованными submit/cancel (docs/API.md#управление-очередью-и-конфигурацией):
| Метод | Путь | Тело | Успех | Ошибки |
|---|---|---|---|---|
| DELETE | /api/v1/admin/ips/{ip} |
— | 200 {"ok":true} |
404 неизвестный адрес |
| POST | /api/v1/admin/ips/delete |
{"addresses":[...]} |
200 {"deleted":[...],"not_found":[...]} |
400 пустой список |
| POST | /api/v1/admin/ips/clear |
— | 200 {"deleted":[...]} |
— |
Выбор POST .../delete и POST .../clear вместо DELETE с телом —
чтобы не смешивать «удалить один по пути» (чистый REST) с «удалить по
списку/всё» (тело обязательно), и по аналогии с уже существующим
паттерном action-эндпоинтов (.../cancel). Оба пути статические
литералы — с существующим .../ips/{ip}/cancel не пересекаются (разное
число сегментов), с реальными IP-адресами как значением {ip} тоже
(слова "delete"/"clear" не бывают адресами).
Новые DTO в internal/httpapi/dto_admin.go (snake_case, по образцу
submitIPsResponse):
type deleteIPsRequest struct{ Addresses []string `json:"addresses"` }
type deleteIPsResponse struct {
Deleted []string `json:"deleted"`
NotFound []string `json:"not_found"`
}
type clearQueueResponse struct{ Deleted []string `json:"deleted"` }
Новые хендлеры в internal/httpapi/handlers_admin.go (в отличие от
handleAdminSubmitIPs, который зовёт s.DB напрямую, эти три идут через
s.Orch — нужен OS.DisassociateFloatingIP):
handleAdminDeleteIP, handleAdminDeleteIPs, handleAdminClearQueue —
ошибки маппятся через уже существующий writeDBError.
routes.go: добавить три mux.HandleFunc(...) рядом с существующими
/api/v1/admin/ips*.
4. Dashboard — internal/dashboard
dto.go: deleteIPsResponse{Deleted, NotFound []string},
clearQueueResponse{Deleted []string} — зеркало новых control-api DTO.
client.go: DeleteIP(ctx, ip) error, DeleteIPs(ctx, addresses []string) (deleteIPsResponse, error), ClearQueue(ctx) (clearQueueResponse, error) — по образцу существующих методов CancelIP/SubmitIPs.
handlers_ips.go, новые хендлеры (все, как и остальные мутации /ips/*,
безусловно перерисовывают ips_table через уже существующий
renderIPsTable — сравнение с предыдущим состоянием не нужно, таблица
после удаления просто станет короче):
handleIPDelete—DELETE /ips/{ip}→s.CA.DeleteIP.handleIPsDeleteSelected—POST /ips/delete→ парситr.ParseForm()+r.Form["addresses"](чекбоксы с одинаковымname="addresses"в одной форме — стандартная сериализация форм, htmx отправит все отмеченные); пустой список → баннерErrValidation-стиль (400, «ничего не выбрано»), не идёт в API.handleIPsClear—POST /ips/clear→s.CA.ClearQueue, без тела.
routes.go: три новых маршрута рядом с существующими /ips*.
Шаблоны — templates/ips.html
- Каждая строка таблицы получает чекбокс
<input type="checkbox" name="addresses" value="{{.IPAddress}}">в новой первой колонке; чекбокс в<thead>для «выбрать всё» (простой инлайновыйonclick, без Alpine — переключаетcheckedу всехinput[name=addresses]в форме, минимальная логика, JS-фреймворк не нужен). - Вся таблица оборачивается в
<form id="ips-form">; кнопка «Удалить выбранные» —type="submit" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить выбранные адреса без возможности восстановления?". - Кнопка «Очистить всё» — отдельно (не завязана на выбор),
hx-post="/ips/clear" hx-target="#ips-table-wrap" hx-swap="innerHTML", с явно пугающимhx-confirm(«Удалить ВСЕ адреса из очереди, включая те, что сейчас проверяются? Действие необратимо.») — самая опасная операция страницы, формулировка должна это отражать. - В каждой строке — кнопка «Удалить» (
.btn-danger-ghost,hx-delete="/ips/{{.IPAddress}}", свойhx-confirm) рядом с уже существующими «Перепроверить»/«Отменить» — для активных строк остаются обе опции (Отменить = сохранить историю как cancelled, Удалить = стереть безвозвратно вместе с историей).
Никакой отдельной серверной "confirm-токен" защиты для /clear не
вводится (как и у остальных деструктивных операций в этом API,
например DELETE /admin/config/validators/{id}) — подтверждение только
на уровне UI (hx-confirm), задокументировать необратимость явно в
docs/API.md/docs/DASHBOARD.md.
5. Тесты
internal/db/queries_dynconfig_test.go(или новый файл рядом) —TestDeleteIP: удаляет queued-адрес, проверяет, что строка и еёchecks/eventsпропали;TestDeleteIPFreesOwningValidator: удаление адреса, которым сейчас владеет валидатор (послеClaimNextQueued) — валидатор становитсяidle/current_ip_id=NULL;TestDeleteIPs: смешанный список (существующий + несуществующий адрес) →Deleted/NotFoundкорректны.internal/orchestrator/orchestrator_test.go— тест, чтоDeleteIPна адресе с привязанным (через мок) FIP вызываетDisassociateFloatingIPперед удалением.internal/httpapi/handlers_config_test.go(или новый) — сквозной сценарий: create → assign →DELETE /admin/ips/{ip}во времяchecking→ FIP отвязан (мок), строка пропала изGET /admin/ips, повторныйGET /admin/ips/{ip}→404;POST /admin/ips/deleteс несколькими адресами;POST /admin/ips/clearс несколькими адресами в разных состояниях → очередь пуста.internal/dashboard/handlers_test.go— фейковый control-api дополняется маршрутами delete/delete-list/clear; тесты на рендер чекбоксов, на пустой выбор (баннер, не 500), наclear.
6. Документация
docs/API.md: новые строки в таблицу раздела «Управление очередью и конфигурацией» (рядом сPOST /admin/ipsи/cancel) + явное предупреждение о необратимости в отличие отcancel.docs/USAGE.md: короткий раздел «Удаление адресов из очереди» рядом с «Принудительная остановка проверки» — с чёткой формулировкой отличия delete vs cancel.docs/DASHBOARD.md: обновить описание страницы/ips— чекбоксы, «Удалить выбранные», «Очистить всё».
Критичные файлы
internal/db/models.go(DeleteIPsResult),internal/db/queries_ipqueue.go(DeleteIP,DeleteIPs)internal/orchestrator/orchestrator.go(DeleteIP,DeleteIPs,ClearQueue)internal/httpapi/dto_admin.go,handlers_admin.go,routes.gointernal/dashboard/dto.go,client.go,handlers_ips.go,routes.go,templates/ips.html
Проверка
go build ./... && go test ./....- Ручной прогон на локальном стенде (
scripts/run-local-e2e.sh+admin-dashboard): удалить адрес вqueued→ пропал из таблицы и БД; удалить адрес вchecking→ FIP реально отвязан (видно в моке/логах), валидатор свободен; выбрать чекбоксами несколько адресов → «Удалить выбранные» → все пропали, баннер без ошибок; «Очистить всё» на непустой очереди с активными проверками → очередь пуста, все FIP отвязаны, все валидаторыidle. - Браузерный смоук-тест страницы
/ips(расширение Claude in Chrome, если доступно в сессии реализации — иначе тщательная curl-проверка рендера форм/чекбоксов, как в предыдущих итерациях).