Files
cloud-ip-validator/docs/PLAN_DELETE_IPS.md
T

17 KiB
Raw Blame History

План: удаление адресов из очереди (точечное, массовое, полная очистка)

Статус: реализовано. Актуальное описание — 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.go
  • internal/dashboard/dto.go, client.go, handlers_ips.go, routes.go, templates/ips.html

Проверка

  1. go build ./... && go test ./....
  2. Ручной прогон на локальном стенде (scripts/run-local-e2e.sh + admin-dashboard): удалить адрес в queued → пропал из таблицы и БД; удалить адрес в checking → FIP реально отвязан (видно в моке/логах), валидатор свободен; выбрать чекбоксами несколько адресов → «Удалить выбранные» → все пропали, баннер без ошибок; «Очистить всё» на непустой очереди с активными проверками → очередь пуста, все FIP отвязаны, все валидаторы idle.
  3. Браузерный смоук-тест страницы /ips (расширение Claude in Chrome, если доступно в сессии реализации — иначе тщательная curl-проверка рендера форм/чекбоксов, как в предыдущих итерациях).