Scan floating IPs in the background, page by page, so thousands of addresses work

The "Scan Floating IP" button failed with a client timeout: the project now
holds ~6.4k floating IPs and the scan listed them all in one unpaginated,
timeout-less Neutron request on the HTTP request context.

openstack: ListFreeFloatingIPs reads marker-based pages (fields= keeps them
small) with per-page retry/backoff on transport errors, 5xx and 429, and every
request now has a timeout (also ends hangs inside the orchestrator tick).

orchestrator: the scan is a single-flight background job on the process
context with progress (clearing/listing/enqueuing/done/error), dry_run, full
discovery before anything is enqueued, then SubmitIPs in chunks of 500 in
ascending IP order; a failed read leaves the queue untouched. The auto-cycle
gets a "scanning" phase that polls the job, so the control loop and
autoCycleMu are never held across OpenStack/DB work; it recovers after a
restart and waits for (instead of adopting) a scan started by someone else.

db: migration 0009 (indexes), paged ListIPsPage/ListRegistryPage, GROUP BY
counters, EXISTS completion check, set-based ClearAllIPs.

API: POST /admin/ips/scan -> 202 (dry_run, wait), GET /admin/ips/scan, paging
and filters on /admin/ips and /admin/registry (bare arrays without limit),
results_by_overall in /admin/status.

dashboard: scan progress panel and dry-run button, paginated /ips and
/registry with server-side filters, Overview on counters and capped lists
with progress/ETA, "select all N by filter", hx-params fix for per-row
buttons, real counts in confirmations.

Also: docs (API, USAGE, DASHBOARD, README), plan and review under
docs/changes/, bin/ rebuilt with new SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-01 19:31:11 +03:00
1 parent debf2afed2
commit aff8fe38b5
61 files changed
+5833 -536

No files matched your search

+66 -22
View File
@@ -330,15 +330,37 @@ IP на данном проходе". До этого момента control-api
{
"total_ips": 25,
"ips_by_state": {"queued": 10, "checking": 3, "done": 11, "failed": 1},
"results_by_overall": {"pass": 8, "partial": 3, "fail": 0, "cancelled": 0},
"total_validators": 4
}
```
`results_by_overall` — сколько адресов с каким итогом (всегда все четыре ключа). Счётчики считаются
запросами `GROUP BY` на стороне БД, а не загрузкой всей очереди, поэтому метод быстрый и при тысячах адресов.
### `GET /api/v1/admin/ips`
Полный список всех IP из очереди со всеми полями (см.
Список IP из очереди со всеми полями (см.
[USAGE.md](USAGE.md#значения-полей-ip) — расшифровка полей и статусов).
**Без параметров** — как раньше: весь список одним массивом (при тысячах адресов это мегабайты — для больших очередей
используйте постраничный режим). **С `limit`** — постраничный режим: ответ — конверт
```json
{"items": [ ... ], "total": 6440, "limit": 50, "offset": 0}
```
| Параметр | Значение |
|---|---|
| `limit` | размер страницы, `1`…`1000` (иначе `400`); включает постраничный режим |
| `offset` | смещение, `>= 0` (без `limit` — `400`) |
| `state` | одно или несколько состояний через запятую (`queued`, `assigning_fip`, `awaiting_self_check`, `checking`, `aggregating`, `done`, `failed`, `occupied`) |
| `q` | подстрока адреса |
| `result` | итог: `pass`, `partial`, `fail`, `cancelled` |
| `order` | `sequence` (по умолчанию, порядок очереди) или `aggregated_at_desc` (последние завершённые) |
`total` — число записей после фильтров. Параметры фильтров без `limit` возвращают отфильтрованный массив.
### `GET /api/v1/admin/ips/{ip}`
Детали по одному адресу: сам объект IP, все проверки текущей попытки и
@@ -470,33 +492,52 @@ YAML для этой секции больше не перечитывается
### `POST /api/v1/admin/ips/scan`
Сканирует текущий проект OpenStack на предмет свободных (не привязанных ни
к одному порту) Floating IP и сразу передаёт найденный список в `POST
/api/v1/admin/ips` — тот же add/requeue/reorder-вызов, как если бы
оператор ввёл эти адреса вручную. Не принимает тело запроса.
Запускает **фоновое** сканирование проекта OpenStack: находит все свободные (не привязанные ни к одному порту) Floating IP
и ставит их в очередь — тот же add/requeue/reorder, что и `POST /api/v1/admin/ips`. Не принимает тело запроса и **сразу отвечает**
`202` со статусом задания; ход сканирования смотрите через `GET /api/v1/admin/ips/scan`.
Почему в фоне: в проекте может быть тысячи Floating IP (на стенде — около 6,4 тыс.), Neutron отдаёт такой список минуты. Control-api читает
его **страницами** (по `openstack.list_page_size`, по умолчанию 200, по `marker`), повторяет страницу при обрыве соединения/5xx/429,
сначала обнаруживает **все** адреса и только потом ставит их в очередь кусками по 500 в порядке возрастания IP. Если чтение не удалось
(после повторов), в очередь не попадает ничего — очередь остаётся как была, а статус задания — `error`.
| Параметр | Значение |
|---|---|
| `dry_run=true` | только найти и посчитать свободные адреса; очередь не меняется (безопасная проверка, итог — в статусе) |
| `wait=true` | дождаться окончания и ответить `200` прежним телом `{scanned_free, added[], requeued[], reordered[], skipped_in_progress[]}` (для curl и скриптов; при ошибке `502`) |
Одновременно идёт одно сканирование: повторный запрос во время работы **присоединяется** к текущему и тоже отвечает `202` с его статусом.
### `GET /api/v1/admin/ips/scan`
Статус и прогресс сканирования (admin-токен).
Ответ (`200`):
```json
{
"scanned_free": 3,
"added": ["203.0.113.20"],
"requeued": [],
"reordered": ["203.0.113.10", "203.0.113.11"],
"skipped_in_progress": []
"state": "listing",
"running": true,
"dry_run": false,
"pages": 12,
"discovered": 2400,
"free": 2399,
"added": 0,
"requeued": 0,
"reordered": 0,
"skipped_in_progress": 0,
"started_at": "2026-10-01T15:47:40.759Z",
"finished_at": null,
"error": ""
}
```
`scanned_free` — сколько свободных Floating IP нашлось в проекте всего
(включая уже стоящие в очереди — они попадут в `reordered`, а не
`added`). Если свободных адресов нет вообще, это не ошибка: ответ будет
`{"scanned_free": 0, "added": [], ...}`.
`state`: `idle` (в этом процессе сканирования ещё не было), `clearing` (очистка очереди — только в автоцикле), `listing` (чтение страниц),
`enqueuing` (постановка в очередь), `done`, `error` (причина в `error`), `cancelled`. `discovered` — сколько Floating IP прочитано
(свободных и занятых), `free` — из них свободных, `added`/`requeued`/`reordered`/`skipped_in_progress` — итог постановки в очередь
(как в `POST /admin/ips`). Статус хранится в памяти процесса: после перезапуска control-api он снова `idle`.
Помимо ручного вызова, сканирование можно включить по расписанию —
`orchestrator.fip_scan_interval_seconds` в `control-api.yaml` (0, по
умолчанию, — только по запросу через эту ручку или кнопку «Сканировать
Floating IP» в дашборде). Пока включён
[автоматический цикл](#автоматический-цикл-проверок), периодический скан
не выполняется.
Помимо ручного вызова, сканирование можно включить по расписанию — `orchestrator.fip_scan_interval_seconds` в `control-api.yaml`
(0, по умолчанию, — только по запросу через эту ручку или кнопку «Сканировать Floating IP» в дашборде). Пока включён
[автоматический цикл](#автоматический-цикл-проверок), периодический скан не выполняется.
## Автоматический цикл проверок
@@ -575,7 +616,10 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/stop
### `GET /api/v1/admin/registry`
Список всех адресов реестра с краткой сводкой по каждому.
Список адресов реестра с краткой сводкой по каждому. Без параметров — все адреса одним массивом; **с `limit`** (`1`…`1000`) —
постраничный конверт `{"items": [...], "total": N, "limit": L, "offset": O}`, параметры `offset`, `q` (подстрока адреса) и
`last_result` (`pass`/`partial`/`fail`/`cancelled`). Страница и фильтры применяются в SQL до расчёта сводки, поэтому
реестр из тысяч адресов отдаётся за доли секунды.
```json
[
+40 -27
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` секунд без перезагрузки страницы. Поиск по IP и фильтр по статусу (`pass`/`partial`/`fail`/`cancelled`) над обеими таблицами — набранное/выбранное не сбрасывается очередным обновлением. Пока включён [автоматический цикл](USAGE.md#автоматический-цикл-проверок), под счётчиками показывается индикатор «Автоцикл активен» с текущей фазой и временем следующего запуска; управляется цикл на `/settings`. |
| `/ips` | Полная очередь. Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). Кнопка «Сканировать Floating IP» делает то же самое автоматически: находит в проекте OpenStack все свободные (не привязанные к порту) Floating IP и сразу ставит их в очередь (`POST /api/v1/admin/ips/scan`, см. [API.md](API.md#post-apiv1adminipsscan)) — то же сканирование можно включить по расписанию через `orchestrator.fip_scan_interval_seconds`. У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний), и всегда — «Удалить» (безвозвратно убирает адрес из очереди, но не из реестра — см. ниже). Чекбоксы у строк + кнопка «Удалить выбранные» удаляют список одним вызовом; «Очистить всё» удаляет вообще всё, включая активные проверки — обе операции требуют явного подтверждения. Пока не истекла настроенная на `/settings` пауза (`fip_settle_seconds`), только что привязавший Floating IP адрес показывает отдельный бейдж «прогрев FIP» вместо обычного статуса. Если на момент попытки привязки Floating IP оказался уже занят другим портом (дрейф состояния облака или ошибочно переданный адрес), цикл проверки для него не запускается — адрес показывает отдельный бейдж «занят» (отличный от «fail») и строку `fip_occupied` в списке событий на его странице; кнопка «Перепроверить» ставит его в очередь заново. |
| `/ips` | Очередь **постранично** (по 50 адресов; 25/50/100/200) с поиском по IP и фильтром по состоянию/итогу на сервере; кнопка «Сканировать Floating IP» запускает фоновое сканирование с панелью прогресса, «Пробное сканирование» ничего не ставит в очередь (подробности — «Очередь из тысяч адресов» ниже). Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). Кнопка «Сканировать Floating IP» делает то же самое автоматически: находит в проекте OpenStack все свободные (не привязанные к порту) Floating IP и сразу ставит их в очередь (`POST /api/v1/admin/ips/scan`, см. [API.md](API.md#post-apiv1adminipsscan)) — то же сканирование можно включить по расписанию через `orchestrator.fip_scan_interval_seconds`. У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний), и всегда — «Удалить» (безвозвратно убирает адрес из очереди, но не из реестра — см. ниже). Чекбоксы у строк + кнопка «Удалить выбранные» удаляют список одним вызовом; «Очистить всё» удаляет вообще всё, включая активные проверки — обе операции требуют явного подтверждения. Пока не истекла настроенная на `/settings` пауза (`fip_settle_seconds`), только что привязавший Floating IP адрес показывает отдельный бейдж «прогрев FIP» вместо обычного статуса. Если на момент попытки привязки Floating IP оказался уже занят другим портом (дрейф состояния облака или ошибочно переданный адрес), цикл проверки для него не запускается — адрес показывает отдельный бейдж «занят» (отличный от «fail») и строку `fip_occupied` в списке событий на его странице; кнопка «Перепроверить» ставит его в очередь заново. |
| `/ips/{ip}` | Детали одного адреса, пока он в очереди: все проверки текущей попытки и вся история событий, плюс ссылка на полную историю в реестре (см. ниже). |
| `/registry` | **Реестр** — все адреса, когда-либо поставленные на проверку, независимо от того, стоят ли они сейчас в очереди. Переживает удаление адреса из `/ips` и повторное добавление того же адреса позже (см. «Реестр адресов» ниже). Поиск по IP и фильтр по статусу — то же самое, что на `/overview`, плюс отражается в адресной строке (`?q=&status=`), так что отфильтрованную ссылку можно сохранить/переслать. |
| `/registry/{ip}` | Полная сохранённая история проверок одного адреса по всем циклам (не только текущему) — в отличие от `/ips/{ip}`, которая показывает только текущую попытку. |
@@ -59,41 +59,37 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
| `/check-types` | Типы проверок (`https`/`icmp`/`ssh`/...), включение/выключение, привязка к группам целей. |
| `/settings` | Четыре блока. Первый — панель **«Автоматический цикл»**: статус и фаза, время последнего/следующего запуска, результат последнего цикла, поля «Интервал между циклами (мин)» и «Максимальная длительность проверки (мин, 0 = без лимита)» с кнопкой «Сохранить» и кнопка «Включить»/«Выключить» (показывается та, что сейчас применима). Значения вводятся в минутах (допустимы дробные), в control-api уходят секундами; минимум интервала — 1 минута (`60` с), нарушение приходит предупреждением в баннере. Подробности — [USAGE.md](USAGE.md#автоматический-цикл-проверок), API — [API.md](API.md#автоматический-цикл-проверок). Далее три формы: `fip_settle_seconds` — пауза (в секундах) между привязкой Floating IP и началом self-check («прогрев» дата-плейна OpenStack, см. [USAGE.md](USAGE.md#пауза-перед-self-check-fip_settle_seconds)); `history_retention_cycles` — сколько последних циклов проверки хранить на адрес в реестре (0 — без ограничения); и типы проверок пробера — TCP-порты (через запятую) + чекбокс ICMP, общие для всех площадок (см. [USAGE.md](USAGE.md#управление-типами-проверок-пробера)). |
### «Текущая» и «последняя завершённая» проверка
### «В работе», «в очереди» и «последняя завершённая» проверка
В `control-api` нет понятия «запуска»/«цикла проверки» как отдельной
сущности — есть только общая очередь IP-адресов
(`docs/PLAN_ADMIN_DASHBOARD.md`). Дашборд ничего не меняет в этом
устройстве и не заводит своего состояния:
устройстве и не заводит своего состояния. Очередь может содержать тысячи
адресов, поэтому `/overview` **никогда не загружает её целиком** — на каждое
обновление запрашиваются счётчики и несколько ограниченных списков:
- **Текущая проверка** — все адреса, которые прямо сейчас не в
состоянии `done`/`failed` (`queued`, `assigning_fip`,
`awaiting_self_check`, `checking`, `aggregating`), вычисляется заново на
каждый запрос из `GET /api/v1/admin/status` + `GET /api/v1/admin/ips`.
- **Последняя завершённая проверка** — последние N адресов, перешедших в
`done`/`failed`, отсортированные по `AggregatedAt` по убыванию (не
«последний запуск», а именно скользящее окно последних по времени
завершений).
- **Счётчики** — `GET /api/v1/admin/status` (по состояниям и `results_by_overall`).
- **В работе** — адреса в `assigning_fip`, `awaiting_self_check`, `checking`, `aggregating`
(не более 100; естественный предел — число валидаторов).
- **В очереди: Q** — счётчик `queued` со ссылкой на `/ips?state=queued` и несколько ближайших адресов.
- **Последние N завершённых** — последние N адресов в `done`/`failed` по `AggregatedAt` по убыванию
(не «последний запуск», а скользящее окно последних по времени завершений).
- **Прогресс** в блоке статистики: «Готово D из T (P%) · в работе A · в очереди Q» с полосой и оценкой
оставшегося времени (по скорости последних завершений, когда их не меньше пяти). `occupied` считается
завершённым состоянием.
### Поиск по IP и фильтр по статусу
На `/overview` и `/registry` есть форма из двух полей — поиск по IP
(подстрока, без учёта регистра) и выпадающий список статуса
(`pass`/`partial`/`fail`/`cancelled`). Оба поля работают вместе (И, а не
ИЛИ) и применяются целиком на стороне дашборда — `client.ListIPs`/
`client.ListRegistry` всегда получают от `control-api` полный список,
`internal/httpapi`/`internal/db` про фильтр вообще не знают.
На `/overview`, `/ips` и `/registry` есть поиск по IP (подстрока) и фильтр по статусу/итогу (`pass`/`partial`/`fail`/`cancelled`;
на `/ips` — ещё по состоянию очереди). Поля работают вместе (И, а не ИЛИ). Фильтрация выполняется **на стороне `control-api`**
(параметры `q`, `state`, `result`/`last_result` у `GET /admin/ips` и `GET /admin/registry`), а дашборд получает только нужную страницу, поэтому
фильтр работает быстро при любом размере очереди.
- **`/overview`** — фильтр действует на обе таблицы сразу («Текущая
проверка» и «Последние N завершённых»). Статус — это фильтр по
итоговому результату (`OverallResult`), поэтому выбор конкретного
статуса скрывает «Текущую проверку» целиком: у ещё идущих проверок
результата попросту нет. Панель статистики (счётчики сверху) фильтру не
подчиняется — это агрегаты по всей очереди, а не по видимым строкам.
- **`/registry`** — тот же принцип, но по одной таблице (`LastResult`), и
значения полей отражаются в адресной строке (`?q=&status=`) через
`hx-replace-url` — отфильтрованную ссылку можно сохранить или переслать,
а обновление страницы (F5) сохраняет применённый фильтр.
- **`/overview`** — `q` и статус передаются в списки «В работе» и «Последние N завершённых». Статус — это фильтр по
итоговому результату (`OverallResult`), поэтому выбор конкретного статуса скрывает «В работе» и «В очереди»: у ещё идущих проверок
результата попросту нет. Панель статистики (счётчики сверху) фильтру не подчиняется — это агрегаты по всей очереди, а не по видимым строкам.
- **`/registry` и `/ips`** — таблица постраничная; значения полей и страница отражаются в адресной строке (`?q=&status=&page=`) через
`hx-replace-url` — отфильтрованную ссылку можно сохранить или переслать, а обновление страницы (F5) сохраняет применённый фильтр.
**Раскладка `/overview` сверху вниз**: панель статистики → форма
фильтра → таблицы. Панель статистики и форма фильтра физически лежат
@@ -157,6 +153,23 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
циклов) остаётся всегда. Подробнее —
[API.md](API.md#реестр-адресов-и-история-проверок).
## Очередь из тысяч адресов
После сканирования проекта в очереди может оказаться несколько тысяч адресов, поэтому тяжёлые страницы работают постранично:
- **`/ips` и `/registry`** — параметры `page` и `per_page` (по умолчанию 50; допустимо 25/50/100/200), «Показано a–b из N» и кнопки ‹ ›.
Поиск (`q`), состояние/итог и размер страницы применяются **на стороне control-api** (`GET /admin/ips?limit=…`, `GET /admin/registry?limit=…`),
поэтому страница весит десятки килобайт независимо от длины очереди. Фильтры и страница отражены в адресной строке.
- **Массовые операции.** Чекбоксы выбирают строки текущей страницы (счётчик «Выбрано на странице: k из P»). Если отмечен заголовок таблицы и записей
больше страницы, появляется ссылка «Выбрать все N по фильтру»: тогда «Перепроверить»/«Удалить» применяются ко **всем** адресам по текущему фильтру
(адреса разрешаются на сервере и отправляются кусками по 500). Подтверждения показывают реальное число: «Удалить ВСЕ 6440 адресов…».
«Очистить всё» очищает очередь целиком одной быстрой операцией.
- **Сканирование.** Кнопка «Сканировать Floating IP» мгновенно возвращает панель прогресса под кнопкой; пока задание идёт, панель сама
обновляется каждые 2 секунды, по окончании опрос прекращается и таблица перезагружается. Во время сканирования кнопки заблокированы; повторное
нажатие присоединяется к идущему заданию. Ошибка (например, OpenStack недоступен) показывается в панели с причиной.
Саму таблицу `/ips` по таймеру по-прежнему не обновляем — она не сбрасывает ввод оператора.
- Долгие операции (`Очистить всё`, массовое удаление/перепроверка) выполняются с увеличенным таймаутом (120 с), остальные запросы к control-api — с `control_api.timeout_seconds`.
## Вход и сессия
Если заданы `ADMIN_DASHBOARD_USERNAME` и `ADMIN_DASHBOARD_PASSWORD`, все страницы, кроме `/login` и `/static/*`, требуют входа.
+1 -1
View File
@@ -66,7 +66,7 @@ It will:
outcome is `completed`, the phase is `waiting` with `runs_total=1`, and
that the registry's `total_cycles` for `127.0.0.1` grew (the cycle
cleared the queue, re-scanned the mock floating IP and re-checked it).
Finally it `stop`s the cycle and asserts it is `idle`. The second cycle
The cycle now passes through the background scan (`scanning` phase) before the checks run. Finally it `stop`s the cycle and asserts it is `idle`. The second cycle
(the interval wait) is covered by unit tests, so the script does not
sit through the 60s pause. The script exits non-zero if any assertion
fails.
+38 -7
View File
@@ -90,7 +90,8 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/ips \
самому найти их в облаке:
```bash
curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/scan
curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/scan # 202: сканирование запущено в фоне
curl -s http://<control-api>:8080/api/v1/admin/ips/scan # ход и результат
```
Сканируются все Floating IP текущего проекта OpenStack, но в очередь
@@ -101,8 +102,23 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/scan
встают в очередь, уже завершённые перезапускаются, активно проверяемые не
трогаются (см. [выше](#добавление-новых-ip-в-очередь)).
В `admin-dashboard` то же самое — кнопка «Сканировать Floating IP» на
странице `/ips`.
**Сколько адресов — не важно.** Сканирование работает в фоне и читает список из OpenStack
**страницами** (по 200 адресов, с повторами при обрывах), поэтому подходит и для проекта с
тысячами Floating IP: на стенде с 6441 адресом чтение занимает около 1,5–2 минут. Сначала
обнаруживаются **все** адреса, и только потом они ставятся в очередь (кусками по 500, в порядке
возрастания IP); сразу после этого начинаются проверки. Если чтение сорвалось даже после повторов,
в очередь не попадает ничего — очередь остаётся как была, а на панели виден `error` и причина.
Одновременно идёт одно сканирование: повторное нажатие присоединяется к текущему.
В `admin-dashboard` — кнопка «Сканировать Floating IP» на странице `/ips`: она сразу отвечает, а под
кнопкой появляется панель прогресса (читаются страницы → ставятся в очередь → готово: прочитано
страниц, найдено, свободных, добавлено, время), по окончании таблица обновляется сама. Рядом —
«Пробное сканирование»: оно проходит все страницы и показывает, сколько свободных адресов нашлось,
**не меняя очередь** (удобно проверить, что облако отвечает и сколько адресов будет поставлено).
Параметры чтения (`control-api.yaml`): `openstack.list_page_size` (200), `openstack.request_timeout_seconds`
(60 — таймаут одного запроса к OpenStack), `openstack.list_page_retries` (5), `orchestrator.fip_scan_timeout_seconds`
(1800 — предел всего сканирования).
Если хочется, чтобы сканирование происходило само по расписанию, а не
только по запросу — задайте `orchestrator.fip_scan_interval_seconds`
@@ -111,6 +127,11 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/scan
[автоматический цикл](#автоматический-цикл-проверок), это периодическое
сканирование не выполняется — цикл сам управляет очередью.
> **Сколько займут проверки.** Один адрес занимает около 50 секунд на валидаторе (из них 30 с — пауза
> `fip_settle_seconds`). Поэтому очередь из 6440 адресов — примерно 18 часов на 5 валидаторах,
> 9 часов на 10, 4,5 часа на 20. Ускорить можно числом валидаторов и (осторожно) `fip_settle_seconds`;
> на странице «Обзор» виден прогресс «Готово D из T» и оценка оставшегося времени.
## Автоматический цикл проверок
Опциональный режим, который сам повторяет то, что оператор делает руками:
@@ -122,7 +143,9 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/scan
в [реестре](#реестр-адресов-и-глубина-истории) при этом сохраняется.
2. Control-api находит все свободные Floating IP и ставит их в очередь —
то же, что «Сканировать Floating IP» (см.
[выше](#сканирование-floating-ip-из-openstack)).
[выше](#сканирование-floating-ip-из-openstack)). Шаги 1–2 выполняются одним
фоновым заданием, поэтому долгое чтение тысяч адресов не блокирует работу
оркестратора (назначение валидаторов, лизинги, heartbeat).
3. Проверки запускаются сами — как для любого адреса в очереди.
4. Цикл ждёт, пока **все** адреса очереди дойдут до конечного состояния
(`done`, `failed` или `occupied`). К этому моменту результат каждого
@@ -136,7 +159,7 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/scan
| Параметр | По умолчанию | Смысл |
|---|---|---|
| `interval_seconds` | `3600` (1 час) | Пауза между циклами. Не меньше `60`: слишком частые сканы нагружают API OpenStack. |
| `max_run_seconds` | `0` (без лимита) | Сколько максимум ждать на шаге 4. По истечении цикл фиксирует `timeout` и переходит к паузе — защита от зависания (нет свободных валидаторов, недоступна площадка). Очередь при этом не трогается: следующий цикл её очистит, а до тех пор видно, что именно не дошло до конца. |
| `max_run_seconds` | `0` (без лимита) | Сколько максимум ждать на шаге 4 (отсчёт — **от конца сканирования**). По истечении цикл фиксирует `timeout` и переходит к паузе — защита от зависания (нет свободных валидаторов, недоступна площадка). Очередь при этом не трогается: следующий цикл её очистит, а до тех пор видно, что именно не дошло до конца. **При тысячах адресов оставьте `0`** (или задайте больше расчётного времени: 6440 адресов — часы). |
Параметры хранятся в базе и меняются на лету, без перезапуска; в `control-api.yaml`
ничего задавать не нужно. Новый `interval_seconds` применяется к паузе
@@ -170,7 +193,8 @@ curl -s http://<control-api>:8080/api/v1/admin/auto-cycle
### Фазы и результат последнего цикла
`phase` показывает, что происходит сейчас: `idle` (автоцикл выключен или ещё не
стартовал), `running` (идут проверки — шаги 3–4) и `waiting` (пауза между циклами,
стартовал), `scanning` (шаги 1–2: очистка очереди и чтение/постановка Floating IP),
`running` (идут проверки — шаги 3–4) и `waiting` (пауза между циклами,
шаг 5; время следующего запуска — `next_run_at`). Результат последнего цикла
(`last_outcome`):
@@ -185,7 +209,8 @@ curl -s http://<control-api>:8080/api/v1/admin/auto-cycle
### Что важно знать
- **Выключение не прерывает проверки**, которые уже идут: они закончатся и попадут
в реестр, остановится только повторение.
в реестр, остановится только повторение. Если выключить цикл во время фазы `scanning`,
сканирование отменяется (уже поставленные в очередь куски остаются).
- Автоцикл **владеет очередью**: каждый цикл начинается с её полной очистки,
поэтому адреса, добавленные вручную, будут удалены (их история в реестре
остаётся). Ручные «Очистить всё» и «Сканировать Floating IP» во время цикла
@@ -195,6 +220,12 @@ curl -s http://<control-api>:8080/api/v1/admin/auto-cycle
- События цикла (`auto_cycle_started`, `auto_cycle_completed`, `auto_cycle_timeout`,
`auto_cycle_error`, `auto_cycle_stopped`) пишутся в журнал событий вместе с
`queue_cleared` и `fip_scan`.
- Если в момент старта цикла уже идёт чужое сканирование (ручное, пробное или по расписанию),
цикл **дожидается** его окончания и запускает собственное (с очисткой очереди) — присоединяться
к чужому нельзя: оно могло ничего не поставить в очередь.
- Если сканирование завершилось ошибкой, очередь уже очищена (шаг 1) и остаётся пустой до следующего
цикла (`interval_seconds`); исход цикла — `error` с причиной в `last_error`.
- Цикл на тысячах адресов длится часы; пауза `interval_seconds` отсчитывается после его завершения.
- В реальном OpenStack отвязка Floating IP после очистки очереди может
отразиться с задержкой; если скан сразу после неё не увидел свободных адресов,
цикл завершится с `no_free_ips` и повторится через `interval_seconds`.
@@ -0,0 +1,166 @@
# План: сканирование Floating IP и автоцикл при тысячах адресов
> Дата: 2026-10-01 18:19 MSK · Статус: **реализовано** — результаты ревью и тестов: [2026-10-01_18-59_fip-scan-at-scale-review.md](2026-10-01_18-59_fip-scan-at-scale-review.md)
## Context
Нажатие «Сканировать Floating IP» на живом стенде падает: `control-api недоступен: … context deadline exceeded`. Расследование
(2026-10-01) показало: в проекте OpenStack **6441 Floating IP, 6440 свободны** (раньше было 5). `ListFloatingIPs` запрашивает весь
список одним запросом без `limit` и без таймаута, Neutron отвечает >60 с (постранично: 200 адресов ≈ 2,4 с, весь список ≈ 70–80 с),
а дашборд ждёт 10 с. Запрос к тому же привязан к `r.Context()`: при обрыве соединения скан отменяется и не может завершиться.
Цель пользователя прежняя: **одной кнопкой подключить к проверке все доступные в проекте адреса, даже если их тысячи**, после чего
проверка запускается автоматически; автоматический цикл (очистка → скан → проверка → пауза) должен работать в этих условиях.
Решение пользователя по объёму: **полная адаптация** — фоновый постраничный скан + автоцикл + постраничные страницы дашборда.
Ожидание по времени (оценка по реальным данным стенда: слот на адрес ≈ 50 с, из них 30 с — `fip_settle_seconds`):
6440 адресов ≈ 18 ч на 5 валидаторах, ≈ 9 ч на 10, ≈ 4,5 ч на 20. Это ограничение пропускной способности, а не кода; рычаги —
число валидаторов и `fip_settle_seconds`. Для автоцикла это значит: `max_run_seconds` должен быть `0` (без лимита) или > 20 ч.
## Карта кода (по графу и разведке)
- `openstack.FloatingIPClient` (`internal/openstack/interface.go`): `GetFloatingIPByAddress`, `ListFloatingIPs`, `Associate…`, `Disassociate…`;
реализации — реальный `Client` (`client.go`, **нет таймаутов и ретраев**) и `MockClient` (`mock.go`, есть `ListFailure`, нет пагинации).
- `Orchestrator.ScanFloatingIPs` (`internal/orchestrator/orchestrator.go:412`) — синхронный: `OS.ListFloatingIPs` → фильтр `PortID==""` →
один `DB.SubmitIPs`. Вызывается из `handleAdminScanFloatingIPs` (`internal/httpapi/handlers_admin.go:107`, на `r.Context()`),
из `autoCycleStartRun` (`autocycle.go`, **в горутине цикла оркестратора под `autoCycleMu`** — минутный скан остановит `Tick`
и sweeps) и из периодического `scanTickerC` (`cmd/control-api/main.go`).
- БД: SQLite, `SetMaxOpenConns(1)`; `SubmitIPs` — одна транзакция, ~6 запросов на новый адрес (6440 ≈ 38 тыс. запросов);
`DeleteIPs`/`ClearQueue` — одна транзакция, ~6 запросов на адрес; `ListRegistry` — N+1 и **O(n²)**: нет индекса `ip_queue(registry_id)`.
- Full-table загрузчики: `GET /admin/ips`, `GET /admin/status` (грузит все строки ради счёта), `GET /admin/registry`, `autoCycleCheckRun`
(`ListIPs` каждый тик), дашборд `/overview` (опрос каждые 5 с: ~3 МБ JSON и таблица «текущая проверка» из ~6000 `queued`-строк),
`/ips` (~8 МБ HTML), `/registry`. Пагинации и фильтров на сервере нет.
- Дашборд: `client.do` с таймаутом 10 с; фрагменты и опрос htmx (`overview.html`, `overview_fragment.html`), GET-фильтр
`registry.html` (`hx-select` + `hx-replace-url`) — идиома для переиспользования. Per-row кнопки `hx-delete` при «выбрать все»
кладут все отмеченные адреса в URL (уже сейчас дефект, при тысячах — фатальный).
- Тик оркестратора не читает всю очередь (`ClaimNextQueued`, `ListChecking` — по индексу), пропускная способность не зависит от размера очереди.
## Дизайн
### 1. OpenStack: постраничное чтение, таймауты, ретраи (`internal/openstack`, `internal/config`)
- Новый метод интерфейса `ListFreeFloatingIPs(ctx, pageSize int, onPage func(page []FloatingIP) error) (pages int, err error)`:
цикл «страница → `onPage`»; для каждой страницы один запрос `floatingips.List(ListOpts{Limit, Marker=lastID})` с `EachPage`
(возврат `false` после первой страницы) — собственная пагинация по `marker`, а не `next`-ссылка (за прокси она может указывать на
внутренний хост). Свой `ListOptsBuilder`, добавляющий `fields=id&fields=floating_ip_address&fields=port_id&fields=project_id`
(в gophercloud `ListOpts.Fields` нет; на стенде проверено: `fields` + `marker` работают). Фильтр свободных — на клиенте
(`PortID==""`); серверный `status=DOWN` не используем (надмножество, возможны гонки статуса).
- **Ретраи страницы** с backoff (по умолчанию 5 попыток, 1→2→4→8→16 с) на сетевые ошибки, `EOF/RemoteDisconnected`, 5xx и 429
(на стенде уже наблюдался `RemoteDisconnected` на второй странице); 4xx (кроме 429) — без ретрая. Контекст отменяет ретраи.
- **Таймаут на запрос**: `provider.HTTPClient.Timeout` (`openstack.request_timeout_seconds`, 60) — закрывает и вечные зависания в `Tick`
(`GetFloatingIPByAddress`/`Associate`/`Disassociate`), ключевой побочный эффект.
- Конфиг: `openstack.list_page_size` (200), `openstack.request_timeout_seconds` (60), `orchestrator.fip_scan_timeout_seconds` (1800),
`openstack.list_page_retries` (5); дефолты в `LoadControlAPI`, примеры в `configs/*.example.yaml` и `rxprod-compose/sources/`.
- `ListFloatingIPs` (полный список) остаётся для совместимости и тестов (реализован поверх нового метода).
- `MockClient`: пагинация (`PageSize`), счётчик вызовов/страниц, очередь ошибок `ListFailures []error` (по одной на запрос),
опциональная задержка страницы, `SeedMany(n)` для тестов на тысячи.
### 2. Фоновое задание скана (`internal/orchestrator/scanjob.go`)
- `ScanJob` в `Orchestrator`, **нулевое значение пригодно** (тесты строят `&Orchestrator{…}` литералом): `sync.Mutex`, текущий прогресс,
`cancel`. Метод `StartScan(opts) (ScanStatus, started bool)` — single-flight: если скан уже идёт, возвращает его статус
(`started=false`). Горутина работает на контексте жизни процесса (хранится в `Orchestrator`, задаётся из `main`, по умолчанию
`context.Background()`), **не** на `r.Context()`; общий дедлайн `fip_scan_timeout_seconds`.
- Опции: `ClearFirst bool` (для автоцикла), `DryRun bool` (только обнаружить и посчитать, очередь не трогать — безопасная проверка
на живом стенде и полезная функция для оператора).
- Фазы и прогресс: `idle → clearing → listing → enqueuing → done|error|cancelled`; поля `pages`, `discovered`, `free`, `added`,
`requeued`, `reordered`, `skipped_in_progress`, `started_at`, `finished_at`, `error`.
- **Алгоритм:** (1) `ClearFirst` → `ClearQueue`; (2) чтение всех страниц в память (6440 строк — килобайты), `free = PortID==""`;
(3) сортировка по IPv4 по возрастанию (детерминированный порядок очереди); (4) **только после полного обнаружения** — `SubmitIPs`
кусками по 500 в этом порядке (`base=MAX+1` пересчитывается на вызов ⇒ порядок сохраняется; транзакции короткие, единственное
соединение освобождается между кусками); проверки стартуют, как только появляются первые `queued`; (5) одно событие `fip_scan`
с итоговыми счётчиками. Ошибка чтения после ретраев ⇒ **ничего не ставится в очередь** (для ручного скана очередь не меняется),
статус `error` с причиной; повтор — кнопкой или следующим циклом. Ошибка БД посередине ⇒ уже поставленные куски остаются
(повтор идемпотентен: `SubmitIPs` переупорядочивает/пропускает).
- `ScanFloatingIPs(ctx)` остаётся тонкой синхронной обёрткой («запустить и дождаться») для существующих тестов/скриптов.
- Периодический `scanTickerC` вызывает неблокирующий `StartScan`.
### 3. Масштабирование БД и запросов (`internal/db`, миграция `0009`)
- Миграция `0009_scale_indexes.sql`: `idx_ip_queue_registry ON ip_queue(registry_id)` (убирает O(n²) в реестре),
`idx_ip_queue_state_aggregated ON ip_queue(state, aggregated_at)` (список «последние завершённые»).
- Новые запросы: `CountIPsByState`, `CountIPsByResult` (GROUP BY — вместо загрузки всех строк в `/admin/status`),
`AnyNonTerminalIP` (`SELECT EXISTS … state NOT IN (done,failed,occupied)`), `ListIPsPage(filter{states[], q, result, order},
limit, offset) → (items, total)`, `ListRegistryPage(filter{q, lastResult}, limit, offset) → (items, total)` — **LIMIT/OFFSET до**
`fillRegistrySummary`, поэтому 3–4 запроса на строку платят только строки страницы. Фильтр `lastResult` реализуется одним SQL:
`ip_registry r LEFT JOIN ip_queue q ON q.registry_id=r.id`, условие `(q.id IS NOT NULL AND q.overall_result=?) OR (q.id IS NULL AND
<подзапрос по checks последнего цикла: pass/fail/partial>=?)` — та же семантика, что `fillRegistrySummary`/`lastCycleResultFromChecks`,
без денормализации и миграции данных.
- `ClearQueue`: set-based очистка без цикла по адресам — `UPDATE validators SET current_ip_id=NULL…`, `UPDATE checks SET ip_id=NULL`,
`UPDATE events SET ip_id=NULL`, `DELETE ip_site_checks`, `DELETE ip_queue` (5 запросов, O(n)); disassociate FIP только для строк с `FIPID`;
событие `queue_cleared` — счётчик и усечённый список (не 6440 адресов). `Orchestrator.DeleteIPs` — выбор строк без N `GetIPByAddress`.
### 4. HTTP API (`internal/httpapi`) — обратная совместимость сохраняется
| Метод | Путь | Изменение |
|---|---|---|
| POST | `/admin/ips/scan` | `202 {state, started_at, …}` (запуск или уже идущий скан — `202` с текущим статусом); `?dry_run=true`; `?wait=true` — старая синхронная семантика (`200` + счётчики) для curl/скриптов |
| GET | `/admin/ips/scan` | **новый**: статус и прогресс скана (admin-токен) |
| GET | `/admin/ips` | без параметров — как раньше (массив); с `limit` — конверт `{items,total,limit,offset}`; фильтры `state` (csv), `q`, `result`, `order` |
| GET | `/admin/registry` | то же: `limit/offset/q/last_result` → конверт с `total` |
| GET | `/admin/status` | + `results_by_overall`, счёт через `GROUP BY` |
| GET | `/admin/overview` | **опционально** одним запросом: счётчики, активные (≤100), последние завершённые (N), ближайшие в очереди (≤10), статус скана и автоцикла |
Новые admin-маршруты попадают в таблицу `routes.go` с `accessAdmin`; `TestRouteTableClassification` (`auth_test.go`) обновить (+1–2 admin).
### 5. Автоцикл (`internal/orchestrator/autocycle.go`, `queries_autocycle.go`, `dashboard/dto.go`)
- Новая фаза **`scanning`** (миграция не нужна — валидатор фаз в `UpdateAutoCycleState` расширить; подписи `PhaseLabel` — «сканирование Floating IP»).
- `autoCycleStartRun` перестаёт блокировать цикл: запускает `StartScan{ClearFirst:true}` (очистка + скан целиком в фоне, **литерал
сценария пользователя сохранён: очистка → скан**) и сразу переводит фазу в `scanning`; `autoCycleMu` держится только на время
чтения/записи состояния, а не на всё время скана ⇒ `Tick` и `Start/Stop` не блокируются.
- Шаг `scanning`: опрос статуса задания. `running` → выход; `error` → существующий путь `fail()` (исход `error`, повтор через
`interval_seconds`); `done` и `free==0` → `no_free_ips`; `done` → фаза `running`, `last_scanned_free`, **`run_started_at` = конец скана**
(лимит `max_run_seconds` считается от конца скана). Таймаут самого скана — `fip_scan_timeout_seconds`.
- **Восстановление после рестарта:** фаза `scanning` без живого задания ⇒ заново `StartScan{ClearFirst:true}` (идемпотентно).
- `Stop` отменяет задание скана (исход `stopped`, если шёл скан или проверка).
- `autoCycleCheckRun`: проверка завершения — `AnyNonTerminalIP` вместо `ListIPs` каждый тик; `COUNT` только при завершении.
- Документировать: при тысячах адресов `max_run_seconds=0`; цикл длится часы; интервал отсчитывается от завершения.
### 6. Дашборд (`internal/dashboard`, шаблоны, CSS)
- **Скан-кнопка и прогресс:** `hx-post="/ips/scan"` возвращает панель `scan_progress` (вне `#ips-form`): стадия, `<progress>`,
прочитано/свободных/добавлено, время, ошибка; пока `running` панель сама опрашивает `GET /ips/scan/status` (`hx-trigger="every 2s"`),
по завершении — без триггера и с `HX-Trigger: scan-finished`, по которому таблица перезагружается (`hx-get` + `hx-select`);
кнопка блокируется на время скана; `409`/ошибки — штатным баннером (`bannerFor`). Скан-старт возвращается мгновенно, таймаут
10 с больше не проблема.
- **Пагинация `/ips` и `/registry`:** `page`, `per_page` (50 по умолчанию; 25/50/100/200), partial `pager` («Показано a–b из N», ‹ ›,
`url.Values` для экранирования); фильтры на сервере: `/registry` — `q`, `status`; `/ips` — новая форма `q` + состояние
(все / в очереди / в работе / done / failed / occupied / результат), идиома GET + `hx-select` + `hx-replace-url`.
Скрытые `page/q/state` внутри `#ips-form`, чтобы мутации возвращали ту же страницу.
- **Массовые операции:** чекбоксы — только строки страницы + счётчик «Выбрано на странице k из 50»; при отмеченном заголовке и
`total > per_page` — ссылка «Выбрать все N по фильтру» (`scope=all`): адреса разрешаются на сервере постранично и уходят в
`DeleteIPs`/`SubmitIPs` кусками по ~500. Per-row и scan/clear-кнопки получают `hx-params="page,q,state"` (чинит URL из тысяч адресов).
`hx-confirm` с реальным числом (`Удалить ВСЕ {{.Total}} адресов…`).
- **«Обзор»:** без `ListIPs`; `Status` (+`results_by_overall`) и ограниченные списки: «В работе» (активные состояния), «В очереди: Q»
(счётчик + ссылка на `/ips?state=queued`, ≤10 ближайших), «Последние N завершённых»; `occupied` — терминальное состояние.
Индикатор в блоке статистики (OOB-обновление, как сейчас): «Готово D из T (P%) · в работе A · в очереди Q» + `<progress>`,
оценка времени по скорости последних завершённых; статус скана («Сканирование: прочитано X») и автоцикла.
- `client.go`: `ListIPsPage`, `ListRegistryPage`, `ScanStatus`; длинный таймаут/отдельный клиент для clear и массовых операций.
### 7. Прочее
- `routes`, `dto_admin.go`, `docs/API.md` (202/статус/пагинация/`dry_run`/`wait`), `docs/USAGE.md` (скан, автоцикл: фаза `scanning`,
ожидание ≈ N×50 с/валидаторов, рычаги), `docs/DASHBOARD.md`, `docs/LOCAL_E2E.md`, `README.md`, `configs/*.example.yaml`
(+ копии в `rxprod-compose/sources/` и docker-примеры).
- `rxprod-compose/control-api.yaml` (живой конфиг) — при необходимости задать `max_run_seconds` автоцикла = 0 (уже 0) и
`openstack.list_page_size`; по умолчанию достаточно дефолтов.
- Опционально (не входит): переупорядочить `Tick` (сначала sweeps, потом `assignIdleValidators`) — экономит до 5 с на адрес (~10 %).
## Тесты (минимальные, в стиле существующих)
- `internal/openstack`: пагинация по marker на моке (N=2500, `PageSize=200`), ретрай страницы при `ListFailures`, отмена контекста;
классификация ретраемых ошибок.
- `internal/orchestrator`: задание скана — single-flight, прогресс, `DryRun`, ошибка чтения ⇒ очередь не изменена, куски по 500,
порядок по возрастанию IP, 6440 адресов за разумное время; автоцикл: фаза `scanning`, шаги с явным `now`, ошибка скана,
`no_free_ips`, рестарт в фазе `scanning`, `Stop` отменяет скан, `Tick` не блокируется во время долгого скана (мок с задержкой).
- `internal/db`: `ListIPsPage`/`ListRegistryPage` (фильтры, total, LIMIT до summary), `CountIPsBy*`, `AnyNonTerminalIP`,
set-based `ClearQueue`, тест на 6440 строк (время и отсутствие N+1).
- `internal/httpapi`: 202/409-семантика скана, `?wait=true`, `?dry_run=true`, `GET /ips/scan`, конверт пагинации, обратная
совместимость без `limit`; обновить `TestScanFloatingIPsEndpoint` и счётчики в `auth_test.go`.
- `internal/dashboard`: панель прогресса и остановка опроса, пагинация/pager, фильтры `/ips`, `scope=all`, Overview на ограниченных
списках при тысячах `queued`, `hx-params`; обновить `fakeControlAPI` и затронутые тесты (`TestIPsScan` и др.).
- `scripts/run-local-e2e.sh`: автоцикл проходит через фазу `scanning` (мок-пагинация), полный сценарий остаётся зелёным за 90 с.
## Верификация
1. `go build ./... && go vet ./... && go test ./...` и `go test -race` для `orchestrator`, `openstack`, `httpapi`, `dashboard`, `db`.
2. `scripts/run-local-e2e.sh` (с токенами) — зелёный, автоцикл проходит `scanning → running → waiting`.
3. **Живой стенд, безопасно (чтение):** `POST /admin/ips/scan?dry_run=true` — скан проходит все страницы реального Neutron, прогресс
идёт, итог ≈ 6440 свободных, очередь не меняется, дашборд не получает таймаута. Замер времени скана и нагрузки.
4. **Живой стенд, по согласованию:** кнопка «Сканировать Floating IP» — адреса поставлены в очередь, проверки идут, `/ips`,
`/registry`, «Обзор» остаются быстрыми (проверка размера ответов и времени), «Очистить всё» отрабатывает за секунды.
Решение о реальной постановке 6440 адресов (≈18 ч проверок на 5 валидаторах) принимает пользователь; перед этим — бэкап БД.
5. Автоцикл на живом стенде — только после п. 4 и с согласия пользователя; наблюдать фазу `scanning`, отсутствие блокировки `Tick`.
6. Реальный браузер (Playwright из venv): прогресс скана, пагинация, фильтры, выбор «все N по фильтру», отсутствие JS-ошибок.
@@ -0,0 +1,81 @@
# Ревью и тестирование: скан Floating IP и автоцикл при тысячах адресов
> Дата: 2026-10-01 18:59 MSK · План: [2026-10-01_18-19_fip-scan-at-scale-plan.md](2026-10-01_18-19_fip-scan-at-scale-plan.md)
> Статус: **реализовано и проверено на живом окружении**; реальная постановка 6440 адресов в очередь и включение автоцикла на живом стенде **не выполнялись** — ждут решения пользователя.
## Итог
Исходная ошибка («control-api недоступен: context deadline exceeded» при нажатии «Сканировать Floating IP») устранена: на живом стенде кнопка
возвращает панель прогресса за 0,7 с, а сканирование реального Neutron (6441 Floating IP, 6440 свободных) проходит за ≈ 1,5–2 минуты в фоне
с видимым прогрессом. Код написан двумя агентами параллельно (Sonnet 5.5): серверная часть и дашборд, ревью и все проверки — независимо (Sonnet 5.5, high).
Найден и исправлен один дефект автоцикла и одна мелочь в клиенте OpenStack; остальные замечания — ограничения дизайна, перечислены ниже.
## Причина исходной ошибки
`ListFloatingIPs` запрашивал весь список одним запросом без `limit` и без таймаута: при ≈ 6,4 тыс. адресов Neutron отвечал дольше минуты, дашборд ждал 10 с,
а запрос был привязан к `r.Context()` — при обрыве соединения скан отменялся и не мог завершиться. Ошибка нигде не логировалась.
Побочные открытия: у клиента OpenStack вообще не было таймаутов и ретраев; `ListRegistry` был O(n²) (нет индекса `ip_queue(registry_id)`);
`/ips` отдавал ≈ 8 МБ HTML, а «Обзор» каждые 5 с тянул ≈ 3 МБ JSON.
## Что реализовано
| Область | Изменения |
|---|---|
| OpenStack | `ListFreeFloatingIPs` — постраничное чтение по `marker` (200 на страницу, `fields=` сокращает ответ), повтор страницы с backoff на обрывы/`RemoteDisconnected`/5xx/429, таймаут запроса (`openstack.request_timeout_seconds`, 60 с — закрывает и зависания в тике оркестратора); `MockClient` с пагинацией, `ListFailures`, `PageDelay`, `SeedMany` |
| Скан-задание | `orchestrator/scanjob.go`: single-flight фоновое задание на контексте процесса (не `r.Context()`), фазы `clearing → listing → enqueuing → done/error/cancelled`, прогресс; сначала полное обнаружение, затем `SubmitIPs` кусками по 500 по возрастанию IP; сбой чтения ⇒ очередь не меняется; `dry_run`; синхронная обёртка `ScanFloatingIPs` сохранена |
| Автоцикл | фаза `scanning`: очистка и скан — одно фоновое задание, цикл оркестратора и `autoCycleMu` не блокируются; восстановление после рестарта; `Stop` отменяет скан; завершение определяется `EXISTS`, а не чтением всей очереди каждый тик |
| БД | миграция `0009` (индексы `ip_queue(registry_id)`, `ip_queue(state, aggregated_at)`); `ListIPsPage`, `ListRegistryPage` (LIMIT/OFFSET до расчёта сводки, фильтр итога одним SQL), `CountIPsByState/Result`, `AnyNonTerminalIP`; `ClearAllIPs` — 5 запросов вместо цикла по адресам |
| API | `POST /admin/ips/scan` → `202` (`dry_run`, `wait`), новый `GET /admin/ips/scan`, пагинация и фильтры у `GET /admin/ips` и `/admin/registry` (без `limit` — прежний массив), `results_by_overall` в `/admin/status`, `count` у `clear` |
| Дашборд | панель прогресса скана и «Пробное сканирование»; постраничные `/ips` и `/registry` с серверными фильтрами; «Обзор» на счётчиках и ограниченных списках (прогресс «Готово D из T», оценка времени); «Выбрать все N по фильтру»; `hx-params` на кнопках (исправлен дефект — отмеченные адреса попадали в URL `hx-delete`); подтверждения с реальным числом; длинный таймаут для массовых операций |
| Конфиг и документы | `openstack.list_page_size/request_timeout_seconds/list_page_retries`, `orchestrator.fip_scan_timeout_seconds`; `API.md`, `USAGE.md`, `DASHBOARD.md`, `README.md`, примеры конфигов |
## Результаты проверок
| Проверка | Результат |
|---|---|
| `gofmt`, `go build ./...`, `go vet ./...` | чисто |
| `go test ./...` (включая тесты на 6440 адресов) | все пакеты зелёные |
| `go test -race -short` (openstack, orchestrator, db, httpapi, dashboard, config) | зелёные (тесты на 6440 адресов под `-race` слишком долгие, пропускаются по `-short`; агент прогонял их полностью) |
| `scripts/run-local-e2e.sh` (с токенами) | exit 0: автоцикл прошёл через `scanning`, проверки реальными агентом и пробером — `pass` |
| **Живой стенд — пробный скан реального Neutron** (`POST …/scan?dry_run=true`) | 33 страницы, **6441 найдено / 6440 свободных за 93 с**, `202` за 2 мс, повторный `POST` присоединился к идущему заданию, **API отвечал за 2–4 мс всё время скана**, очередь осталась пустой |
| **Живой дашборд (настоящий Chromium)**, кнопка «Пробное сканирование» | панель за 0,7 с без баннера ошибки, прогресс по страницам, итог «готово: 33 страницы, 6441, 6440, время 1 мин 48 с» |
| Изолированный mock-стенд на **6440 адресах**, настоящий Chromium (17 из 19 автопроверок, 2 — ложные, см. ниже) | `/ips` **68 КБ за 0,18 с** (было ≈ 8 МБ), фрагмент «Обзора» **3 КБ за 0,05 с**, `/registry` 34 КБ за 0,12 с; пагинация и серверный поиск; прогресс скана (читаются страницы → ставятся в очередь → готово); «Очистить всё» с реальным числом в подтверждении — **0,5 с**; «Выбрать все 6440 по фильтру» + массовое удаление — **8 с**; JS-ошибок нет; ошибок в логе control-api нет |
| Миграция `0009` на живой БД | `user_version = 9`, оба индекса созданы, данные не тронуты |
Примечание: два «FAIL» в браузерном скрипте mock-стенда — ошибка самого скрипта: панель показывает состояние заглавными («ГОТОВО», CSS), а скрипт искал строчные.
Выведенный текст панели подтверждает успех (`добавлено6440`, `найдено адресов6440`). Реальных провалов нет.
## Замечания ревью
| № | Серьёзность | Замечание | Статус |
|---|---|---|---|
| 1 | средняя | **Автоцикл «усыновлял» чужое сканирование.** Если в момент старта цикла уже шло ручное/периодическое/**пробное** сканирование, `StartScan` возвращал `started=false`, а цикл переходил в `scanning` и ждал чужое задание. Пробное ничего не ставит в очередь, ручное не очищает очередь ⇒ цикл переходил в `running` над пустой/нетронутой очередью и сразу отчитывался `completed` (`runs_total+1`) без единой проверки | **исправлено**: если собственный скан не стартовал, цикл ничего не меняет и пробует снова на следующем такте (после окончания чужого); добавлен тест `TestAutoCycleWaitsForForeignScanInsteadOfFollowingIt` (падал до правки) |
| 2 | низкая | Клиент OpenStack заполнял `ProjectID` только из `tenant_id`; при `fields=` Neutron может вернуть лишь `project_id` | **исправлено** (запасной вариант `project_id`); поле нигде не влияет на логику |
| 3 | низкая | `aggregated_at_desc` сортирует по `strftime(...)` — временные метки хранятся как RFC3339Nano, и сырая сортировка текстом неверна (поймал тест агента); индекс `(state, aggregated_at)` помогает фильтру по состоянию, но не сортировке | принято; на 6440 строк незаметно |
| 4 | низкая | `StopAutoCycle` в фазе `scanning` вызывает `CancelScan`, который ждёт до 5 с под `autoCycleMu`: «Выключить» может занять до 5 с, а следующий шаг цикла — подождать | принято |
| 5 | низкая | Если скан завершился ошибкой после «Очистить» (шаг 1 цикла), очередь остаётся пустой до следующего цикла (`interval_seconds`); исход — `error` с причиной | принято, описано в `USAGE.md`; при желании — отдельная доработка (повтор скана сразу) |
| 6 | низкая | Статус скана хранится в памяти: после рестарта control-api он `idle`; автоцикл в фазе `scanning` при этом корректно перезапускает скан | принято |
| 7 | инфо | Отмена сканирования (`CancelScan`) вызывается только из `Stop` автоцикла; ручной кнопки/эндпоинта отмены нет | не входило в план |
| 8 | инфо | Дашборд: мутации заменяют `#ips-table-wrap` целиком (`outerHTML`), чтобы `hx-get` обёртки всегда указывал на текущую страницу/фильтр; убраны функции `filterQueueItems/filterRegistryItems/currentlyChecking/lastCompleted` вместе с тестами (фильтрация перенесена на сервер); сводка «последние N» считается по показанному (возможно, отфильтрованному) окну, а общие итоги — отдельной строкой | принято |
| 9 | инфо | Тесты на 6440 адресов под `-race` занимают 40–90 с на пакет — пропускаются по `-short` | принято |
## Пропускная способность (важно для автоцикла)
Проверка не стала быстрее — стало возможным её запустить. По фактическим данным стенда слот на адрес ≈ 50 с на валидатор (из них 30 с — `fip_settle_seconds`):
**6440 адресов ≈ 18 ч на 5 валидаторах, ≈ 9 ч на 10, ≈ 4,5 ч на 20.** Для автоцикла `max_run_seconds` должен оставаться `0`. Рычаги — число валидаторов и (осторожно)
`fip_settle_seconds`. На странице «Обзор» виден прогресс и оценка времени.
## Состояние живого стенда (`rxprod-compose`)
- Развёрнуты новые образы `civ-capi`, `civ-adash`, `civ-prober` (и пересобран `civ-agent`); миграция `0009` применена. Предыдущие образы сохранены под тегом `:pre-scale`
(откат: `docker tag civ-capi:pre-scale civ-capi:latest` и `docker compose up -d`; на `0009` откат БД не нужен — это только индексы). Бэкап БД перед обновлением — в каталоге scratchpad сессии (`/tmp`, временный).
- Очередь пуста (5 адресов прежней работы остались в реестре). **Реальная постановка 6440 адресов и автоцикл на живом стенде не запускались**: это ≈ 18 ч реальных проверок,
решение за пользователем. Перед запуском рекомендую «Пробное сканирование» (уже отработало штатно) и бэкап БД.
- Токен агентов по-прежнему не включён (внешние валидаторы и пробер `rxyc` со старыми бинарниками) — см. [ревью аутентификации](2026-10-01_11-31_authentication-review.md).
Новые бинарники для внешних валидаторов — в `bin/` (после раскатки токена агентов их можно обновить одновременно).
- `bin/` пересобран (`CGO_ENABLED=0`, `-trimpath -ldflags="-s -w"`), `SHA256SUMS` обновлён. Временные контейнеры `civ-scale-*` удалены.
## Что осталось
- Решение пользователя: поставить 6440 адресов в очередь («Сканировать Floating IP») и/или включить автоцикл на живом стенде.
- По желанию: кнопка/эндпоинт отмены скана (замечание 7), повтор скана внутри цикла при ошибке (замечание 5), `Cache-Control: no-store` (из ревью аутентификации).