Files

252 lines
17 KiB
Markdown
Raw Permalink Normal View History

2026-08-23 22:24:55 +03:00
# План: удаление адресов из очереди (точечное, массовое, полная очистка)
> Статус: **реализовано**. Актуальное описание — [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-проверка
рендера форм/чекбоксов, как в предыдущих итерациях).