252 lines
17 KiB
Markdown
252 lines
17 KiB
Markdown
# План: удаление адресов из очереди (точечное, массовое, полная очистка)
|
||||
|
|
|
|||
|
|
> Статус: **реализовано**. Актуальное описание — [docs/API.md](API.md#delete-apiv1adminipsip-post-apiv1adminipsdelete-post-apiv1adminipsclear),
|
|||
|
|
> [docs/USAGE.md](USAGE.md#удаление-адресов-из-очереди),
|
|||
|
|
> [docs/DASHBOARD.md](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`: новый тип результата массовой операции —
|
|||
|
|
|
|||
|
|
```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-состояние обязано освободиться в любом случае).
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 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`):
|
|||
|
|
```go
|
|||
|
|
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-проверка
|
|||
|
|
рендера форм/чекбоксов, как в предыдущих итерациях).
|