feature: handle delete operation for IPs

This commit is contained in:
ayurishchev committed 2026-08-23 22:24:55 +03:00
1 parent 4f597505cc
commit f8336740ad
20 files changed
+1083 -5

No files matched your search

+36 -3
View File
@@ -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)
+15 -1
View File
@@ -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`. Ключевые поля:
+252
View File
@@ -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`
- Каждая строка таблицы получает чекбокс
`<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-проверка
рендера форм/чекбоксов, как в предыдущих итерациях).
+41
View File
@@ -20,6 +20,7 @@
- [Управление целями проверки](#управление-целями-проверки)
- [Повторная проверка адреса](#повторная-проверка-адреса)
- [Принудительная остановка проверки](#принудительная-остановка-проверки)
- [Удаление адресов из очереди](#удаление-адресов-из-очереди)
- [Частые проблемы и что с ними делать](#частые-проблемы-и-что-с-ними-делать)
## Как устроена работа с системой
@@ -372,6 +373,46 @@ curl -s http://<control-api>: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://<control-api>:8080/api/v1/admin/ips/203.0.113.10
```
Удалить список адресов одним вызовом (неизвестные адреса просто
попадают в `not_found`, не считаются ошибкой):
```bash
curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/delete \
-d '{"addresses": ["203.0.113.10", "203.0.113.11"]}'
```
Полностью очистить очередь — **самая опасная операция**, удаляет вообще
всё, включая адреса, которые прямо сейчас проверяются:
```bash
curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/clear
```
В `admin-dashboard` то же самое доступно на странице `/ips`: чекбоксы у
каждой строки + кнопка «Удалить выбранные» для точечного/массового
удаления, кнопка «Удалить» в каждой строке, и отдельная кнопка «Очистить
всё» — каждая с подтверждением, явно предупреждающим о необратимости
(см. [DASHBOARD.md](DASHBOARD.md)).
## Частые проблемы и что с ними делать
**Валидатор долго висит в `unreachable`.**
+21
View File
@@ -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)
+48
View File
@@ -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()
+9
View File
@@ -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"`
+25
View File
@@ -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)
}
+70
View File
@@ -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)
+3
View File
@@ -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)
+9 -1
View File
@@ -43,15 +43,21 @@
{{end}}
{{define "ips_table"}}
<form id="ips-form">
<div class="panel">
<div class="panel-body" style="display:flex; gap:8px; align-items:center;">
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить выбранные адреса без возможности восстановления?">Удалить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/clear" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить ВСЕ адреса из очереди, включая те, что сейчас проверяются? Действие необратимо.">Очистить всё</button>
</div>
<div class="table-scroll">
<table>
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th></th></tr></thead>
<thead><tr><th><input type="checkbox" onclick="this.closest('table').querySelectorAll('input[name=addresses]').forEach(cb => cb.checked = this.checked)"></th><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th></th></tr></thead>
<tbody>
{{range .Items}}
{{$b := ipBadge .State .OverallResult}}
{{$terminal := or (eq .State "done") (eq .State "failed")}}
<tr>
<td data-label=""><input type="checkbox" name="addresses" value="{{.IPAddress}}"></td>
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
<td data-label="Валидатор">{{deref .OwnerValidatorID}}</td>
@@ -64,6 +70,7 @@
{{else}}
<button class="btn btn-danger-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/cancel" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Остановить проверку {{.IPAddress}}?">Отменить</button>
{{end}}
<button class="btn btn-danger-ghost btn-sm" hx-delete="/ips/{{.IPAddress}}" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить {{.IPAddress}} без возможности восстановления?">Удалить</button>
</div>
</td>
</tr>
@@ -72,5 +79,6 @@
</table>
</div>
</div>
</form>
{{if not .Items}}<p class="muted">Очередь пуста.</p>{{end}}
{{end}}
+8
View File
@@ -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
}
+101
View File
@@ -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)
}
}
+84
View File
@@ -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
+13
View File
@@ -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"`
+48
View File
@@ -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 {
+121
View File
@@ -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)
}
}
+3
View File
@@ -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)
+100
View File
@@ -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 {
@@ -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.