feature: handle delete operation for IPs
This commit is contained in:
1 parent
4f597505cc
commit
f8336740ad
20 files changed
+1083
-5
No files matched your search
+36
-3
@@ -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
@@ -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`. Ключевые поля:
|
||||
|
||||
@@ -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-проверка
|
||||
рендера форм/чекбоксов, как в предыдущих итерациях).
|
||||
@@ -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`.**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user