From f8336740ad658e0b6ef3fc9105be11ac05154a68 Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Sun, 23 Aug 2026 22:24:55 +0300 Subject: [PATCH] feature: handle delete operation for IPs --- docs/API.md | 39 +++- docs/DASHBOARD.md | 16 +- docs/PLAN_DELETE_IPS.md | 252 +++++++++++++++++++++ docs/USAGE.md | 41 ++++ internal/dashboard/client.go | 21 ++ internal/dashboard/dashboard_test.go | 48 ++++ internal/dashboard/dto.go | 9 + internal/dashboard/handlers_ips.go | 25 ++ internal/dashboard/handlers_test.go | 70 ++++++ internal/dashboard/routes.go | 3 + internal/dashboard/templates/ips.html | 10 +- internal/db/models.go | 8 + internal/db/queries_dynconfig_test.go | 101 +++++++++ internal/db/queries_ipqueue.go | 84 +++++++ internal/httpapi/dto_admin.go | 13 ++ internal/httpapi/handlers_admin.go | 48 ++++ internal/httpapi/handlers_config_test.go | 121 ++++++++++ internal/httpapi/routes.go | 3 + internal/orchestrator/orchestrator.go | 100 ++++++++ internal/orchestrator/orchestrator_test.go | 76 +++++++ 20 files changed, 1083 insertions(+), 5 deletions(-) create mode 100644 docs/PLAN_DELETE_IPS.md diff --git a/docs/API.md b/docs/API.md index 471ab6d..685766c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -382,6 +382,33 @@ YAML для этой секции больше не перечитывается Ответ: `{"ok": true}`. `404`, если адрес неизвестен. `409`, если адрес уже в терминальном состоянии (`done`/`failed`/уже отменён) — отменять нечего. +### `DELETE /api/v1/admin/ips/{ip}`, `POST /api/v1/admin/ips/delete`, `POST /api/v1/admin/ips/clear` + +**Безвозвратное удаление**, в отличие от `cancel` выше: строка `ip_queue` +и вся её история (`checks`, `events`) стираются физически, без возможности +восстановления. Работает из любого состояния, включая активно +проверяемое — если Floating IP привязан, он отвязывается тем же +best-effort способом, что и при `cancel`/обычном завершении, владеющий +валидатор освобождается. + +| Метод | Путь | Тело | Успех | Ошибки | +|---|---|---|---|---| +| 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` удаляет ровно перечисленный список (неизвестные адреса +идут в `not_found`, не ошибка — тот же терпимый стиль, что у `POST +/api/v1/admin/ips`). `POST .../clear` удаляет **вообще всё**, что сейчас в +очереди, включая адреса в процессе проверки — самая опасная операция +этого API, используйте с осторожностью. + +```bash +curl -s -X DELETE "$BASE/api/v1/admin/ips/203.0.113.10" +curl -s -X POST "$BASE/api/v1/admin/ips/delete" -d '{"addresses":["203.0.113.10","203.0.113.11"]}' +curl -s -X POST "$BASE/api/v1/admin/ips/clear" +``` + ### Валидаторы: `/api/v1/admin/config/validators` | Метод | Путь | Тело | Успех | Ошибки | @@ -489,12 +516,18 @@ queued ──(control-api сам, без вызова API)──▶ assigning_fi ждёт только `egress_complete`, ни одна площадка не требуется. Подробнее — [USAGE.md](USAGE.md#управление-площадками-проберами). -Два дополнительных перехода, оба инициируются оператором через -`/api/v1/admin/ips`, а не самим оркестратором: +Три дополнительных перехода, все инициируются оператором через +`/api/v1/admin/ips*`, а не самим оркестратором: - **любое нетерминальное состояние → `failed` (`overall_result: "cancelled"`)** — `POST /api/v1/admin/ips/{ip}/cancel`; - **`done`/`failed` → `queued` (новая попытка)** — `POST - /api/v1/admin/ips` с уже завершённым адресом в списке. + /api/v1/admin/ips` с уже завершённым адресом в списке; +- **любое состояние → адрес физически исчезает из очереди**, вместе со + всей историей — `DELETE /api/v1/admin/ips/{ip}`, `POST + /api/v1/admin/ips/delete`, `POST /api/v1/admin/ips/clear` (см. + [выше](#delete-apiv1adminipsip-post-apiv1adminipsdelete-post-apiv1adminipsclear)). + Не путать с cancel — cancel сохраняет запись как историю (`failed`/ + `cancelled`), delete стирает её целиком без возможности восстановления. ## Сквозной пример работы (curl) diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index 65ce227..c12d4e2 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -49,7 +49,7 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml | Страница | Назначение | |---|---| | `/overview` | Сводная статистика: счётчики по состояниям, «текущая проверка» (live-снимок всех IP не в терминальном состоянии) и «последние N завершённых» (по умолчанию 20, `overview.last_completed_count`) с разбивкой pass/partial/fail/cancelled. Обновляется каждые `overview.poll_interval_seconds` секунд без перезагрузки страницы. | -| `/ips` | Полная очередь. Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний). | +| `/ips` | Полная очередь. Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний), и всегда — «Удалить» (безвозвратно, в отличие от «Отменить», см. ниже). Чекбоксы у строк + кнопка «Удалить выбранные» удаляют список одним вызовом; «Очистить всё» удаляет вообще всё, включая активные проверки — обе операции требуют явного подтверждения. | | `/ips/{ip}` | Детали одного адреса: все проверки текущей попытки и вся история событий. | | `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. | | `/sites` | Три фиксированных слота площадок (1/2/3) — назначить/сменить/освободить `site_id`. | @@ -82,6 +82,20 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml (дашборд честно показывает это в таблице, а не делает вид, что запрос ничего не значил). +### Удаление адресов — безвозвратно, в отличие от «Отменить» + +«Отменить» (`POST .../cancel`) останавливает проверку, но сохраняет +адрес и его историю как `failed`/`cancelled` — он остаётся виден в +очереди. «Удалить» (кнопка в строке, «Удалить выбранные» по чекбоксам, +«Очистить всё») стирает строку и всю её историю проверок/событий +физически, без возможности восстановления — работает из любого +состояния, включая активно проверяемое (Floating IP отвязывается, +валидатор освобождается). Все три операции удаления в UI защищены +`hx-confirm` с формулировкой, отражающей необратимость — «Очистить всё» +предупреждает отдельно, так как затрагивает и активные проверки. Подробнее +— [API.md](API.md#delete-apiv1adminipsip-post-apiv1adminipsdelete-post-apiv1adminipsclear) +и [USAGE.md](USAGE.md#удаление-адресов-из-очереди). + ## Конфигурация См. `configs/admin-dashboard.example.yaml`. Ключевые поля: diff --git a/docs/PLAN_DELETE_IPS.md b/docs/PLAN_DELETE_IPS.md new file mode 100644 index 0000000..f5c59fb --- /dev/null +++ b/docs/PLAN_DELETE_IPS.md @@ -0,0 +1,252 @@ +# План: удаление адресов из очереди (точечное, массовое, полная очистка) + +> Статус: **реализовано**. Актуальное описание — [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` + +- Каждая строка таблицы получает чекбокс + `` + в новой первой колонке; чекбокс в `` для «выбрать всё» + (простой инлайновый `onclick`, без Alpine — переключает `checked` у + всех `input[name=addresses]` в форме, минимальная логика, JS-фреймворк + не нужен). +- Вся таблица оборачивается в `
`; кнопка «Удалить + выбранные» — `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-проверка + рендера форм/чекбоксов, как в предыдущих итерациях). diff --git a/docs/USAGE.md b/docs/USAGE.md index 6b1693c..9001fc8 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -20,6 +20,7 @@ - [Управление целями проверки](#управление-целями-проверки) - [Повторная проверка адреса](#повторная-проверка-адреса) - [Принудительная остановка проверки](#принудительная-остановка-проверки) +- [Удаление адресов из очереди](#удаление-адресов-из-очереди) - [Частые проблемы и что с ними делать](#частые-проблемы-и-что-с-ними-делать) ## Как устроена работа с системой @@ -372,6 +373,46 @@ curl -s http://:8080/api/v1/admin/ips/203.0.113.10 | python3 -m jso [«Повторную проверку адреса»](#повторная-проверка-адреса) выше, она одинаково работает и для отменённых, и для обычно завершённых адресов. +## Удаление адресов из очереди + +**Отличие от отмены (`cancel`) выше: удаление безвозвратно.** Cancel +переводит адрес в `failed`/`cancelled` и сохраняет запись как историю — +её видно в очереди и в деталях адреса. Delete физически стирает строку +`ip_queue` и всю её историю проверок и событий: адрес полностью исчезает, +восстановить его нельзя. Если нужно просто остановить зависшую проверку, +но сохранить её результат в истории — используйте +[«Принудительную остановку проверки»](#принудительная-остановка-проверки) +выше, а не удаление. + +Удалить один адрес (работает из любого состояния, включая активно +проверяемое — Floating IP при этом отвязывается, а владевший валидатор +освобождается, точно так же, как при cancel): + +```bash +curl -s -X DELETE http://:8080/api/v1/admin/ips/203.0.113.10 +``` + +Удалить список адресов одним вызовом (неизвестные адреса просто +попадают в `not_found`, не считаются ошибкой): + +```bash +curl -s -X POST http://:8080/api/v1/admin/ips/delete \ + -d '{"addresses": ["203.0.113.10", "203.0.113.11"]}' +``` + +Полностью очистить очередь — **самая опасная операция**, удаляет вообще +всё, включая адреса, которые прямо сейчас проверяются: + +```bash +curl -s -X POST http://:8080/api/v1/admin/ips/clear +``` + +В `admin-dashboard` то же самое доступно на странице `/ips`: чекбоксы у +каждой строки + кнопка «Удалить выбранные» для точечного/массового +удаления, кнопка «Удалить» в каждой строке, и отдельная кнопка «Очистить +всё» — каждая с подтверждением, явно предупреждающим о необратимости +(см. [DASHBOARD.md](DASHBOARD.md)). + ## Частые проблемы и что с ними делать **Валидатор долго висит в `unreachable`.** diff --git a/internal/dashboard/client.go b/internal/dashboard/client.go index eb8bbf8..a8e9d06 100644 --- a/internal/dashboard/client.go +++ b/internal/dashboard/client.go @@ -115,6 +115,27 @@ func (c *client) CancelIP(ctx context.Context, ip string) error { return c.do(ctx, http.MethodPost, "/api/v1/admin/ips/"+url.PathEscape(ip)+"/cancel", nil, nil) } +// DeleteIP permanently removes one address and its full history — unlike +// CancelIP, there's nothing left to look up afterward. +func (c *client) DeleteIP(ctx context.Context, ip string) error { + return c.do(ctx, http.MethodDelete, "/api/v1/admin/ips/"+url.PathEscape(ip), nil, nil) +} + +// DeleteIPs permanently removes a specific list of addresses in one call. +func (c *client) DeleteIPs(ctx context.Context, addresses []string) (deleteIPsResponse, error) { + var out deleteIPsResponse + err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips/delete", map[string][]string{"addresses": addresses}, &out) + return out, err +} + +// ClearQueue permanently removes every address currently in the queue, +// including those actively being checked. +func (c *client) ClearQueue(ctx context.Context) (clearQueueResponse, error) { + var out clearQueueResponse + err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips/clear", nil, &out) + return out, err +} + func (c *client) ListValidators(ctx context.Context) ([]validatorDTO, error) { var out []validatorDTO err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/validators", nil, &out) diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go index 4903d8a..607875c 100644 --- a/internal/dashboard/dashboard_test.go +++ b/internal/dashboard/dashboard_test.go @@ -139,6 +139,54 @@ func (f *fakeControlAPI) handler() http.Handler { writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }) + mux.HandleFunc("DELETE /api/v1/admin/ips/{ip}", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + addr := r.PathValue("ip") + idx := f.findIP(addr) + if idx < 0 { + writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr) + return + } + f.ips = append(f.ips[:idx], f.ips[idx+1:]...) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) + }) + + mux.HandleFunc("POST /api/v1/admin/ips/delete", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + var req struct { + Addresses []string `json:"addresses"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if len(req.Addresses) == 0 { + writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty") + return + } + resp := deleteIPsResponse{} + for _, addr := range req.Addresses { + idx := f.findIP(addr) + if idx < 0 { + resp.NotFound = append(resp.NotFound, addr) + continue + } + f.ips = append(f.ips[:idx], f.ips[idx+1:]...) + resp.Deleted = append(resp.Deleted, addr) + } + writeJSON(w, http.StatusOK, resp) + }) + + mux.HandleFunc("POST /api/v1/admin/ips/clear", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + resp := clearQueueResponse{} + for _, ip := range f.ips { + resp.Deleted = append(resp.Deleted, ip.IPAddress) + } + f.ips = nil + writeJSON(w, http.StatusOK, resp) + }) + mux.HandleFunc("GET /api/v1/admin/config/validators", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/dashboard/dto.go b/internal/dashboard/dto.go index 2cb3c02..197e8fd 100644 --- a/internal/dashboard/dto.go +++ b/internal/dashboard/dto.go @@ -89,6 +89,15 @@ type submitIPsResponse struct { SkippedInProgress []string `json:"skipped_in_progress"` } +type deleteIPsResponse struct { + Deleted []string `json:"deleted"` + NotFound []string `json:"not_found"` +} + +type clearQueueResponse struct { + Deleted []string `json:"deleted"` +} + type validatorDTO struct { ValidatorID string `json:"validator_id"` OSPortID string `json:"os_port_id"` diff --git a/internal/dashboard/handlers_ips.go b/internal/dashboard/handlers_ips.go index 79dad69..b972bd1 100644 --- a/internal/dashboard/handlers_ips.go +++ b/internal/dashboard/handlers_ips.go @@ -69,3 +69,28 @@ func (s *Server) handleIPCancel(w http.ResponseWriter, r *http.Request) { err := s.CA.CancelIP(r.Context(), ip) s.renderIPsTable(w, r, err) } + +func (s *Server) handleIPDelete(w http.ResponseWriter, r *http.Request) { + ip := r.PathValue("ip") + err := s.CA.DeleteIP(r.Context(), ip) + s.renderIPsTable(w, r, err) +} + +func (s *Server) handleIPsDeleteSelected(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err)) + return + } + addresses := r.Form["addresses"] + if len(addresses) == 0 { + s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"}) + return + } + _, err := s.CA.DeleteIPs(r.Context(), addresses) + s.renderIPsTable(w, r, err) +} + +func (s *Server) handleIPsClear(w http.ResponseWriter, r *http.Request) { + _, err := s.CA.ClearQueue(r.Context()) + s.renderIPsTable(w, r, err) +} diff --git a/internal/dashboard/handlers_test.go b/internal/dashboard/handlers_test.go index 95084fa..cfb038b 100644 --- a/internal/dashboard/handlers_test.go +++ b/internal/dashboard/handlers_test.go @@ -105,6 +105,76 @@ func TestIPCancel(t *testing.T) { } } +func TestIPDelete(t *testing.T) { + fake, caURL := newFakeControlAPI(t) + now := time.Now() + fake.ips = []ipQueueItem{{IPAddress: "7.7.7.7", State: "checking", UpdatedAt: now, CreatedAt: now}} + ts := newTestServer(t, caURL) + + body := postForm(t, ts, "DELETE", "/ips/7.7.7.7", nil) + if strings.Contains(body, "7.7.7.7") { + t.Fatalf("expected 7.7.7.7 removed from table, got:\n%s", body) + } + if len(fake.ips) != 0 { + t.Fatalf("expected fake control-api queue empty, got %+v", fake.ips) + } + + // Deleting an already-gone address is a 404, surfaced via banner. + body = postForm(t, ts, "DELETE", "/ips/7.7.7.7", nil) + if !strings.Contains(body, "error-banner") { + t.Fatalf("expected error banner deleting unknown ip, got:\n%s", body) + } +} + +func TestIPsDeleteSelected(t *testing.T) { + fake, caURL := newFakeControlAPI(t) + now := time.Now() + fake.ips = []ipQueueItem{ + {IPAddress: "1.1.1.1", State: "queued", UpdatedAt: now, CreatedAt: now}, + {IPAddress: "2.2.2.2", State: "queued", UpdatedAt: now, CreatedAt: now}, + {IPAddress: "3.3.3.3", State: "queued", UpdatedAt: now, CreatedAt: now}, + } + ts := newTestServer(t, caURL) + + body := postForm(t, ts, "POST", "/ips/delete", map[string][]string{"addresses": {"1.1.1.1", "2.2.2.2"}}) + if strings.Contains(body, "1.1.1.1") || strings.Contains(body, "2.2.2.2") { + t.Fatalf("expected selected addresses removed, got:\n%s", body) + } + if !strings.Contains(body, "3.3.3.3") { + t.Fatalf("expected untouched address still listed, got:\n%s", body) + } + if len(fake.ips) != 1 || fake.ips[0].IPAddress != "3.3.3.3" { + t.Fatalf("expected only 3.3.3.3 left in fake control-api, got %+v", fake.ips) + } + + // Empty selection is a client error banner, not a 500. + body = postForm(t, ts, "POST", "/ips/delete", nil) + if !strings.Contains(body, "alert-warning") { + t.Fatalf("expected client error banner for empty selection, got:\n%s", body) + } +} + +func TestIPsClear(t *testing.T) { + fake, caURL := newFakeControlAPI(t) + now := time.Now() + fake.ips = []ipQueueItem{ + {IPAddress: "1.1.1.1", State: "checking", UpdatedAt: now, CreatedAt: now}, + {IPAddress: "2.2.2.2", State: "queued", UpdatedAt: now, CreatedAt: now}, + } + ts := newTestServer(t, caURL) + + body := postForm(t, ts, "POST", "/ips/clear", nil) + if strings.Contains(body, "1.1.1.1") || strings.Contains(body, "2.2.2.2") { + t.Fatalf("expected all addresses removed, got:\n%s", body) + } + if !strings.Contains(body, "Очередь пуста") { + t.Fatalf("expected empty-queue message, got:\n%s", body) + } + if len(fake.ips) != 0 { + t.Fatalf("expected fake control-api queue empty, got %+v", fake.ips) + } +} + func TestValidatorsCRUD(t *testing.T) { _, caURL := newFakeControlAPI(t) ts := newTestServer(t, caURL) diff --git a/internal/dashboard/routes.go b/internal/dashboard/routes.go index 54c42ab..ac56e2c 100644 --- a/internal/dashboard/routes.go +++ b/internal/dashboard/routes.go @@ -13,6 +13,9 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /ips", s.handleIPsSubmit) mux.HandleFunc("POST /ips/{ip}/recheck", s.handleIPRecheck) mux.HandleFunc("POST /ips/{ip}/cancel", s.handleIPCancel) + mux.HandleFunc("DELETE /ips/{ip}", s.handleIPDelete) + mux.HandleFunc("POST /ips/delete", s.handleIPsDeleteSelected) + mux.HandleFunc("POST /ips/clear", s.handleIPsClear) mux.HandleFunc("GET /validators", s.handleValidatorsPage) mux.HandleFunc("POST /validators", s.handleValidatorCreate) diff --git a/internal/dashboard/templates/ips.html b/internal/dashboard/templates/ips.html index a15f5e9..18591c2 100644 --- a/internal/dashboard/templates/ips.html +++ b/internal/dashboard/templates/ips.html @@ -43,15 +43,21 @@ {{end}} {{define "ips_table"}} +
+
+ + +
- + {{range .Items}} {{$b := ipBadge .State .OverallResult}} {{$terminal := or (eq .State "done") (eq .State "failed")}} + @@ -64,6 +70,7 @@ {{else}} {{end}} + @@ -72,5 +79,6 @@
АдресСостояниеВалидаторПопыткаОбновлено
АдресСостояниеВалидаторПопыткаОбновлено
{{.IPAddress}} {{$b.Label}} {{deref .OwnerValidatorID}}
+ {{if not .Items}}

Очередь пуста.

{{end}} {{end}} diff --git a/internal/db/models.go b/internal/db/models.go index e5eb09b..5486ec8 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -154,3 +154,11 @@ type SubmitIPsResult struct { Reordered []string SkippedInProgress []string } + +// DeleteIPsResult categorizes how each address in a DeleteIPs call (or a +// ClearQueue call, which is DeleteIPs given every currently queued address) +// was handled. +type DeleteIPsResult struct { + Deleted []string + NotFound []string +} diff --git a/internal/db/queries_dynconfig_test.go b/internal/db/queries_dynconfig_test.go index 647bf38..dd6ab0e 100644 --- a/internal/db/queries_dynconfig_test.go +++ b/internal/db/queries_dynconfig_test.go @@ -2,6 +2,7 @@ package db import ( "context" + "database/sql" "errors" "path/filepath" "testing" @@ -330,3 +331,103 @@ func TestCancelIP(t *testing.T) { t.Fatalf("expected cancelled + owner cleared, got %+v", got) } } + +func TestDeleteIP(t *testing.T) { + d, ctx := newTestDB(t) + + if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil { + t.Fatalf("seed queue: %v", err) + } + ip, err := d.GetIPByAddress(ctx, "1.2.3.4") + if err != nil { + t.Fatalf("get ip: %v", err) + } + if err := d.UpsertCheck(ctx, Check{ + IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber, + Source: SourceEgress, CheckType: "https", Target: "https://example.test", + Success: true, CheckedAt: Now(), + }); err != nil { + t.Fatalf("upsert check: %v", err) + } + if err := d.InsertEvent(ctx, Event{SourceType: "control-api", IPID: &ip.ID, EventType: "test_event", OccurredAt: Now()}); err != nil { + t.Fatalf("insert event: %v", err) + } + + if err := d.DeleteIP(ctx, ip.ID); err != nil { + t.Fatalf("delete ip: %v", err) + } + if _, err := d.GetIP(ctx, ip.ID); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("expected row gone, got %v", err) + } + checks, err := d.ListChecksForAttempt(ctx, ip.ID, ip.AttemptNumber) + if err != nil { + t.Fatalf("list checks: %v", err) + } + if len(checks) != 0 { + t.Fatalf("expected checks gone, got %+v", checks) + } + events, err := d.ListEventsForIP(ctx, ip.ID) + if err != nil { + t.Fatalf("list events: %v", err) + } + if len(events) != 0 { + t.Fatalf("expected events gone, got %+v", events) + } + + if err := d.DeleteIP(ctx, ip.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound deleting already-gone ip, got %v", err) + } +} + +func TestDeleteIPFreesOwningValidator(t *testing.T) { + d, ctx := newTestDB(t) + + if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil { + t.Fatalf("seed queue: %v", err) + } + if err := d.AdminCreateValidator(ctx, "validator-1", "port-1"); err != nil { + t.Fatalf("create validator: %v", err) + } + claimed, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute) + if err != nil || claimed == nil { + t.Fatalf("claim: item=%+v err=%v", claimed, err) + } + + if err := d.DeleteIP(ctx, claimed.ID); err != nil { + t.Fatalf("delete ip: %v", err) + } + v, err := d.GetValidator(ctx, "validator-1") + if err != nil { + t.Fatalf("get validator: %v", err) + } + if v.State != ValidatorIdle || v.CurrentIPID != nil { + t.Fatalf("expected validator freed, got state=%s current_ip=%v", v.State, v.CurrentIPID) + } +} + +func TestDeleteIPs(t *testing.T) { + d, ctx := newTestDB(t) + + if err := d.SeedQueue(ctx, []string{"1.1.1.1", "2.2.2.2"}); err != nil { + t.Fatalf("seed queue: %v", err) + } + + result, err := d.DeleteIPs(ctx, []string{"1.1.1.1", "no-such-address", "2.2.2.2"}) + if err != nil { + t.Fatalf("delete ips: %v", err) + } + if len(result.Deleted) != 2 || result.Deleted[0] != "1.1.1.1" || result.Deleted[1] != "2.2.2.2" { + t.Fatalf("expected both real addresses deleted, got %+v", result) + } + if len(result.NotFound) != 1 || result.NotFound[0] != "no-such-address" { + t.Fatalf("expected unknown address in NotFound, got %+v", result) + } + + ips, err := d.ListIPs(ctx) + if err != nil { + t.Fatalf("list ips: %v", err) + } + if len(ips) != 0 { + t.Fatalf("expected empty queue, got %+v", ips) + } +} diff --git a/internal/db/queries_ipqueue.go b/internal/db/queries_ipqueue.go index ed8d5a6..de2062c 100644 --- a/internal/db/queries_ipqueue.go +++ b/internal/db/queries_ipqueue.go @@ -335,6 +335,90 @@ func (d *DB) CancelIP(ctx context.Context, ipID int64) error { return nil } +// DeleteIP permanently removes an ip_queue row, along with its full check +// and event history, in one transaction — frees the owning validator (if +// any) back to idle first, same as ForceCancel does for the DB side. +// Unlike CancelIP, this leaves nothing behind: the row and its history are +// gone, not marked cancelled. Disassociating a currently-attached floating +// IP is the caller's (orchestrator's) job, same division as CancelIP. +// Returns ErrNotFound if the address is unknown. +func (d *DB) DeleteIP(ctx context.Context, ipID int64) error { + tx, err := d.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if err := deleteIPTx(ctx, tx, ipID); err != nil { + return err + } + return tx.Commit() +} + +// DeleteIPs deletes a specific list of addresses in one transaction, +// tolerating unknown addresses the same way SubmitIPs does: each is +// resolved to an id and either deleted (added to Deleted) or, if unknown, +// added to NotFound rather than aborting the whole call. Also the +// implementation behind "clear queue" — call it with every address +// currently in the queue. +func (d *DB) DeleteIPs(ctx context.Context, addresses []string) (DeleteIPsResult, error) { + var result DeleteIPsResult + + tx, err := d.BeginTx(ctx, nil) + if err != nil { + return result, err + } + defer tx.Rollback() + + for _, addr := range addresses { + var ipID int64 + err := tx.QueryRowContext(ctx, `SELECT id FROM ip_queue WHERE ip_address=?`, addr).Scan(&ipID) + if err == sql.ErrNoRows { + result.NotFound = append(result.NotFound, addr) + continue + } + if err != nil { + return result, err + } + if err := deleteIPTx(ctx, tx, ipID); err != nil { + return result, fmt.Errorf("delete %s: %w", addr, err) + } + result.Deleted = append(result.Deleted, addr) + } + + if err := tx.Commit(); err != nil { + return result, err + } + return result, nil +} + +// deleteIPTx is the shared body of DeleteIP/DeleteIPs: free the owning +// validator, delete dependent checks/events, then the ip_queue row itself +// — the same FK-clearing order DeleteValidator uses for owner_validator_id. +func deleteIPTx(ctx context.Context, tx *sql.Tx, ipID int64) error { + now := timeToDB(Now()) + if _, err := tx.ExecContext(ctx, ` + UPDATE validators SET state=?, current_ip_id=NULL, updated_at=? + WHERE current_ip_id=? + `, ValidatorIdle, now, ipID); err != nil { + return fmt.Errorf("free owning validator: %w", err) + } + if _, err := tx.ExecContext(ctx, `DELETE FROM checks WHERE ip_id=?`, ipID); err != nil { + return fmt.Errorf("delete checks: %w", err) + } + if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE ip_id=?`, ipID); err != nil { + return fmt.Errorf("delete events: %w", err) + } + res, err := tx.ExecContext(ctx, `DELETE FROM ip_queue WHERE id=?`, ipID) + if err != nil { + return fmt.Errorf("delete ip_queue row: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("ip_id %d: %w", ipID, ErrNotFound) + } + return nil +} + func (d *DB) SetEgressComplete(ctx context.Context, ipID int64) error { _, err := d.ExecContext(ctx, `UPDATE ip_queue SET egress_complete=1, updated_at=? WHERE id=?`, timeToDB(Now()), ipID) return err diff --git a/internal/httpapi/dto_admin.go b/internal/httpapi/dto_admin.go index 2adbd88..9602eb7 100644 --- a/internal/httpapi/dto_admin.go +++ b/internal/httpapi/dto_admin.go @@ -17,6 +17,19 @@ type submitIPsResponse struct { SkippedInProgress []string `json:"skipped_in_progress"` } +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"` +} + type validatorDTO struct { ValidatorID string `json:"validator_id"` OSPortID string `json:"os_port_id"` diff --git a/internal/httpapi/handlers_admin.go b/internal/httpapi/handlers_admin.go index 167cce0..7fdc952 100644 --- a/internal/httpapi/handlers_admin.go +++ b/internal/httpapi/handlers_admin.go @@ -111,6 +111,54 @@ func (s *Server) handleAdminCancelIP(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, okResponse{OK: true}) } +// handleAdminDeleteIP permanently removes one address and its full history +// — unlike handleAdminCancelIP, there's nothing left to look up afterward. +// Goes through the orchestrator (not a direct DB call) since a currently +// associated floating IP needs disassociating first. +func (s *Server) handleAdminDeleteIP(w http.ResponseWriter, r *http.Request) { + address := r.PathValue("ip") + if err := s.Orch.DeleteIP(r.Context(), address); err != nil { + writeDBError(w, err) + return + } + writeJSON(w, http.StatusOK, okResponse{OK: true}) +} + +// handleAdminDeleteIPs permanently removes a specific list of addresses in +// one call — unknown addresses are reported in not_found rather than +// failing the whole request. +func (s *Server) handleAdminDeleteIPs(w http.ResponseWriter, r *http.Request) { + var req deleteIPsRequest + if err := readJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body: "+err.Error()) + return + } + if len(req.Addresses) == 0 { + writeError(w, http.StatusBadRequest, "addresses must not be empty") + return + } + result, err := s.Orch.DeleteIPs(r.Context(), req.Addresses) + if err != nil { + writeDBError(w, err) + return + } + writeJSON(w, http.StatusOK, deleteIPsResponse{ + Deleted: emptyIfNil(result.Deleted), + NotFound: emptyIfNil(result.NotFound), + }) +} + +// handleAdminClearQueue permanently removes every address currently in the +// queue, including those actively being checked. +func (s *Server) handleAdminClearQueue(w http.ResponseWriter, r *http.Request) { + result, err := s.Orch.ClearQueue(r.Context()) + if err != nil { + writeDBError(w, err) + return + } + writeJSON(w, http.StatusOK, clearQueueResponse{Deleted: emptyIfNil(result.Deleted)}) +} + // emptyIfNil turns a nil slice into an empty one so these fields always // marshal as `[]` rather than `null`. func emptyIfNil(s []string) []string { diff --git a/internal/httpapi/handlers_config_test.go b/internal/httpapi/handlers_config_test.go index ccc83b1..acf7353 100644 --- a/internal/httpapi/handlers_config_test.go +++ b/internal/httpapi/handlers_config_test.go @@ -289,3 +289,124 @@ func TestForceCancelMidCheck(t *testing.T) { t.Fatalf("expected 404 cancelling unknown ip, status=%d body=%s", resp.StatusCode, body) } } + +// TestDeleteIPMidCheck proves DELETE /admin/ips/{ip} disassociates the +// floating IP and permanently removes the address — unlike cancel, a +// subsequent GET on the same address is a 404, not a cancelled record. +func TestDeleteIPMidCheck(t *testing.T) { + fc, d, orch, mock := newConfigTestHarness(t) + ctx := context.Background() + mock.Seed("fip-1", "9.9.9.9", "svc-project") + + fc.do(http.MethodPost, "/api/v1/admin/config/validators", createValidatorRequest{ValidatorID: "validator-1", OSPortID: "port-1"}) + fc.do(http.MethodPut, "/api/v1/admin/config/targets/web", putTargetGroupRequest{Targets: []string{"https://example.test"}}) + fc.do(http.MethodPut, "/api/v1/admin/config/check-types/https", putCheckTypeRequest{Enabled: true, Targets: []string{"web"}}) + fc.do(http.MethodPost, "/api/v1/agents/register", registerAgentRequest{ValidatorID: "validator-1"}) + fc.do(http.MethodPost, "/api/v1/admin/ips", submitIPsRequest{Addresses: []string{"9.9.9.9"}}) + + orch.Tick(ctx) // claim + associate FIP -> awaiting_self_check + + if fip, _ := mock.GetFloatingIPByAddress(ctx, "9.9.9.9"); fip.PortID == "" { + t.Fatalf("expected fip associated before delete") + } + + resp, body := fc.do(http.MethodDelete, "/api/v1/admin/ips/9.9.9.9", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("delete: status=%d body=%s", resp.StatusCode, body) + } + + if fip, _ := mock.GetFloatingIPByAddress(ctx, "9.9.9.9"); fip.PortID != "" { + t.Fatalf("expected fip disassociated after delete, still on port %q", fip.PortID) + } + if _, err := d.GetIPByAddress(ctx, "9.9.9.9"); err == nil { + t.Fatalf("expected ip row gone from db after delete") + } + v, err := d.GetValidator(ctx, "validator-1") + if err != nil { + t.Fatalf("get validator: %v", err) + } + if v.State != db.ValidatorIdle || v.CurrentIPID != nil { + t.Fatalf("expected validator freed, got state=%s current_ip=%v", v.State, v.CurrentIPID) + } + + resp, body = fc.do(http.MethodGet, "/api/v1/admin/ips/9.9.9.9", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for deleted ip, status=%d body=%s", resp.StatusCode, body) + } + + resp, body = fc.do(http.MethodDelete, "/api/v1/admin/ips/9.9.9.9", nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 deleting already-gone ip, status=%d body=%s", resp.StatusCode, body) + } +} + +// TestDeleteIPsAndClearQueue exercises POST /admin/ips/delete against a +// mixed known/unknown list, then POST /admin/ips/clear against whatever +// remains in the queue (including an actively checking address). +func TestDeleteIPsAndClearQueue(t *testing.T) { + fc, d, orch, mock := newConfigTestHarness(t) + ctx := context.Background() + mock.Seed("fip-1", "1.1.1.1", "svc-project") + + fc.do(http.MethodPost, "/api/v1/admin/config/validators", createValidatorRequest{ValidatorID: "validator-1", OSPortID: "port-1"}) + fc.do(http.MethodPut, "/api/v1/admin/config/targets/web", putTargetGroupRequest{Targets: []string{"https://example.test"}}) + fc.do(http.MethodPut, "/api/v1/admin/config/check-types/https", putCheckTypeRequest{Enabled: true, Targets: []string{"web"}}) + fc.do(http.MethodPost, "/api/v1/agents/register", registerAgentRequest{ValidatorID: "validator-1"}) + fc.do(http.MethodPost, "/api/v1/admin/ips", submitIPsRequest{Addresses: []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"}}) + + orch.Tick(ctx) // claims 1.1.1.1 for validator-1, associates its fip + + // POST /admin/ips/delete with an empty list is a 400. + resp, body := fc.do(http.MethodPost, "/api/v1/admin/ips/delete", deleteIPsRequest{}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for empty delete list, status=%d body=%s", resp.StatusCode, body) + } + + // Delete a mix of a queued address and an unknown one. + resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/delete", deleteIPsRequest{Addresses: []string{"2.2.2.2", "no-such-ip"}}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("delete ips: status=%d body=%s", resp.StatusCode, body) + } + var delResp deleteIPsResponse + if err := json.Unmarshal(body, &delResp); err != nil { + t.Fatalf("unmarshal delete response: %v", err) + } + if len(delResp.Deleted) != 1 || delResp.Deleted[0] != "2.2.2.2" { + t.Fatalf("expected 2.2.2.2 deleted, got %+v", delResp) + } + if len(delResp.NotFound) != 1 || delResp.NotFound[0] != "no-such-ip" { + t.Fatalf("expected no-such-ip in not_found, got %+v", delResp) + } + + // Clear whatever's left — 1.1.1.1 (mid-check, fip attached) and 3.3.3.3 + // (still queued). + resp, body = fc.do(http.MethodPost, "/api/v1/admin/ips/clear", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("clear queue: status=%d body=%s", resp.StatusCode, body) + } + var clearResp clearQueueResponse + if err := json.Unmarshal(body, &clearResp); err != nil { + t.Fatalf("unmarshal clear response: %v", err) + } + if len(clearResp.Deleted) != 2 { + t.Fatalf("expected both remaining addresses deleted, got %+v", clearResp) + } + + if fip, _ := mock.GetFloatingIPByAddress(ctx, "1.1.1.1"); fip.PortID != "" { + t.Fatalf("expected fip disassociated on clear, still on port %q", fip.PortID) + } + ips, err := d.ListIPs(ctx) + if err != nil { + t.Fatalf("list ips: %v", err) + } + if len(ips) != 0 { + t.Fatalf("expected empty queue after clear, got %+v", ips) + } + v, err := d.GetValidator(ctx, "validator-1") + if err != nil { + t.Fatalf("get validator: %v", err) + } + if v.State != db.ValidatorIdle || v.CurrentIPID != nil { + t.Fatalf("expected validator freed after clear, got state=%s current_ip=%v", v.State, v.CurrentIPID) + } +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index b5fadd6..419609e 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -22,6 +22,9 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /api/v1/admin/ips", s.handleAdminSubmitIPs) mux.HandleFunc("GET /api/v1/admin/ips/{ip}", s.handleAdminIPDetail) mux.HandleFunc("POST /api/v1/admin/ips/{ip}/cancel", s.handleAdminCancelIP) + mux.HandleFunc("DELETE /api/v1/admin/ips/{ip}", s.handleAdminDeleteIP) + mux.HandleFunc("POST /api/v1/admin/ips/delete", s.handleAdminDeleteIPs) + mux.HandleFunc("POST /api/v1/admin/ips/clear", s.handleAdminClearQueue) mux.HandleFunc("GET /api/v1/admin/validators", s.handleAdminValidators) mux.HandleFunc("GET /api/v1/admin/config/validators", s.handleConfigListValidators) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 62b48ad..4e4b1a8 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -10,6 +10,7 @@ package orchestrator import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "log/slog" @@ -238,6 +239,105 @@ func (o *Orchestrator) ForceCancel(ctx context.Context, ipAddress string) error return nil } +// DeleteIP disassociates the floating IP if attached, then permanently +// removes the address and its full history — differs from ForceCancel, +// which keeps a cancelled record instead of deleting it. Works from any +// state, including actively checking: it does the same resource-freeing +// (FIP disassociation, validator release) ForceCancel does, but goes +// straight to physical deletion rather than parking in `cancelled`. +// Returns db.ErrNotFound if the address is unknown. +func (o *Orchestrator) DeleteIP(ctx context.Context, ipAddress string) error { + item, err := o.DB.GetIPByAddress(ctx, ipAddress) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("ip %q: %w", ipAddress, db.ErrNotFound) + } + return err + } + + if item.FIPID != "" { + if err := o.OS.DisassociateFloatingIP(ctx, item.FIPID); err != nil { + o.Log.Error("disassociate fip on delete", "ip_id", item.ID, "fip_id", item.FIPID, "err", err) + } + } + + if err := o.DB.DeleteIP(ctx, item.ID); err != nil { + return err + } + + o.event(ctx, "control-api", "", nil, "ip_deleted", fmt.Sprintf(`{"ip_address":%q}`, ipAddress)) + return nil +} + +// DeleteIPs disassociates the floating IP (best-effort) for every address +// in the list that has one attached, then deletes the whole list in one +// DB.DeleteIPs call. Addresses not currently in the queue are simply +// omitted from the disassociation pass and reported back in NotFound by +// DB.DeleteIPs — not an error. +func (o *Orchestrator) DeleteIPs(ctx context.Context, addresses []string) (db.DeleteIPsResult, error) { + for _, addr := range addresses { + item, err := o.DB.GetIPByAddress(ctx, addr) + if err != nil { + continue // unknown address — DB.DeleteIPs will report it in NotFound + } + if item.FIPID != "" { + if err := o.OS.DisassociateFloatingIP(ctx, item.FIPID); err != nil { + o.Log.Error("disassociate fip on delete", "ip_id", item.ID, "fip_id", item.FIPID, "err", err) + } + } + } + + result, err := o.DB.DeleteIPs(ctx, addresses) + if err != nil { + return result, err + } + o.event(ctx, "control-api", "", nil, "ips_deleted", deletedAddressesPayload(result.Deleted)) + return result, nil +} + +// ClearQueue deletes every address currently in the queue, regardless of +// state — the "delete everything" operation, implemented as DeleteIPs over +// the full current address list rather than a separate DB code path. +func (o *Orchestrator) ClearQueue(ctx context.Context) (db.DeleteIPsResult, error) { + items, err := o.DB.ListIPs(ctx) + if err != nil { + return db.DeleteIPsResult{}, fmt.Errorf("list ips: %w", err) + } + addresses := make([]string, len(items)) + for i, item := range items { + addresses[i] = item.IPAddress + } + + for _, item := range items { + if item.FIPID != "" { + if err := o.OS.DisassociateFloatingIP(ctx, item.FIPID); err != nil { + o.Log.Error("disassociate fip on clear queue", "ip_id", item.ID, "fip_id", item.FIPID, "err", err) + } + } + } + + result, err := o.DB.DeleteIPs(ctx, addresses) + if err != nil { + return result, err + } + o.event(ctx, "control-api", "", nil, "queue_cleared", deletedAddressesPayload(result.Deleted)) + return result, nil +} + +// deletedAddressesPayload builds the event payload for the batch delete +// operations — a proper JSON array via encoding/json rather than fmt's %q +// slice formatting (which produces space-separated quoted strings, not +// valid JSON). +func deletedAddressesPayload(addresses []string) string { + b, err := json.Marshal(struct { + Addresses []string `json:"addresses"` + }{addresses}) + if err != nil { + return "{}" + } + return string(b) +} + // sweepCheckingWindow moves IPs that have either finished reporting from // every source, or hit the checking-window deadline, into aggregation. func (o *Orchestrator) sweepCheckingWindow(ctx context.Context) error { diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 97063e3..0c6505a 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "errors" "log/slog" "os" "path/filepath" @@ -333,6 +334,81 @@ func TestInboundChecksDisabled(t *testing.T) { } } +// TestDeleteIPDisassociatesFIP proves DeleteIP disassociates a currently +// attached floating IP (via the mock) before permanently removing the +// address — the same resource-freeing ForceCancel does, but going straight +// to physical deletion instead of a `cancelled` record. +func TestDeleteIPDisassociatesFIP(t *testing.T) { + ctx := context.Background() + o, d, mock := newTestOrchestrator(t, 180) + mock.Seed("fip-1", "1.2.3.4", "svc-project") + _ = d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1") + _ = d.SeedQueue(ctx, []string{"1.2.3.4"}) + + o.Tick(ctx) // claim + associate -> awaiting_self_check, fip attached + + ip, err := d.GetIPByAddress(ctx, "1.2.3.4") + if err != nil { + t.Fatalf("get ip: %v", err) + } + if ip.FIPID == "" { + t.Fatalf("expected fip associated before delete") + } + + if err := o.DeleteIP(ctx, "1.2.3.4"); err != nil { + t.Fatalf("delete ip: %v", err) + } + + if fip, _ := mock.GetFloatingIPByAddress(ctx, "1.2.3.4"); fip.PortID != "" { + t.Fatalf("expected fip disassociated on delete, still on port %q", fip.PortID) + } + if _, err := d.GetIPByAddress(ctx, "1.2.3.4"); err == nil { + t.Fatalf("expected ip row gone after delete") + } + v, err := d.GetValidator(ctx, "validator-1") + if err != nil { + t.Fatalf("get validator: %v", err) + } + if v.State != db.ValidatorIdle || v.CurrentIPID != nil { + t.Fatalf("expected validator freed, got state=%s current_ip=%v", v.State, v.CurrentIPID) + } + + if err := o.DeleteIP(ctx, "1.2.3.4"); !errors.Is(err, db.ErrNotFound) { + t.Fatalf("expected ErrNotFound deleting already-gone ip, got %v", err) + } +} + +// TestClearQueueDisassociatesAllFIPs proves ClearQueue deletes every +// address regardless of state and disassociates any attached floating IPs +// along the way. +func TestClearQueueDisassociatesAllFIPs(t *testing.T) { + ctx := context.Background() + o, d, mock := newTestOrchestrator(t, 180) + mock.Seed("fip-1", "1.2.3.4", "svc-project") + _ = d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1") + _ = d.SeedQueue(ctx, []string{"1.2.3.4", "5.6.7.8"}) + + o.Tick(ctx) // claims + associates 1.2.3.4; 5.6.7.8 stays queued + + result, err := o.ClearQueue(ctx) + if err != nil { + t.Fatalf("clear queue: %v", err) + } + if len(result.Deleted) != 2 { + t.Fatalf("expected both addresses deleted, got %+v", result) + } + if fip, _ := mock.GetFloatingIPByAddress(ctx, "1.2.3.4"); fip.PortID != "" { + t.Fatalf("expected fip disassociated on clear, still on port %q", fip.PortID) + } + ips, err := d.ListIPs(ctx) + if err != nil { + t.Fatalf("list ips: %v", err) + } + if len(ips) != 0 { + t.Fatalf("expected empty queue after clear, got %+v", ips) + } +} + // TestInboundChecksPartialSites confirms a partially-configured sites list // (fewer than 3 slots assigned) only waits on the sites actually // configured — the two unassigned slots are never expected.