Files

253 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План: удаление адресов из очереди (точечное, массовое, полная очистка)
> Статус: **реализовано**. Актуальное описание — [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-проверка
рендера форм/чекбоксов, как в предыдущих итерациях).