Compare commits
18
Commits
48efa61ef3
..
main
No files matched your search
@@ -5,6 +5,8 @@
|
||||
!/deploy/docker/.env.example
|
||||
!/deploy/docker/.env.prod.example
|
||||
/deploy/docker/control-api/control-api.docker.yaml
|
||||
# Ansible: рабочий env-файл запуска validator-agent (шаблон .example остаётся в git).
|
||||
/deploy/ansible/env/*.env
|
||||
/rxprod-compose/.env
|
||||
|
||||
# Live runtime database for the rxprod-compose control-api container.
|
||||
|
||||
@@ -1,54 +1,227 @@
|
||||
# Cloud IP Validator
|
||||
|
||||
Система проверки освобождённых публичных IPv4-адресов перед их повторной
|
||||
выдачей: каждый адрес временно привязывается как Floating IP к
|
||||
ВМ-валидатору в облаке (OpenStack) и проверяется одновременно в двух
|
||||
направлениях — исходящий трафик валидатора (egress: HTTPS/ICMP/опционально
|
||||
SSH до внешних целей) и входящая доступность самого адреса с нескольких
|
||||
независимых внешних площадок (inbound: TCP 22/80/443/8080 + ICMP). Итог по
|
||||
каждому адресу — `pass`/`partial`/`fail`, с полной историей проверок,
|
||||
которая сохраняется даже после того, как адрес убрали из очереди (см.
|
||||
[«Реестр адресов»](docs/USAGE.md#реестр-адресов-и-глубина-истории)).
|
||||
Система проверки освобождённых публичных IPv4-адресов перед их повторной выдачей. Каждый адрес временно привязывается как Floating IP
|
||||
к ВМ-валидатору в облаке (OpenStack) и проверяется в двух направлениях одновременно: исходящий трафик валидатора (egress: HTTPS/ICMP/опционально SSH
|
||||
до внешних целей) и входящая доступность самого адреса с нескольких независимых внешних площадок (inbound: TCP 22/80/443/8080 + ICMP).
|
||||
Итог по каждому адресу — `pass`/`partial`/`fail`; полная история проверок сохраняется в реестре даже после удаления адреса из очереди.
|
||||
|
||||
## Роли компонентов
|
||||
## Быстрый старт
|
||||
```bash
|
||||
go build ./... && go test ./... # сборка и юнит-тесты
|
||||
scripts/run-local-e2e.sh # офлайн-прогон всей системы: mock OpenStack, без облака и интернета
|
||||
```
|
||||
```bash
|
||||
cd deploy/docker
|
||||
cp .env.example .env
|
||||
cp control-api/control-api.docker.example.yaml control-api/control-api.docker.yaml
|
||||
docker compose up -d --build # весь стенд на одной машине в mock-режиме (профили control-plane, dashboard, prober, validator)
|
||||
```
|
||||
- UI: `http://<хост>:8090/`, API: `http://<хост>:8080/api/v1`, проверка живости: `GET /healthz`.
|
||||
- Docker-стенд работает с `openstack.mode: mock` и тестовыми адресами `203.0.113.10–12`; реальный OpenStack и учётные данные не нужны.
|
||||
- Реальный стенд (systemd или Docker на нескольких хостах) — по шагам в [docs/SETUP.md](docs/SETUP.md).
|
||||
|
||||
## Конфигурация
|
||||
Каждый компонент читает свой YAML (примеры — `configs/*.example.yaml`). Учётные данные OpenStack в YAML не хранятся — только *имена* переменных окружения.
|
||||
|
||||
**control-api (`control-api.yaml`)**
|
||||
| Ключ | Назначение |
|
||||
|---|---|
|
||||
| `server.listen_addr` | Адрес API (`:8080`) |
|
||||
| `database.path` | Файл SQLite (`/var/lib/cloud-ip-validator/control-api.db`) |
|
||||
| `openstack.mode` | `real` или `mock` (встроенная заглушка OpenStack для разработки и тестов) |
|
||||
| `openstack.auth_method` | `token` (готовый токен проекта из `OS_TOKEN`, сам не обновляется) или `password` (логин/пароль Keystone, токен перевыпускается автоматически) |
|
||||
| `openstack.*_env` | Имена переменных окружения: `OS_AUTH_URL`, `OS_PROJECT_ID`, `OS_REGION_NAME`, `OS_INTERFACE`, `OS_TOKEN` либо `OS_USERNAME`/`OS_USER_DOMAIN_NAME`/`OS_PASSWORD` |
|
||||
| `orchestrator.poll_interval_seconds` | Период такта оркестратора (5) |
|
||||
| `orchestrator.self_check_timeout_seconds`, `max_self_check_retries` | Ожидание self-check валидатора (60). `max_self_check_retries` **устарело** и не используется: потолок провалов self-check задаётся настройкой `self_check_max_attempts` (по умолчанию 5, `/settings`) |
|
||||
| `orchestrator.checking_window_seconds` | Сколько ждать результаты проверок, прежде чем подвести итог; отсчёт от начала проверки (120) |
|
||||
| `orchestrator.lease_ttl_seconds`, `max_retries` | Лизинг адреса за валидатором (180) и число возвратов в очередь при его истечении (3) |
|
||||
| `orchestrator.heartbeat_timeout_seconds` | После скольких секунд тишины валидатор или площадка считаются потерянными (30) |
|
||||
| `orchestrator.fip_settle_seconds` | Только начальное значение: пауза между привязкой FIP и self-check; далее управляется на лету. Должно выполняться `fip_settle_seconds + self_check_timeout_seconds < lease_ttl_seconds` |
|
||||
| `orchestrator.fip_scan_interval_seconds` | Периодический скан Floating IP (0 — выключен). Не выполняется, пока включён автоматический цикл |
|
||||
| `orchestrator.fip_scan_timeout_seconds` | Предел всего сканирования Floating IP (1800) |
|
||||
| `openstack.list_page_size`, `list_page_retries`, `request_timeout_seconds` | Скан читает Floating IP страницами (200), повторяя страницу при обрыве/5xx/429 (5 раз); таймаут одного запроса к OpenStack (60 с) |
|
||||
| `auth.admin_token_env`, `auth.agent_token_env` | Имена переменных окружения с токеном администратора (`CONTROL_API_ADMIN_TOKEN`) и токеном агентов (`CONTROL_API_AGENT_TOKEN`). Значения в YAML не хранятся; пустой токен — соответствующий уровень API открыт (с предупреждением в логе) |
|
||||
| `aggregation.missing_counts_as_fail` | Отсутствие ответа источника засчитывается как провал (`true`) |
|
||||
| `validators`, `sites`, `check_types`, `targets`, `inbound_checks` | Начальная загрузка пустой БД: валидаторы (`validator_id` + `os_port_id`), внешние площадки, типы и цели egress-проверок, порты inbound-проверок. Дальше источник истины — БД, правки через API/UI |
|
||||
| `ip_addresses` | Адреса, которые доливаются в очередь при каждом старте (только новые) |
|
||||
|
||||
**Остальные компоненты**
|
||||
| Компонент | Ключи |
|
||||
|---|---|
|
||||
| `validator-agent` | `validator_id`, `control_api_url`, `control_api_token_env` (`CONTROL_API_AGENT_TOKEN`), `poll_interval_seconds`, `self_check.*` (таймаут, `methods` — `ip_echo`/`control_api`, `ip_echo_urls`), `checks.*` (таймауты HTTPS/ICMP, число ICMP-пакетов, `ssh.*`) |
|
||||
| `prober` | `site_id`, `control_api_url`, `control_api_token_env` (`CONTROL_API_AGENT_TOKEN`), `poll_interval_seconds`, `checks.*` (таймауты TCP/ICMP, число ICMP-пакетов) |
|
||||
| `admin-dashboard` | `server.listen_addr` (`:8090`), `control_api.base_url`, `control_api.timeout_seconds`, `control_api.token_env` (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`), `auth.username_env` / `password_env` / `session_secret_env` (`ADMIN_DASHBOARD_USERNAME` / `_PASSWORD` / `_SESSION_SECRET`), `auth.session_ttl_minutes` (480), `overview.last_completed_count` (20), `overview.poll_interval_seconds` (5) |
|
||||
|
||||
Секреты (токены, пароль дашборда, ключ сессии) задаются **только переменными окружения**; генерация — `openssl rand -hex 32`. Подробности и порядок включения — [docs/SETUP.md](docs/SETUP.md#5-аутентификация-токены-и-пароль-дашборда).
|
||||
|
||||
Настройки, меняющиеся на лету (пауза перед self-check, глубина истории, типы inbound-проверок, автоматический цикл, валидаторы, площадки, цели), хранятся в БД и правятся через API или страницу `/settings`.
|
||||
|
||||
## Архитектура
|
||||
| Слой | Технологии |
|
||||
|---|---|
|
||||
| Backend | Go 1.26, `net/http` (маршруты Go 1.22), SQLite (чистый Go-драйвер `modernc.org/sqlite`, WAL, одно соединение), встроенные миграции `0001`–`0008` |
|
||||
| Облако | Интерфейс `FloatingIPClient`: реальный клиент OpenStack Neutron и `MockClient` |
|
||||
| UI | Серверный рендеринг (Go-шаблоны) + htmx и Alpine.js, шрифты и скрипты лежат в репозитории, внешних зависимостей нет |
|
||||
| Сборка | Статические бинарники (`CGO_ENABLED=0`) в `bin/`, Docker-образы, systemd-юниты |
|
||||
|
||||
| Компонент | Роль | Состояние | Где работает |
|
||||
|---|---|---|---|
|
||||
| `control-api` | Управляющий сервис: ведёт очередь адресов и реестр их истории, назначает валидаторов, агрегирует результаты. Единственная точка, с которой общаются все остальные компоненты. | Хранит (SQLite) | Одна управляющая машина |
|
||||
| `validator-agent` | Привязывает к себе выданный Floating IP и прогоняет egress-проверки до внешних целей. | Без состояния | Каждая ВМ-валидатор в облаке |
|
||||
| `prober` | Проверяет входящую доступность адреса снаружи (TCP/ICMP) — с независимой от облака сети. | Без состояния | Каждая внешняя тестовая площадка |
|
||||
| `admin-dashboard` | Браузерная админ-панель — то же самое, что доступно через API `control-api`, но графически. Опционален. | Без состояния | Любая машина с сетевым доступом к `control-api` |
|
||||
| `control-api` | Ведёт очередь адресов и реестр их истории, назначает валидаторов, агрегирует результаты. Единственная точка, с которой общаются все остальные компоненты | Хранит (SQLite) | Одна управляющая машина |
|
||||
| `validator-agent` | Привязывает к себе выданный Floating IP и прогоняет egress-проверки до внешних целей | Без состояния | Каждая ВМ-валидатор в облаке |
|
||||
| `prober` | Проверяет входящую доступность адреса снаружи (TCP/ICMP) — с независимой от облака сети | Без состояния | Каждая внешняя тестовая площадка |
|
||||
| `admin-dashboard` | Браузерная админ-панель — то же, что API `control-api`, но графически. Опционален | Без состояния | Любая машина с доступом к `control-api` |
|
||||
|
||||
Все четыре общаются между собой **только** через HTTP API `control-api` —
|
||||
прямых связей между остальными компонентами нет. Подробная схема
|
||||
control/data plane — в [docs/DIAGRAMS.md](docs/DIAGRAMS.md).
|
||||
Все четыре общаются **только** через HTTP API `control-api`. Pull-модель: `validator-agent` и `prober` сами опрашивают `control-api`, он к ним не обращается.
|
||||
Схемы control/data plane — [docs/DIAGRAMS.md](docs/DIAGRAMS.md).
|
||||
|
||||
```
|
||||
cmd/ control-api validator-agent prober admin-dashboard
|
||||
internal/ config db orchestrator httpapi openstack dashboard agentcore probercore checkrunner apiclient
|
||||
db/migrations/ схема SQLite (0001–0008)
|
||||
configs/ примеры конфигураций компонентов
|
||||
deploy/ docker/ (compose, Dockerfile, RUN.txt) systemd/ (юниты)
|
||||
rxprod-compose/ compose реального стенда (control-api на :8081, дашборд на :8091)
|
||||
scripts/ run-local-e2e.sh httpstub/ (заглушки целей для e2e)
|
||||
bin/ готовые бинарники linux/amd64 и SHA256SUMS
|
||||
docs/ документация и планы доработок
|
||||
```
|
||||
|
||||
## Модель данных и правила
|
||||
`ip_queue` (текущая очередь) · `ip_registry` (все адреса, когда-либо бывшие в очереди) · `checks` (результаты по циклам) · `events` (журнал) · `validators` · `sites` · `target_groups` · `check_types` · `settings` · `inbound_checks_settings` · `auto_cycle`.
|
||||
|
||||
**Очередь и состояния**
|
||||
- Путь адреса: `queued` → `assigning_fip` → `awaiting_self_check` → `checking` → `aggregating` → `done` или `failed`. Все переходы, кроме команд оператора, выполняет оркестратор сам по таймеру.
|
||||
- Терминальные состояния: `done`, `failed`, `occupied`. `occupied` — Floating IP к моменту привязки уже занят чужим портом: цикл проверки не стартует, `overall_result` пуст, это не `fail`.
|
||||
- Оператор может отменить проверку (`failed` + `cancelled`), перепроверить завершённый адрес (возврат в `queued`) или удалить адрес из очереди.
|
||||
- Адреса обрабатываются в порядке `sequence`; каждый свободный валидатор получает следующий `queued`-адрес под лизинг. Истёкший лизинг возвращает адрес в очередь, после `max_retries` — в `failed`.
|
||||
|
||||
**Итоговый результат**
|
||||
- `pass` — прошли все проверки: исходящие и все настроенные площадки по всем портам и ICMP. `partial` — часть прошла, часть нет. `fail` — не прошла ни одна проверка или адрес не дошёл до проверок (self-check не подтвердился). `cancelled` — остановлено оператором.
|
||||
- Итог подводится, когда отчитались валидатор и все настроенные площадки либо истекло `checking_window_seconds` (отсчёт от начала проверки). Молчание источника — провал (`aggregation.missing_counts_as_fail`).
|
||||
- Проверки фиксируются в момент вердикта: результат после него не сохраняется (событие `result_dropped`), каждая площадка зондирует адрес один раз за попытку. Вердикт всегда совпадает с сохранёнными проверками.
|
||||
- Площадки опциональны: с пустым списком `sites` итог строится только по egress-проверкам.
|
||||
|
||||
**Запуски и аналитика**
|
||||
- Запуск — одна партия проверок: открывается, когда адрес попадает в пустую или обработанную очередь, принимает всё, что добавлено или перепроверено, пока открыт, и завершается, когда у всех его адресов есть итог. Перепроверка после завершения открывает новый запуск; результаты запусков не пересекаются.
|
||||
- Страница «Аналитика» показывает один завершённый запуск по фактическим проверкам: показатели, причины `partial`, подсети (список — в настройках), цели и типы проверок, площадки, классы ошибок, валидаторы; списки адресов выгружаются в CSV.
|
||||
|
||||
**Реестр**
|
||||
- Запись реестра создаётся при первой постановке адреса и не удаляется: она переживает удаление из очереди и повторное добавление.
|
||||
- История проверок хранится по циклам; `history_retention_cycles` ограничивает глубину (0 — без ограничения), сама запись реестра остаётся.
|
||||
|
||||
**Автоматический цикл** (опционально, по умолчанию выключен)
|
||||
- Один цикл: очистить очередь → просканировать Floating IP (фаза `scanning`, фоновое задание) → дождаться, пока все адреса станут терминальными → пауза `interval_seconds` → заново. Пауза считается от завершения цикла. Сканирование не блокирует оркестратор; чужое идущее сканирование цикл дожидается, а не присоединяется к нему.
|
||||
- `interval_seconds` — по умолчанию 3600, минимум 60; `max_run_seconds` — максимальное ожидание проверок (0 — без лимита), по истечении исход `timeout`.
|
||||
- Исходы цикла: `completed`, `no_free_ips`, `timeout`, `error`, `stopped`. Опустевшая очередь посреди цикла считается завершением.
|
||||
- Состояние хранится в БД и переживает перезапуск; выключение не прерывает идущие проверки. Подробности — [docs/USAGE.md](docs/USAGE.md#автоматический-цикл-проверок).
|
||||
|
||||
**Удаление и сканирование**
|
||||
- Удаление (точечное, списком, «очистить всё») убирает строку очереди, но не историю в реестре.
|
||||
- Скан Floating IP ставит в очередь только свободные адреса (не привязанные ни к одному порту); уже идущие проверки не трогаются. Он идёт **в фоне и читает облако страницами**: подходит и для тысяч адресов (на стенде 6441 Floating IP читаются ≈ 1,5–2 мин). Адреса ставятся в очередь только после полного обнаружения (кусками по 500, по возрастанию IP); при сбое чтения очередь не меняется. `dry_run=true` / «Пробное сканирование» считает адреса, не меняя очередь.
|
||||
- Пропускная способность: ≈ 50 с на адрес на валидатор — очередь из 6440 адресов это ≈ 18 ч на 5 валидаторах, ≈ 9 ч на 10 (рычаги: число валидаторов и `fip_settle_seconds`).
|
||||
|
||||
## API (`/api/v1`)
|
||||
| Область | Эндпоинты |
|
||||
|---|---|
|
||||
| Валидатор | `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`, `GET /agents/{id}/observed-ip`, `POST /agents/{id}/self-check\|events\|results\|complete` |
|
||||
| Пробер | `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments`, `POST /probers/{site_id}/results` |
|
||||
| Очередь | `GET /admin/status`, `GET\|POST /admin/ips` (`limit/offset/state/q/result/order` — постранично), `GET /admin/ips/{ip}`, `POST /admin/ips/{ip}/cancel`, `DELETE /admin/ips/{ip}`, `POST /admin/ips/delete\|clear`, `POST\|GET /admin/ips/scan` (фоновый скан: `202`, `dry_run`, `wait`; статус и прогресс) |
|
||||
| Реестр | `GET /admin/registry` (`limit/offset/q/last_result/run/subnet` — постранично; в строке — уровни `egress`/`ingress` с разбивкой по типам), `GET /admin/registry/{ip}` |
|
||||
| Аналитика | `GET /admin/analytics/runs`, `GET /admin/analytics/runs/{id}`, `GET /admin/analytics/runs/{id}/lists/{kind}` (JSON или `?format=csv`), `GET`/`PUT /admin/config/subnets` |
|
||||
| Автоцикл | `GET\|PUT /admin/auto-cycle`, `POST /admin/auto-cycle/start\|stop` |
|
||||
| Конфигурация | `/admin/config/validators`, `/sites`, `/targets`, `/check-types`, `GET\|PUT /admin/config/orchestrator`, `GET\|PUT /admin/config/inbound-checks` |
|
||||
| Служебное | `GET /admin/validators`, `GET /healthz` |
|
||||
|
||||
**Соглашения**
|
||||
- Тело запросов и ответов — JSON. Успех — `200`, `204` — когда данных нет (например, у валидатора нет назначения).
|
||||
- Ошибки — `4xx`/`5xx` с телом `{"error": "..."}`; нет или неверен токен — `401` (с `WWW-Authenticate: Bearer`), неизвестная сущность — `404`, конфликт состояния — `409`, неверные данные — `400`, ошибка OpenStack при скане — `502`.
|
||||
- Токен передаётся заголовком `Authorization: Bearer <токен>` ([docs/API.md](docs/API.md#аутентификация)).
|
||||
- Времена — RFC 3339. Полная спецификация и примеры `curl` — [docs/API.md](docs/API.md).
|
||||
|
||||
## Безопасность
|
||||
- **Доступ к API — два статических Bearer-токена** (без срока жизни, из переменных окружения, сравнение в константное время):
|
||||
| Уровень | Токен | Методы |
|
||||
|---|---|---|
|
||||
| admin | `CONTROL_API_ADMIN_TOKEN` | все `/api/v1/admin/*` |
|
||||
| agent | `CONTROL_API_AGENT_TOKEN` | запись результатов валидатора и пробера: `self-check`, `events`, `results`, `complete` |
|
||||
| открыто | — | `GET /healthz`, `register`, `heartbeat`, получение задания (`GET assignment` / `assignments`) и `GET /agents/{id}/observed-ip` |
|
||||
Токены разные: административный не открывает методы агентов, и наоборот. Валидатор и пробер получают настройку и задание без токена, но не могут отправить результат без токена агентов.
|
||||
- **Пустой токен — уровень открыт** (обратная совместимость): `control-api` стартует с предупреждением в логе. На реальном стенде задайте оба токена и ограничьте доступ на уровне сети ([docs/SETUP.md](docs/SETUP.md#сетевые-доступы)); токены идут открытым текстом без TLS — публикуйте через reverse-proxy с TLS. Включать токен агентов нужно **после** его раздачи валидаторам и проберам ([порядок](docs/SETUP.md#5-аутентификация-токены-и-пароль-дашборда)).
|
||||
- **Дашборд закрыт логином и паролем** (один администратор; пароль и ключ сессии — из env). Сессия — подписанная cookie (`HttpOnly`, `SameSite=Strict`, без состояния на сервере), CSRF-защита по `Origin`, 5 неудачных входов за 10 минут с одного IP → `429`. Без заданных логина/пароля дашборд открыт (с предупреждением в логе). Дашборд ходит в API с токеном администратора. Подробности — [docs/DASHBOARD.md](docs/DASHBOARD.md#вход-и-сессия).
|
||||
- Учётные данные OpenStack передаются только через переменные окружения процесса (`EnvironmentFile=` в systemd, `OS_*` в Docker) и не попадают в YAML; режим `password` перевыпускает токен сам, режим `token` — нет.
|
||||
- Компоненты работают по pull-модели: на валидаторах и площадках не нужно открывать входящие порты для `control-api`.
|
||||
- Бинарники статические, без `cgo`; целостность проверяется `sha256sum -c bin/SHA256SUMS`.
|
||||
|
||||
## Публикация и эксплуатация
|
||||
- **Бинарники.** Готовые linux/amd64 лежат в `bin/` и **не обновляются автоматически**: после правок кода пересоберите их и обновите `SHA256SUMS` (команды — [docs/SETUP.md](docs/SETUP.md#вариант-b-сборка-из-исходников)); Dockerfile копируют именно `bin/*`.
|
||||
- **systemd.** Юниты в `deploy/systemd/`; у `control-api` — `EnvironmentFile` с учётными данными OpenStack.
|
||||
- **Docker.** `deploy/docker/docker-compose.yml` + `docker-compose.override.yml` (dev, mock) или `docker-compose.prod.yml` (без публикации портов, `restart: unless-stopped`). Какие сервисы поднимаются на хосте, задаёт `COMPOSE_PROFILES`: `control-plane`, `dashboard`, `prober`, `validator`. БД — volume `cloud-ip-validator-db`. Массовая доставка `validator-agent` на валидаторы (сборка образа на каждом хосте, замена контейнера) — Ansible-сценарий [`deploy/ansible/`](deploy/ansible/README.md).
|
||||
- **Реальный стенд.** `rxprod-compose/` — compose с готовыми образами, собственным `control-api.yaml` и каталогом БД `capi-db/`; `.env` с учётными данными в репозиторий не входит.
|
||||
- **Миграции** применяются при старте `control-api`; версия схемы — `PRAGMA user_version`. Начальная загрузка (`validators`, `sites`, `targets`, `check_types`, `inbound_checks`) выполняется только в пустые таблицы.
|
||||
- Остановка (`SIGTERM`) корректно завершает HTTP-сервер и фоновые циклы. Состояние автоцикла и очереди сохраняется в БД.
|
||||
|
||||
## Интерфейс
|
||||
Страницы `admin-dashboard` (подробно — [docs/DASHBOARD.md](docs/DASHBOARD.md)):
|
||||
| Страница | Назначение |
|
||||
|---|---|
|
||||
| `/overview` | Счётчики и прогресс («Готово D из T», оценка времени), «в работе», «в очереди: Q», «последние завершённые», поиск по IP и фильтр по статусу, индикатор скана и автоцикла; работает на счётчиках и ограниченных списках, поэтому быстрый и при тысячах адресов |
|
||||
| `/ips`, `/ips/{ip}` | Очередь **постранично** с поиском и фильтром на сервере: добавление адресов, «Сканировать Floating IP» (панель прогресса) и «Пробное сканирование», перепроверка, отмена, удаление (страница или «все N по фильтру», «Очистить всё»); детали и события адреса |
|
||||
| `/registry`, `/registry/{ip}` | Реестр всех адресов (постранично) и полная история проверок адреса; поиск, фильтр и страница сохраняются в адресной строке. Последний результат разделён на уровни Egress и Ingress: «успешно из всего» по каждому и по типам проверок (icmp, ssh, tcp, https…) |
|
||||
| `/analytics` | Аналитика одного завершённого запуска: показатели, причины `partial`, подсети, провалы по целям и типам проверок, ingress по площадкам, классы ошибок, валидаторы; выбор запуска; списки адресов с выгрузкой в CSV |
|
||||
| `/validators`, `/sites`, `/targets`, `/check-types` | Управление валидаторами, внешними площадками, группами целей и типами проверок |
|
||||
| `/settings` | Панель «Автоматический цикл», пауза перед self-check, потолок провалов self-check на адрес, глубина истории, TCP-порты и ICMP для inbound-проверок |
|
||||
- Порядок блоков на `/overview` фиксирован: статистика → фильтр → таблицы; поллится только блок таблиц, поэтому набранный в фильтре текст не сбрасывается.
|
||||
- Ошибки control-api показываются баннером; при недоступном API страница остаётся рабочей.
|
||||
- Тёмная и светлая темы, переключатель в шапке.
|
||||
|
||||
## Тесты
|
||||
```bash
|
||||
go build ./... && go vet ./... && go test ./... # юнит-тесты: db, orchestrator, httpapi, dashboard, agentcore, probercore, checkrunner, openstack
|
||||
go test -race ./internal/orchestrator ./internal/httpapi ./internal/dashboard ./internal/db
|
||||
scripts/run-local-e2e.sh # сквозной прогон: lease-reclaim, перепроверка, автоматический цикл
|
||||
```
|
||||
Юнит-тесты используют временную SQLite и `MockClient`, внешних ресурсов не требуют. E2E поднимает все компоненты локальными процессами с включёнными токенами (проверяет `401`/открытые маршруты и работу агента и пробера с токеном) и завершается ненулевым кодом при провале проверок автоцикла — [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md).
|
||||
|
||||
## Документация
|
||||
|
||||
| Документ | Для чего |
|
||||
|---|---|
|
||||
| [docs/SETUP.md](docs/SETUP.md) | Развёртывание с нуля: бинарники или сборка из исходников, конфигурация, systemd **и** Docker/docker-compose — пошагово |
|
||||
| [docs/USAGE.md](docs/USAGE.md) | Повседневная работа: постановка адресов в очередь, сканирование Floating IP, автоматический цикл проверок по расписанию, наблюдение за статусом, реестр и история, разбор результатов |
|
||||
| [docs/USAGE.md](docs/USAGE.md) | Повседневная работа: очередь, сканирование Floating IP, автоматический цикл, статус, реестр и история, разбор результатов |
|
||||
| [docs/API.md](docs/API.md) | Спецификация HTTP API `control-api` и примеры запросов (curl) |
|
||||
| [docs/DASHBOARD.md](docs/DASHBOARD.md) | Устройство `admin-dashboard`: страницы, поиск/фильтр, обработка ошибок |
|
||||
| [docs/DASHBOARD.md](docs/DASHBOARD.md) | Устройство `admin-dashboard`: страницы, поиск и фильтр, обработка ошибок |
|
||||
| [docs/ADMIN_CLEANUP.md](docs/ADMIN_CLEANUP.md) | Ручная очистка БД администратором (SQL): сброс данных перед новым прогоном, журнал событий, реестр проверок, отдельные адреса, резервная копия и восстановление |
|
||||
| [docs/DIAGRAMS.md](docs/DIAGRAMS.md) | Диаграммы потоков данных: control plane, egress-проверка, телеметрия |
|
||||
| [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md) | Полностью офлайн-прогон всей системы одним скриптом — без реального облака и интернета |
|
||||
| [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md) | Полностью офлайн-прогон всей системы одним скриптом |
|
||||
| [docs/changes/](docs/changes/) | Планы доработок и отчёты ревью с отметкой времени в имени файла (последняя: [скан при тысячах адресов](docs/changes/2026-10-01_18-59_fip-scan-at-scale-review.md)) |
|
||||
| [docs/CONTROL_DATA_PLANE.html](docs/CONTROL_DATA_PLANE.html) | Презентационные схемы control/data plane для docker-compose-деплоя — открыть в браузере |
|
||||
|
||||
## Быстрый старт
|
||||
## История изменений
|
||||
Дизайн крупных доработок зафиксирован в планах `docs/PLAN_*.md`; остальное — в истории git (`git log`).
|
||||
|
||||
Посмотреть систему в работе без реального облака (60 секунд):
|
||||
| Изменение | Документ |
|
||||
|---|---|
|
||||
| Веб-панель администратора `admin-dashboard` | [план](docs/PLAN_ADMIN_DASHBOARD.md) · [описание](docs/DASHBOARD.md) |
|
||||
| Динамическое управление конфигурацией и очередью через API | [план](docs/PLAN_API_CONFIG_MANAGEMENT.md) · [API](docs/API.md#управление-очередью-и-конфигурацией) |
|
||||
| Удаление адресов: точечное, массовое, «очистить всё» | [план](docs/PLAN_DELETE_IPS.md) |
|
||||
| Пауза перед self-check после привязки Floating IP (`fip_settle_seconds`) | [план](docs/PLAN_FIP_SETTLE_DELAY.md) · [USAGE](docs/USAGE.md#пауза-перед-self-check-fip_settle_seconds) |
|
||||
| Механизм аутентификации OpenStack-клиента (token / password) | [план](docs/PLAN_OPENSTACK_AUTH.md) |
|
||||
|
||||
```bash
|
||||
go build ./... && go test ./...
|
||||
scripts/run-local-e2e.sh
|
||||
```
|
||||
|
||||
Поднимет все компоненты как локальные процессы (`openstack.mode: mock`) и
|
||||
прогонит тестовый адрес через полный цикл проверки. Подробности —
|
||||
[docs/LOCAL_E2E.md](docs/LOCAL_E2E.md).
|
||||
|
||||
Для реального стенда (systemd-юниты или Docker/docker-compose, на одной
|
||||
машине или распределённо) — по шагам в
|
||||
[docs/SETUP.md](docs/SETUP.md), начиная с
|
||||
[«Получение бинарников»](docs/SETUP.md#получение-бинарников).
|
||||
| Дата | Веха | Документ |
|
||||
|---|---|---|
|
||||
| 2026-10-04 | Аналитика: сравнение двух запусков (`/analytics/compare`): новые, выбывшие и изменившиеся адреса, динамика по семи индикаторам, матрица переходов вердикта; API `analytics/compare` | [план](docs/changes/2026-10-04_10-08_analytics-run-compare-plan.md) · [итог](docs/changes/2026-10-04_10-08_analytics-run-compare-summary.md) |
|
||||
| 2026-10-04 | Аналитика: карточки `pass`, `partial`, `fail` открывают список адресов с этим вердиктом и выгрузку в CSV (`lists/verdict_*`) | [план](docs/changes/2026-10-04_09-55_analytics-verdict-indicators-plan.md) · [итог](docs/changes/2026-10-04_09-55_analytics-verdict-indicators-summary.md) |
|
||||
| 2026-10-04 | Повтор после сбоя self-check — на другом валидаторе: валидатор, проваливший self-check, этому адресу больше не выдаётся; потолок провалов `self_check_max_attempts` (по умолчанию 5, миграция `0012`); поле `self_check_failed_on` | [план](docs/changes/2026-10-04_08-01_self-check-exclude-validator-plan.md) · [итог](docs/changes/2026-10-04_08-01_self-check-exclude-validator-summary.md) |
|
||||
| 2026-10-03 | Раздел «Аналитика»: запуски проверки (миграция `0011`), показатели и списки адресов по запуску, подсети, CSV; сайдбар: связь с control-api и выход наверху, группы разделов; фильтры реестра по запуску и подсети | [план](docs/changes/2026-10-03_16-39_analytics-section-plan.md) · [итог](docs/changes/2026-10-03_18-41_analytics-section-summary.md) · [макет](docs/mockups/analytics-mockup.html) · [USAGE](docs/USAGE.md#аналитика-запусков) · [API](docs/API.md#аналитика-запусков) |
|
||||
| 2026-10-03 | Вердикт без опоздавших результатов: проверки фиксируются в момент вердикта, площадка зондирует адрес один раз за попытку, окно проверки считается от её начала, время записи по часам сервера (`checks.recorded_at`) | [план](docs/changes/2026-10-03_17-21_verdict-no-late-results-plan.md) · [итог](docs/changes/2026-10-03_17-21_verdict-no-late-results-summary.md) · [API](docs/API.md#результаты-после-вердикта) · [USAGE](docs/USAGE.md#просмотр-деталей-и-истории-по-конкретному-адресу) |
|
||||
| 2026-10-03 | Реестр: последний результат по уровням Egress и Ingress, «успешно из всего» и разбивка по типам проверок (поля `egress`, `ingress`, `last_cycle_id` в `GET /admin/registry`) | [план](docs/changes/2026-10-03_16-24_registry-egress-ingress-levels-plan.md) · [итог](docs/changes/2026-10-03_16-24_registry-egress-ingress-levels-summary.md) · [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) · [API](docs/API.md#get-apiv1adminregistry) |
|
||||
| 2026-10-02 | Валидатор не получает второй адрес при потере heartbeat (иначе адреса уходили в `fail` без проверок); heartbeat агента в отдельном потоке; быстрая очистка очереди, не зависящая от соединения клиента | [план](docs/changes/2026-10-02_09-02_orchestrator-validator-state-and-clear-plan.md) · [анализ инцидента](analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md) · [USAGE](docs/USAGE.md#управление-валидаторами) |
|
||||
| 2026-10-02 | Самопроверка через ручку control-api (`self_check.methods`, способ `control_api` рядом с IP-echo); отвязка Floating IP при провале self-check | [план](docs/changes/2026-10-02_03-06_self-check-control-api-plan.md) · [API](docs/API.md#get-apiv1agentsidobserved-ip) |
|
||||
| 2026-10-01 | Скан Floating IP при тысячах адресов: фоновый постраничный скан с прогрессом, фаза `scanning` в автоцикле, постраничные `/ips` и `/registry`, «Обзор» на счётчиках | [план](docs/changes/2026-10-01_18-19_fip-scan-at-scale-plan.md) · [ревью и тесты](docs/changes/2026-10-01_18-59_fip-scan-at-scale-review.md) · [USAGE](docs/USAGE.md#сканирование-floating-ip-из-openstack) · [API](docs/API.md#post-apiv1adminipsscan) |
|
||||
| 2026-10-01 | Аутентификация: токены администратора и агентов для API, логин и пароль для дашборда | [план](docs/changes/2026-10-01_11-12_authentication-plan.md) · [ревью и тесты](docs/changes/2026-10-01_11-31_authentication-review.md) · [API](docs/API.md#аутентификация) |
|
||||
| 2026-10-01 | Автоматический цикл проверок по сценарию: очистка → скан FIP → проверка → пауза | [USAGE](docs/USAGE.md#автоматический-цикл-проверок) · [API](docs/API.md#автоматический-цикл-проверок) |
|
||||
| 2026-09-23 | Сканирование Floating IP и устойчивый реестр адресов с настраиваемой глубиной истории | [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) |
|
||||
| 2026-09-23 | Поиск по IP и фильтр по статусу на «Обзоре» и «Реестре» | [DASHBOARD](docs/DASHBOARD.md) |
|
||||
| 2026-09-23 | Пошаговое руководство по развёртыванию в Docker | [SETUP](docs/SETUP.md) |
|
||||
| 2026-09-18 | Массовая перепроверка в дашборде; compose реального стенда `rxprod-compose` | [DASHBOARD](docs/DASHBOARD.md) |
|
||||
| 2026-09-13 | Состояние `occupied`: пропуск цикла для уже занятого Floating IP; схемы control/data plane | [DIAGRAMS](docs/DIAGRAMS.md) |
|
||||
| 2026-08-26 | Управление внешними площадками и heartbeat пробера, новый UI дашборда, переключатель темы | [DASHBOARD](docs/DASHBOARD.md) |
|
||||
@@ -0,0 +1,167 @@
|
||||
# Аналитический разбор массовой проверки: 1026 адресов
|
||||
|
||||
> Время отчёта: 2026-10-02 08:56 UTC · Данные: снимок БД control-api на 08:46:53 UTC и лог control-api за 4 часа до остановки
|
||||
> Проверено адресов: **1026** (984 завершены `done` + 42 завершены `failed`) из 6440 в очереди
|
||||
> Окно прогона: 07:14:58 – 08:46:53 UTC (1 ч 32 мин). Остановлен вручную в 08:53:46 UTC.
|
||||
|
||||
## 1. Остановка проверок
|
||||
|
||||
- Проверки остановлены в 08:53:46 UTC операцией «Очистить всё» (`POST /api/v1/admin/ips/clear`).
|
||||
- После остановки: очередь пуста, все 20 валидаторов `idle`, последняя выдача адреса в 08:53:43, новых выдач нет.
|
||||
Реестр с историей адресов сохранён (6445 записей).
|
||||
- Отвязка Floating IP с портов валидаторов при очистке: 13 зависших привязок снято прямым опросом портов
|
||||
(`detached floating ip from validator port`), ещё 8 привязок, завершавшихся во время очистки, система сняла сама
|
||||
(`address removed during association`). Состояние портов в самом OpenStack на момент отчёта не проверялось.
|
||||
- **Первая попытка очистки не сработала.** Очистка шла дольше таймаута клиента (120 с) и оборвалась: 600 отвязок завершились
|
||||
с ошибкой `context canceled`, ничего не удалилось, проверки продолжались. Повторная очистка без таймаута выполнялась 256 с.
|
||||
- **Причина долгой очистки (дефект кода, не исправлен):** у завершённых адресов (`done`) в БД остаётся `fip_id`, и очистка
|
||||
последовательно отвязывает все такие FIP, хотя они уже свободны (более 1000 вызовов OpenStack по ~0,2 с).
|
||||
|
||||
## 2. Итоги прогона
|
||||
|
||||
| Показатель | Значение |
|
||||
|---|---|
|
||||
| Адресов в очереди | 6440 |
|
||||
| Завершено | 1026 (984 `done` + 42 `failed`) |
|
||||
| `pass` | 227 (23% от завершённых) |
|
||||
| `partial` | 757 (77%) |
|
||||
| `fail` | 42 (все `failed`, вердикта по существу нет, см. раздел 4) |
|
||||
| Остались в очереди / в работе на момент снимка | 5392 в очереди, 22 в работе |
|
||||
|
||||
Пропускная способность по 10-минутным окнам (завершено адресов за минуту): 07:10 — 5,8; 07:20 — 13,4; 07:30 — 11,6;
|
||||
07:40 — 10,1; 07:50 — 10,6; 08:00 — 9,4; 08:10 — 9,7; 08:20 — 10,5; 08:30 — 10,6; 08:40 — 6,7 (окно неполное).
|
||||
|
||||
Цикл одного адреса (от выдачи валидатору до итога): минимум 65 с, медиана 85 с, p90 100 с, p99 115 с, максимум 125 с,
|
||||
среднее 84 с (по 984 адресам `done`). Для 20 валидаторов это теоретически ~14 адресов в минуту; фактически ~10,4,
|
||||
потеря около 27% из-за залипших валидаторов (раздел 4).
|
||||
|
||||
## 3. Фактура по накопившимся ошибкам
|
||||
|
||||
### 3.1. Классы ошибок (с 07:10)
|
||||
|
||||
В логе control-api за 4 часа на уровне `ERROR` только один вид сообщений: 41 ошибка привязки Floating IP.
|
||||
|
||||
| Ошибка / событие | Число | Что это |
|
||||
|---|---|---|
|
||||
| `lease expired` (возврат адреса по истечении лизинга) | 198 | Валидатор не подхватил задание за время лизинга. Затронуто минимум 77 адресов (часть событий без привязки к адресу) |
|
||||
| Привязка FIP: `409 Cannot associate floating IP … fixed IP already has a floating IP` | 41 | На порту валидатора уже висит другой FIP. Порты: v1 — 13, v7 — 6, v13 — 6, v3 — 5, v12 — 5, v16 — 4, v17 — 2 |
|
||||
| `validator_unreachable` | 7 | По одному разу: v1 и v12 (07:18:03), v13 (07:33:53), v3 (07:34:33), v7 (07:40:03), v16 (07:42:33), v17 (08:17:53) |
|
||||
| `site_unreachable` | 5 | rxmsk (08:09, 08:45) и misha-v (08:09, 08:20, 08:45) |
|
||||
| «Очистить всё»: `context canceled` | 600 | Последствие обрыва первой очистки (08:49), см. раздел 1 |
|
||||
|
||||
Чего не было: **провалов self-check — 0 из 1015** результатов; все 1015 прошли способом `control_api` (запасной `ip_echo`
|
||||
не понадобился). Событий `fip_occupied` — 0, адресов `occupied` — 0.
|
||||
|
||||
Журнал событий за прогон: `self_check_result` 2030 (по две записи на адрес), `fip_associated` 1120, `config_received` 1015,
|
||||
`aggregated` 1004, `retry_or_fail` 239 (198 лизинг + 41 привязка), `lease_expired` 198, `validator_unreachable` 7,
|
||||
`site_unreachable` 5.
|
||||
|
||||
### 3.2. По валидаторам
|
||||
|
||||
| Валидатор | Завершено (`done`) | `failed` | Сбросов лизинга | `unreachable` |
|
||||
|---|---|---|---|---|
|
||||
| vkiplab-v1 | 2 | 17 | 49 | 1 |
|
||||
| vkiplab-v12 | 2 | 13 | 51 | 1 |
|
||||
| vkiplab-v13 | 13 | 10 | 41 | 1 |
|
||||
| vkiplab-v16 | 38 | 2 | 19 | 1 |
|
||||
| vkiplab-v7 | 36 | 0 | 21 | 1 |
|
||||
| vkiplab-v17 | 50 | 0 | 10 | 1 |
|
||||
| vkiplab-v3 | 50 | 0 | 7 | 1 |
|
||||
| остальные 13 (v2, v4–v6, v8–v11, v14, v15, v18–v20) | 60–63 | 0 | 0 | 0 |
|
||||
|
||||
(Сбросы лизинга и `unreachable` в таблице — только за прогон, с 07:10. У v14, v18 и v2 в истории БД есть сбросы лизинга
|
||||
за 1 октября, к этому прогону они не относятся.)
|
||||
|
||||
- **v1 и v12** не подхватили ни одного задания после 07:17 (последний подхват 07:17:29 и 07:17:26).
|
||||
- **v13** — после 07:33:16.
|
||||
- **v7, v16, v17, v3** залипали временно и затем восстановились; механизм восстановления не выяснен.
|
||||
|
||||
### 3.3. 42 адреса `fail`
|
||||
|
||||
- По валидаторам: v1 — 17, v12 — 13, v13 — 10, v16 — 2. Все 42 исчерпали повторы: `retry_count` = 4 и `attempt_number` = 4 у каждого.
|
||||
- Причины неудачных попыток по этим адресам: 143 сброса лизинга и 25 ошибок привязки `409`.
|
||||
- **Ни одной проверки по ним не выполнено** (в таблице проверок у этих адресов 0 записей): адреса не получили вердикта,
|
||||
и `fail` здесь не характеризует сами адреса.
|
||||
- Появлялись равномерно с 07:31 до 08:46 (4–10 за 10 минут).
|
||||
- Подсети: 37.139.x, 79.137.x, 83.166.x и другие, без концентрации.
|
||||
|
||||
## 4. Причины
|
||||
|
||||
Подтверждены по БД и логам control-api. Логи агентов на самих валидаторах не изучались.
|
||||
|
||||
**Причина 1. Валидатор получает два адреса сразу и застревает.** Два дефекта вместе:
|
||||
- Heartbeat (`queries_validators.go`) возвращает валидатор из `unreachable` в `idle`, не проверяя, что за ним числится адрес.
|
||||
Адрес с долгими внешними проверками (3–4 таймаута по 10 с) блокирует агента больше 30 с (порог
|
||||
`heartbeat_timeout_seconds`), control-api помечает валидатор недоступным, затем возвращает в `idle` занятым.
|
||||
- При завершении старого адреса `ReleaseFIP` (`queries_ipqueue.go`) освобождает валидатор по его имени, а не по адресу.
|
||||
- Подтверждение: **35 двойных выдач** (два адреса одному валидатору с интервалом ~5 с) в логе: v1 — 10, v12 — 9, v7 — 6,
|
||||
v13 — 4, v17 — 3, v16 — 2, v3 — 1. Из 1265 выдач в логе.
|
||||
|
||||
**Причина 2. Регрессия моей правки с параллельной привязкой.** Защита от дублей в `orchestrator.go:149` ключуется по
|
||||
валидатору (`assign:<validator>`). Вторая выдача того же валидатора пропускает привязку, и адрес стоит в `assigning_fip`
|
||||
до истечения лизинга. В БД у валидатора одно поле `current_ip_id`, оно указывает на последний выданный адрес, агент по нему
|
||||
получает пустое задание, лизинг истекает, валидатор берёт новый адрес, и круг повторяется. Ошибки `409` — следствие: на
|
||||
порту остаётся FIP первого адреса, привязать второй нельзя.
|
||||
|
||||
**Причина 3. Агент молчит во время долгих проверок.** Heartbeat отправляется только между заданиями; адреса с несколькими
|
||||
таймаутами ведут к `unreachable` (пусковой механизм причины 1). Пример: v1 проверял `37.139.32.1`, v12 — `37.139.32.4`;
|
||||
у обоих проваливались все четыре внешних HTTPS-цели (~40 с таймаутов), в 07:18:03 оба помечены `unreachable`.
|
||||
|
||||
**Дефект очистки.** См. раздел 1: отвязка всех `done`-адресов последовательно.
|
||||
|
||||
## 5. Результаты проверок самих адресов
|
||||
|
||||
**Почему 77% `partial`:** 735 адресов проваливают только исходящие HTTPS, ещё 22 — исходящие и входящие.
|
||||
|
||||
| Цель (egress HTTPS) | Адресов с провалом (из 984) |
|
||||
|---|---|
|
||||
| `packages.ubuntu.com` | 698 (71%) |
|
||||
| `repo.almalinux.org/almalinux/` | 304 (31%) |
|
||||
| `github.com` | 251 (26%) |
|
||||
| `hub.docker.com` | 17 (2%) |
|
||||
|
||||
Число провалов на один `partial`-адрес: 1 — 392 адреса, 2 — 202, 3 — 136, 4 и больше — 27.
|
||||
|
||||
По подсетям (доля адресов с провалом цели):
|
||||
|
||||
| Подсеть | Адресов | `packages.ubuntu.com` | `repo.almalinux.org` | `github.com` | `hub.docker.com` | Доля `partial` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 83.166.x | 435 | 80% | 53% | 42% | 0% | 92% |
|
||||
| 37.139.x | 418 | 62% | 11% | 10% | 4% | 63% |
|
||||
| 79.137.x | 109 | 66% | 19% | 18% | 0% | 68% |
|
||||
| 5.188.x | 22 | 59% | 0% | 0% | 0% | 59% |
|
||||
|
||||
- `packages.ubuntu.com` проваливается у всех подсетей и во все окна. Доля проваленных строк egress для этой цели по 10-минутным
|
||||
окнам выросла с 26% до 40% (строки включают HTTPS и ICMP, поэтому реальная доля HTTPS вдвое выше).
|
||||
- Доля `partial` почти одинакова у всех валидаторов (72–84%; v1 — 100% по 2 адресам, v12 — 50% по 2): причина в самом адресе
|
||||
или во внешнем ресурсе, а не в валидаторе.
|
||||
- Провалы `repo.almalinux.org` и `github.com` сильно зависят от подсети (83.166.x — 53% и 42%, 37.139.x — 11% и 10%).
|
||||
- Успешные HTTPS-проверки: медиана 200 мс, p90 6707 мс (много ответов близко к таймауту 10 с).
|
||||
|
||||
**Входящие проверки.** Провалы только у `inbound-site-1` (33 пробы из 2934) и `inbound-site-3` (50 из 2931); `inbound-site-2` — 0 из 2952,
|
||||
`inbound-site-4` — 1 из 2952. Провалы всплесками в 08:00, 08:20 и 08:40 по ICMP, SSH и TCP 22; у 20 адресов упали все пробы
|
||||
одной площадки. Пробер rxmsk и misha-v в эти же минуты отмечены `unreachable`. Причина недоступности проберов не выяснена.
|
||||
|
||||
## 6. Рекомендации
|
||||
|
||||
1. **Исправить оркестратор** (до повторного запуска): защита от дублей по адресу, `unreachable` → `assigned` вместо `idle`,
|
||||
освобождение валидатора только по текущему адресу, heartbeat агента в отдельном потоке.
|
||||
2. **Исправить очистку:** отвязывать только действительно привязанные FIP (без `fip_released_at`) и параллельно.
|
||||
3. **Перепроверить 42 адреса** после исправления: вердикта по существу они не получили. Все `fail` с причиной «lease expired» считать недействительными.
|
||||
4. **Решить по `packages.ubuntu.com`:** 71% `partial` и 10 с таймаута на каждый такой адрес; если ресурс нестабилен, убрать или заменить.
|
||||
5. **Проверить причины `site_unreachable`** у проберов (возможна перегрузка при большой очереди).
|
||||
6. Проверить в OpenStack, что порты валидаторов свободны от Floating IP.
|
||||
|
||||
## 7. Что не проверено
|
||||
|
||||
- Состояние портов и Floating IP в самом OpenStack.
|
||||
- Логи агентов `validator-agent` на валидаторах и логи проберов (причины блокировок heartbeat подтверждены по времени и
|
||||
событиям control-api, не по логам агентов).
|
||||
- Почему залипшие v7, v16, v17, v3 восстановились.
|
||||
- Причины провалов внешних HTTPS-целей (ресурс, сеть облака или фильтрация) и недоступности проберов.
|
||||
|
||||
## 8. Источники данных
|
||||
|
||||
- Снимок БД control-api: `.backup` на 08:46:53 UTC (таблицы `ip_queue`, `checks`, `events`, `validators`, `sites`).
|
||||
- Лог контейнера control-api за 4 часа до остановки (1504 строки) и за период очистки.
|
||||
- Статус и результат очистки: HTTP 200 за 256,7 с.
|
||||
@@ -0,0 +1,42 @@
|
||||
37.139.32.70
|
||||
37.139.32.71
|
||||
37.139.32.72
|
||||
37.139.32.151
|
||||
37.139.33.219
|
||||
37.139.33.227
|
||||
37.139.33.230
|
||||
37.139.34.75
|
||||
37.139.34.94
|
||||
37.139.34.128
|
||||
37.139.40.122
|
||||
37.139.41.103
|
||||
37.139.41.129
|
||||
37.139.41.157
|
||||
37.139.41.202
|
||||
37.139.42.126
|
||||
37.139.43.9
|
||||
79.137.174.150
|
||||
79.137.174.172
|
||||
79.137.174.205
|
||||
79.137.174.210
|
||||
79.137.175.14
|
||||
79.137.175.51
|
||||
79.137.175.71
|
||||
79.137.175.162
|
||||
83.166.232.116
|
||||
83.166.232.117
|
||||
83.166.232.159
|
||||
83.166.233.37
|
||||
83.166.233.75
|
||||
83.166.233.78
|
||||
83.166.234.136
|
||||
83.166.235.75
|
||||
83.166.235.132
|
||||
83.166.235.247
|
||||
83.166.237.59
|
||||
83.166.237.228
|
||||
83.166.238.19
|
||||
83.166.248.57
|
||||
83.166.248.60
|
||||
83.166.248.92
|
||||
83.166.248.188
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
fb8aaab7c07c3702d274aa15fcb299690bb738cbde9faad79e73d726c24e9d1b control-api
|
||||
d7377e90cc34549933f0de8556df113f7ace7425ad0fee96f723ed6a4510d66b validator-agent
|
||||
c3c9e42ad7632cd8a4b88fe5eedf341cf0dcba6ecf5281d247733a98cee91fb6 prober
|
||||
23d1c10c26366dedd790025689eae63677316c7bb9dafb53c8cebca340dfe77f admin-dashboard
|
||||
2f4fa432060fb70c744ab5f593d3f12bbf39f5f6587b4fb6a445c60218d25831 control-api
|
||||
9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent
|
||||
3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober
|
||||
76db6c2dedaa89b7ba8253287a8289c7d72034f5371bcc714a55f3ab257f66ff admin-dashboard
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -39,11 +39,21 @@ func run(configPath string, log *slog.Logger) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
controlAPIToken := os.Getenv(cfg.ControlAPI.TokenEnv)
|
||||
if controlAPIToken == "" {
|
||||
log.Warn("control-api admin token is not set: calls to control-api are sent without credentials", "env", cfg.ControlAPI.TokenEnv)
|
||||
}
|
||||
|
||||
srv, err := dashboard.New(dashboard.Config{
|
||||
ControlAPIBaseURL: cfg.ControlAPI.BaseURL,
|
||||
ControlAPITimeout: time.Duration(cfg.ControlAPI.TimeoutSeconds) * time.Second,
|
||||
LastCompletedCount: cfg.Overview.LastCompletedCount,
|
||||
OverviewPollIntervalS: cfg.Overview.PollIntervalSeconds,
|
||||
ControlAPIToken: controlAPIToken,
|
||||
Username: os.Getenv(cfg.Auth.UsernameEnv),
|
||||
Password: os.Getenv(cfg.Auth.PasswordEnv),
|
||||
SessionSecret: os.Getenv(cfg.Auth.SessionSecretEnv),
|
||||
SessionTTL: time.Duration(cfg.Auth.SessionTTLMinutes) * time.Minute,
|
||||
}, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init dashboard: %w", err)
|
||||
|
||||
+25
-3
@@ -59,8 +59,21 @@ func run(configPath string, log *slog.Logger) error {
|
||||
}
|
||||
|
||||
orch := orchestrator.New(database, osClient, cfg, log)
|
||||
orch.Async = true // slow OpenStack calls run per address, Tick never waits for them
|
||||
// Background jobs (the floating-IP scan) live as long as the process, not
|
||||
// as long as the HTTP request or loop iteration that started them.
|
||||
orch.SetContext(ctx)
|
||||
|
||||
srv := httpapi.New(database, orch, log)
|
||||
adminToken := os.Getenv(cfg.Auth.AdminTokenEnv)
|
||||
agentToken := os.Getenv(cfg.Auth.AgentTokenEnv)
|
||||
if adminToken == "" {
|
||||
log.Warn("admin API is open: admin token is not set", "env", cfg.Auth.AdminTokenEnv)
|
||||
}
|
||||
if agentToken == "" {
|
||||
log.Warn("agent write API is open: agent token is not set", "env", cfg.Auth.AgentTokenEnv)
|
||||
}
|
||||
|
||||
srv := httpapi.New(database, orch, log).WithAuth(adminToken, agentToken)
|
||||
httpServer := &http.Server{Addr: cfg.Server.ListenAddr, Handler: srv.Handler()}
|
||||
|
||||
go runOrchestratorLoop(ctx, orch, cfg, log)
|
||||
@@ -126,8 +139,10 @@ func runOrchestratorLoop(ctx context.Context, orch *orchestrator.Orchestrator, c
|
||||
} else if ac.Enabled {
|
||||
continue
|
||||
}
|
||||
if _, _, err := orch.ScanFloatingIPs(ctx); err != nil {
|
||||
log.Error("scan floating ips", "err", err)
|
||||
// Non-blocking: the scan runs in the background (single-flight, so
|
||||
// a still-running scan is simply joined) and must not stall Tick.
|
||||
if st, started := orch.StartScan(orchestrator.ScanOptions{}); !started {
|
||||
log.Info("periodic floating ip scan skipped: a scan is already running", "state", st.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,11 +164,18 @@ func newOpenStackClient(ctx context.Context, cfg *config.ControlAPI) (openstack.
|
||||
}
|
||||
|
||||
func newRealOpenStackClient(ctx context.Context, cfg *config.ControlAPI) (openstack.FloatingIPClient, error) {
|
||||
retries := cfg.OpenStack.ListPageRetries
|
||||
if retries < 0 {
|
||||
retries = 0 // negative in the config disables retries
|
||||
}
|
||||
clientCfg := openstack.ClientConfig{
|
||||
AuthURL: os.Getenv(cfg.OpenStack.AuthURLEnv),
|
||||
ProjectID: os.Getenv(cfg.OpenStack.ProjectIDEnv),
|
||||
Region: os.Getenv(cfg.OpenStack.RegionEnv),
|
||||
Interface: os.Getenv(cfg.OpenStack.InterfaceEnv),
|
||||
|
||||
RequestTimeout: time.Duration(cfg.OpenStack.RequestTimeoutSeconds) * time.Second,
|
||||
ListPageRetries: retries,
|
||||
}
|
||||
|
||||
switch cfg.OpenStack.AuthMethod {
|
||||
|
||||
+5
-1
@@ -31,7 +31,11 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
prober := probercore.New(cfg, log)
|
||||
token := os.Getenv(cfg.ControlAPITokenEnv)
|
||||
if token == "" {
|
||||
log.Warn("agent token is not set: result calls will be rejected by an authenticated control-api", "env", cfg.ControlAPITokenEnv)
|
||||
}
|
||||
prober := probercore.New(cfg, log).WithToken(token)
|
||||
if err := prober.Run(ctx); err != nil && err != context.Canceled {
|
||||
log.Error("prober stopped", "err", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -39,7 +39,11 @@ func main() {
|
||||
startStubListeners(ctx, log, *stubPorts)
|
||||
}
|
||||
|
||||
agent := agentcore.New(cfg, log)
|
||||
token := os.Getenv(cfg.ControlAPITokenEnv)
|
||||
if token == "" {
|
||||
log.Warn("agent token is not set: result/event/complete calls will be rejected by an authenticated control-api", "env", cfg.ControlAPITokenEnv)
|
||||
}
|
||||
agent := agentcore.New(cfg, log).WithToken(token)
|
||||
if err := agent.Run(ctx); err != nil && err != context.Canceled {
|
||||
log.Error("agent stopped", "err", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -4,6 +4,23 @@ server:
|
||||
control_api:
|
||||
base_url: "http://control-api.internal:8080"
|
||||
timeout_seconds: 10
|
||||
# Имя переменной окружения с admin-токеном control-api
|
||||
# (CONTROL_API_ADMIN_TOKEN на стороне control-api). Пусто в окружении —
|
||||
# запросы идут без заголовка Authorization.
|
||||
token_env: "ADMIN_DASHBOARD_CONTROL_API_TOKEN"
|
||||
|
||||
# Вход в дашборд: один администратор, сессия в cookie. Здесь только ИМЕНА
|
||||
# переменных окружения; значения задаются окружением процесса (см.
|
||||
# deploy/systemd/admin-dashboard.service). Если логин или пароль не заданы —
|
||||
# вход не требуется (в логе предупреждение при старте).
|
||||
auth:
|
||||
username_env: "ADMIN_DASHBOARD_USERNAME"
|
||||
password_env: "ADMIN_DASHBOARD_PASSWORD"
|
||||
# Ключ подписи cookie сессии (openssl rand -hex 32). Не задан — ключ
|
||||
# случайный на каждый старт, и рестарт сбрасывает все сессии.
|
||||
session_secret_env: "ADMIN_DASHBOARD_SESSION_SECRET"
|
||||
# Срок жизни сессии, минут (по умолчанию 480 = 8 часов).
|
||||
session_ttl_minutes: 480
|
||||
|
||||
# Настройки сводки на странице "Обзор" — см. docs/DASHBOARD.md. Оба поля
|
||||
# влияют только на то, как дашборд группирует уже существующие данные
|
||||
|
||||
@@ -37,6 +37,36 @@ openstack:
|
||||
user_domain_name_env: "OS_USER_DOMAIN_NAME"
|
||||
password_env: "OS_PASSWORD"
|
||||
|
||||
# Постраничное чтение Floating IP (скан при тысячах адресов): сколько
|
||||
# адресов запрашивать у Neutron за один запрос. Default 200.
|
||||
# Page size of the paged floating-IP listing. Default 200.
|
||||
list_page_size: 200
|
||||
# Таймаут каждого HTTP-запроса к Keystone/Neutron, секунд. Default 60.
|
||||
# Per-request HTTP timeout (also protects the orchestrator tick from a
|
||||
# hung Neutron call). Default 60.
|
||||
request_timeout_seconds: 60
|
||||
# Сколько раз повторять неудавшуюся страницу (сетевая ошибка, EOF/
|
||||
# RemoteDisconnected, 5xx, 429) с паузами 1,2,4,8,16 с. Default 5;
|
||||
# отрицательное значение отключает повторы.
|
||||
# Retries per failed listing page. Default 5; negative disables retries.
|
||||
list_page_retries: 5
|
||||
|
||||
# Аутентификация API: здесь только ИМЕНА переменных окружения, значения
|
||||
# (статические bearer-токены) задаются окружением процесса — см.
|
||||
# deploy/systemd/control-api.service (EnvironmentFile=). Генерация:
|
||||
# openssl rand -hex 32
|
||||
# Пустой/незаданный токен оставляет соответствующий уровень ОТКРЫТЫМ
|
||||
# (в логе при старте предупреждение) — для обратной совместимости.
|
||||
auth:
|
||||
# Защищает все /api/v1/admin/* (его использует дашборд и оператор: curl -H
|
||||
# "Authorization: Bearer $TOKEN").
|
||||
admin_token_env: "CONTROL_API_ADMIN_TOKEN"
|
||||
# Защищает запись результатов/событий: POST /agents/{id}/self-check|events|
|
||||
# results|complete и POST /probers/{site_id}/results. Один общий токен для
|
||||
# validator-agent и prober. register/heartbeat/получение задания остаются
|
||||
# открытыми.
|
||||
agent_token_env: "CONTROL_API_AGENT_TOKEN"
|
||||
|
||||
orchestrator:
|
||||
poll_interval_seconds: 5
|
||||
self_check_timeout_seconds: 60
|
||||
@@ -60,6 +90,10 @@ orchestrator:
|
||||
# scan on demand via POST /api/v1/admin/ips/scan or the dashboard's
|
||||
# "Scan Floating IPs" button.
|
||||
fip_scan_interval_seconds: 0
|
||||
# Общий таймаут одного фонового скана Floating IP (очистка + чтение всех
|
||||
# страниц + постановка в очередь), секунд. Default 1800.
|
||||
# Overall deadline of one background floating-IP scan. Default 1800.
|
||||
fip_scan_timeout_seconds: 1800
|
||||
|
||||
aggregation:
|
||||
missing_counts_as_fail: true
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
site_id: "site-1"
|
||||
control_api_url: "http://control-api.internal:8080"
|
||||
# Имя переменной окружения с токеном агентов control-api (тот же, что у
|
||||
# validator-agent; CONTROL_API_AGENT_TOKEN на стороне control-api). Нужен для
|
||||
# записи результатов; register/heartbeat/получение заданий работают без него.
|
||||
control_api_token_env: "CONTROL_API_AGENT_TOKEN"
|
||||
poll_interval_seconds: 5
|
||||
|
||||
checks:
|
||||
|
||||
@@ -3,11 +3,29 @@
|
||||
|
||||
validator_id: "validator_01"
|
||||
control_api_url: "http://control-api.internal:8080"
|
||||
# Имя переменной окружения с токеном агентов control-api (тот же, что у
|
||||
# prober; CONTROL_API_AGENT_TOKEN на стороне control-api). Нужен для записи
|
||||
# результатов/событий; register/heartbeat/получение задания работают без него.
|
||||
# Токен отправляется только в control-api — не на ip_echo_urls и не на цели
|
||||
# проверок.
|
||||
control_api_token_env: "CONTROL_API_AGENT_TOKEN"
|
||||
poll_interval_seconds: 5
|
||||
|
||||
self_check:
|
||||
timeout_seconds: 10
|
||||
# Must be a resource genuinely outside the cloud project — OpenStack only
|
||||
# Способы самопроверки в порядке приоритета (допустимо: ip_echo,
|
||||
# control_api); по умолчанию [ip_echo]. Самопроверка успешна, если адрес
|
||||
# подтвердил любой способ: пробуются по порядку, остановка на первом
|
||||
# успешном, к следующему переходим и при отсутствии ответа, и при
|
||||
# несовпадении адреса. Таймаут timeout_seconds действует на каждый способ
|
||||
# отдельно (зависший первый способ не лишает второй времени). control_api спрашивает у control-api, с какого адреса он видит
|
||||
# это соединение; рекомендуется [control_api, ip_echo], когда control-api
|
||||
# стоит вне облака и валидатор ходит к нему напрямую (через внешнюю сеть).
|
||||
# Ограничение: если control-api достижим по внутренней сети облака, он
|
||||
# увидит частный адрес валидатора и control_api всегда даст несовпадение —
|
||||
# тогда оставьте только ip_echo (или он сработает вторым в списке).
|
||||
methods: [control_api, ip_echo]
|
||||
# Used by the ip_echo method. Must be a resource genuinely outside the cloud project — OpenStack only
|
||||
# applies floating-IP SNAT to traffic leaving via the external network,
|
||||
# so anything reachable over the project's internal network (including
|
||||
# control-api itself, if it's on the same internal network) would report
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Доставка validator-agent на валидаторы (Ansible)
|
||||
|
||||
Сценарий запускается с jump-хоста и на каждой ВМ-валидаторе: обновляет git-клон в `/opt/cloud-ip-validator`,
|
||||
**собирает образ на самом хосте**, останавливает и удаляет старый контейнер `validator-agent` (образ — `cloud-ip-validator-validator-agent`)
|
||||
и поднимает на его месте новый. Параметры запуска агента вынесены в env-файл.
|
||||
|
||||
Порядок безопасен: сначала проверки, обновление кода и сборка образа, и только потом замена контейнера. Если что-то
|
||||
упало до замены, старый контейнер продолжает работать. Простой валидатора — секунды (`stop` + `rm` + `run`).
|
||||
|
||||
## Требования
|
||||
|
||||
| Где | Что |
|
||||
|---|---|
|
||||
| jump-хост (Debian 13) | `apt install ansible-core`; SSH-доступ по ключу ко всем валидаторам |
|
||||
| валидаторы | Docker, git, клон репозитория в `/opt/cloud-ip-validator`, `sudo` без пароля для SSH-пользователя, доступ к Gitea и Docker Hub (`alpine:3.20`), архитектура x86_64 |
|
||||
|
||||
Только модули `ansible.builtin`: Python Docker SDK и дополнительные коллекции на валидаторах не нужны.
|
||||
Go на валидаторах не нужен: образ копирует закоммиченный `bin/validator-agent` (его сумма проверяется по `bin/SHA256SUMS`).
|
||||
|
||||
## Подготовка (один раз)
|
||||
|
||||
```bash
|
||||
cd deploy/ansible
|
||||
cp env/validator-agent.env.example env/validator-agent.env
|
||||
chmod 600 env/validator-agent.env
|
||||
$EDITOR env/validator-agent.env # адрес control-api, токен, способы самопроверки, таймауты
|
||||
ansible validators -m ping # проверка связи (20 хостов: validator-1 ... validator-20)
|
||||
```
|
||||
|
||||
- В `inventory/hosts.yml` 20 хостов `validator-1 … validator-20` с адресами (`ansible_host`) и переменной `validator_id`
|
||||
(`vkiplab-v1 … vkiplab-v20`, как в control-api). Соответствие `validator-N` → `vkiplab-vN` задано по порядку номеров;
|
||||
**сценарий сверяет `validator_id` с тем, что записано в работающем контейнере на хосте, и при расхождении останавливается до замены
|
||||
контейнера**. `VALIDATOR_AGENT_VALIDATOR_ID` в env-файл писать не нужно: сценарий подставляет его из inventory.
|
||||
- Отпечатки хостов запоминаются при первом подключении (`StrictHostKeyChecking=accept-new` в `ansible.cfg`); сменившийся отпечаток известного хоста — ошибка.
|
||||
- SSH: пользователь `debian` и ключ `~/.ssh/vk_cloud_priv.key` на jump-хосте (права 0600) — одинаковые на всех хостах; меняются в
|
||||
`inventory/group_vars/validators.yml` (`ansible_user`, `ansible_ssh_private_key_file`). Для docker и записи env-файла сценарий
|
||||
повышает права через `sudo`.
|
||||
- Токен в env-файле можно зашифровать: `ansible-vault encrypt env/validator-agent.env`, запускать с `--ask-vault-pass`.
|
||||
Рабочий `env/validator-agent.env` в git не попадает (`.gitignore`).
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cd deploy/ansible
|
||||
ansible-playbook playbooks/deploy-validator-agent.yml --limit vkiplab-v1 # канарейка: один валидатор
|
||||
ansible-playbook playbooks/deploy-validator-agent.yml # все валидаторы волнами
|
||||
ansible-playbook playbooks/deploy-validator-agent.yml --check # только проверки (preflight), без изменений
|
||||
```
|
||||
|
||||
Волны по умолчанию: 1 хост, затем 4, затем все остальные (`deploy_serial: [1, 4, "100%"]`). Любой сбой в волне останавливает
|
||||
прогон: следующая волна не начнётся. Все сразу: `-e '{"deploy_serial": ["100%"]}'`.
|
||||
|
||||
Что выкатывается — `deploy_ref` (по умолчанию `main`): ветка, тег или коммит. Выкатить нужно **запушенный** коммит:
|
||||
валидаторы берут код из репозитория, а не с jump-хоста. После правок кода сначала пересоберите `bin/validator-agent`
|
||||
(см. [SETUP.md](../../docs/SETUP.md#обновление-образов-после-изменения-кода)) и закоммитьте его вместе с `bin/SHA256SUMS`.
|
||||
|
||||
### Откат
|
||||
|
||||
```bash
|
||||
ansible-playbook playbooks/deploy-validator-agent.yml -e deploy_ref=<предыдущий коммит или тег>
|
||||
```
|
||||
|
||||
Для тега или коммита клон переходит в detached HEAD; следующий запуск с `deploy_ref=main` возвращает его на ветку.
|
||||
|
||||
## Что делает сценарий на каждом хосте
|
||||
|
||||
1. **preflight** — env-файл на jump-хосте существует и в нём задан `VALIDATOR_AGENT_CONTROL_API_URL` (адрес-пример
|
||||
`example.com` не принимается); на валидаторе отвечает Docker, есть git и клон, архитектура x86_64; `validator_id` из inventory совпадает
|
||||
с `validator_id` работающего контейнера; на хосте нет другого контейнера этого агента (по имени или образу) — иначе рядом со старым
|
||||
запустился бы второй с тем же `validator_id`. Показывает текущий контейнер.
|
||||
2. **git** — от имени `git_user` (на валидаторах `root`: клон принадлежит ему) `fetch`, затем клон сбрасывается на `deploy_ref` (`checkout --force`). Через `git`, а не модуль `git`:
|
||||
учётные данные, уже настроенные в клоне, не трогаются. Локальные правки отслеживаемых файлов в клоне будут сброшены;
|
||||
неотслеживаемые и игнорируемые (`.env.*`) — нет.
|
||||
3. **build** — проверка `bin/validator-agent` по `bin/SHA256SUMS`; `docker build --platform linux/amd64` с контекстом в корне репозитория,
|
||||
образ получает метку ревизии (`cloud-ip-validator-validator-agent:<хеш>`) и `latest`.
|
||||
4. **replace** — env-файл копируется в `/opt/cloud-ip-validator/deploy/docker/.env.validator` (0600; путь закрыт `.gitignore`),
|
||||
затем `docker stop` → `docker rm -f` → `docker run -d --restart unless-stopped --cap-add NET_RAW --env-file …`.
|
||||
5. **verify** — ждёт строку `registered` в логе агента (регистрация в control-api), проверяет, что контейнер запущен, без перезапусков
|
||||
и на только что собранном образе. При неудаче хост падает с последними строками лога, следующие волны не стартуют.
|
||||
Затем остаются 3 последних образа с метками ревизий (`keep_images`), остальные и «висячие» удаляются.
|
||||
|
||||
Все шаги можно запускать по тегам: `--tags preflight|git|build|replace|verify`.
|
||||
|
||||
## Параметры
|
||||
|
||||
**`env/validator-agent.env`** — запуск агента (читает `deploy/docker/validator-agent/docker-entrypoint.sh`):
|
||||
`VALIDATOR_AGENT_CONTROL_API_URL`, `CONTROL_API_AGENT_TOKEN`, `VALIDATOR_AGENT_SELF_CHECK_METHODS`,
|
||||
`VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS`, `VALIDATOR_AGENT_POLL_INTERVAL_SECONDS`, `VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS`,
|
||||
`VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS`, `VALIDATOR_AGENT_ICMP_COUNT`, `VALIDATOR_AGENT_SSH_ENABLED`, `VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS`.
|
||||
Смена только env-файла не требует изменения кода: достаточно запустить сценарий (контейнер пересоздаётся с новыми значениями).
|
||||
|
||||
**`inventory/group_vars/validators.yml`** — доставка: `repo_dir`, `repo_remote`, `deploy_ref`, `git_user` (пользователь, у которого в клоне
|
||||
настроен доступ к репозиторию; пусто — SSH-пользователь), `image_name`, `container_name`, `platform`, `keep_images`, `restart_policy`,
|
||||
`capabilities`, `log_max_size`, `log_max_file`, `stop_timeout`, `verify_retries`, `verify_delay`, `local_env_file`. Любой параметр
|
||||
переопределяется ключом `-e`.
|
||||
|
||||
## Разбор сбоев
|
||||
|
||||
- **«Нет env-файла» / «не задан VALIDATOR_AGENT_CONTROL_API_URL»** — см. «Подготовка».
|
||||
- **«в запущенном контейнере validator_id=…, а в inventory …»** — соответствие `validator-N` и `validator_id` в `inventory/hosts.yml`
|
||||
неверно для этого хоста: исправьте inventory (контейнер при этом не тронут).
|
||||
- **Контейнер не прошёл проверку** — в сообщении последние строки лога. `registration failed` — control-api недоступен с валидатора
|
||||
или `validator_id` не заведён в control-api. Контейнер остаётся на хосте для разбора (`docker logs`).
|
||||
- **`Permission denied` на `.git/FETCH_HEAD`, `dubious ownership`, `could not read Username`** — git запущен не от владельца клона или
|
||||
учётные данные есть у другого пользователя: задайте `git_user` (на валидаторах — `root`).
|
||||
- **«найден другой контейнер агента»** — на хосте есть контейнер с похожим именем или образом, не совпадающий с `container_name`:
|
||||
проверьте имя (`docker ps -a`) и при необходимости удалите лишний контейнер вручную.
|
||||
- **`bin/validator-agent` не совпадает с суммой** — в репозитории устарел `bin/SHA256SUMS`: пересоберите бинарник и обновите сумму.
|
||||
- **Сборка падает на `FROM alpine:3.20` / `apk add`** — с валидатора нет доступа к Docker Hub / репозиториям Alpine.
|
||||
- **Старый образ другого имени остаётся** — сценарий чистит только образы `image_name`; образ с прежним именем удалите вручную (`docker rmi`).
|
||||
@@ -0,0 +1,15 @@
|
||||
# Запускать из каталога deploy/ansible (там же лежит этот файл).
|
||||
[defaults]
|
||||
inventory = inventory/hosts.yml
|
||||
roles_path = roles
|
||||
forks = 20
|
||||
retry_files_enabled = False
|
||||
interpreter_python = auto_silent
|
||||
callback_result_format = yaml
|
||||
|
||||
[ssh_connection]
|
||||
pipelining = True
|
||||
# accept-new: отпечаток нового хоста запоминается при первом подключении (без
|
||||
# интерактивного вопроса); изменившийся отпечаток известного хоста по-прежнему
|
||||
# приводит к ошибке.
|
||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Параметры запуска validator-agent. Скопируйте в validator-agent.env и заполните:
|
||||
# cp validator-agent.env.example validator-agent.env && chmod 600 validator-agent.env
|
||||
# Файл передаётся контейнеру как `docker run --env-file` (формат KEY=VALUE, без
|
||||
# кавычек и пробелов вокруг "="). Читает их deploy/docker/validator-agent/docker-entrypoint.sh.
|
||||
# VALIDATOR_AGENT_VALIDATOR_ID сюда НЕ пишется: сценарий подставляет имя хоста.
|
||||
|
||||
# Адрес control-api (обязательно). Для внешнего размещения — адрес, доступный
|
||||
# с валидаторов напрямую (через него же работает способ самопроверки control_api).
|
||||
VALIDATOR_AGENT_CONTROL_API_URL=https://control-api.example.com
|
||||
|
||||
# Токен агентов (CONTROL_API_AGENT_TOKEN на стороне control-api). Пусто — если
|
||||
# токен на control-api ещё не включён.
|
||||
CONTROL_API_AGENT_TOKEN=
|
||||
|
||||
# Способы самопроверки в порядке приоритета: ip_echo, control_api.
|
||||
# Самопроверка проходит, если адрес подтвердил любой способ. Без пробелов.
|
||||
VALIDATOR_AGENT_SELF_CHECK_METHODS=[control_api,ip_echo]
|
||||
# Таймаут одного способа, секунд.
|
||||
VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS=10
|
||||
|
||||
# Период опроса control-api, секунд.
|
||||
VALIDATOR_AGENT_POLL_INTERVAL_SECONDS=5
|
||||
|
||||
# Исходящие проверки.
|
||||
VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS=10
|
||||
VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS=5
|
||||
VALIDATOR_AGENT_ICMP_COUNT=3
|
||||
VALIDATOR_AGENT_SSH_ENABLED=false
|
||||
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS=5
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
# Параметры доставки validator-agent (не секреты). Любой из них можно
|
||||
# переопределить в командной строке: -e deploy_ref=<коммит|тег>.
|
||||
# Параметры запуска самого агента (адрес control-api, токен, способы
|
||||
# самопроверки, таймауты) лежат в env-файле, см. local_env_file.
|
||||
|
||||
# SSH: пользователь и ключ одинаковы на jump-хосте и на валидаторах. Путь к
|
||||
# ключу — на jump-хосте (ключ с правами 0600). Для docker и записи env-файла
|
||||
# сценарий повышает права через sudo (become).
|
||||
ansible_user: debian
|
||||
ansible_ssh_private_key_file: ~/.ssh/vk_cloud_priv.key
|
||||
|
||||
# --- git-клон на валидаторе ---------------------------------------------
|
||||
repo_dir: /opt/cloud-ip-validator
|
||||
repo_remote: origin
|
||||
# Ветка, тег или коммит, который нужно выкатить (откат: -e deploy_ref=<коммит>).
|
||||
deploy_ref: main
|
||||
# Пользователь, от имени которого выполняется git в клоне (через sudo): владелец
|
||||
# клона. На валидаторах клон принадлежит root, репозиторий читается без учётных
|
||||
# данных. Пусто — git работает от SSH-пользователя без sudo.
|
||||
git_user: root
|
||||
|
||||
# --- образ и контейнер --------------------------------------------------
|
||||
image_name: cloud-ip-validator-validator-agent
|
||||
# Имя контейнера на валидаторах (имя образа — другое, см. image_name).
|
||||
container_name: validator-agent
|
||||
platform: linux/amd64
|
||||
dockerfile: deploy/docker/validator-agent/Dockerfile
|
||||
# Сколько образов с метками ревизий хранить (кроме latest); старые удаляются.
|
||||
keep_images: 3
|
||||
|
||||
# --- запуск контейнера (как в deploy/docker/RUN.txt) --------------------
|
||||
restart_policy: unless-stopped
|
||||
capabilities: [NET_RAW]
|
||||
log_max_size: 10m
|
||||
log_max_file: "3"
|
||||
stop_timeout: 10
|
||||
|
||||
# --- проверка после запуска ---------------------------------------------
|
||||
verify_retries: 10
|
||||
verify_delay: 3
|
||||
|
||||
# --- env-файл на jump-хосте ---------------------------------------------
|
||||
# Параметры запуска агента. Рабочий файл создаётся из validator-agent.env.example
|
||||
# и в git не попадает. Файл можно зашифровать: ansible-vault encrypt <файл>
|
||||
# (тогда запускайте с --ask-vault-pass или --vault-password-file).
|
||||
local_env_file: "{{ playbook_dir }}/../env/validator-agent.env"
|
||||
# Куда файл копируется на валидатор (путь закрыт .gitignore репозитория,
|
||||
# переживает git reset).
|
||||
remote_env_file: "{{ repo_dir }}/deploy/docker/.env.validator"
|
||||
@@ -0,0 +1,69 @@
|
||||
# Валидаторы. Имя хоста (validator-N) — это имя ВМ; validator_id в control-api
|
||||
# другой (vkiplab-vN) и задаётся переменной validator_id. Соответствие
|
||||
# validator-N -> vkiplab-vN предполагается по порядку номеров; сценарий
|
||||
# сверяет validator_id с тем, что записано в работающем контейнере на хосте,
|
||||
# и при расхождении останавливается ДО замены контейнера.
|
||||
all:
|
||||
children:
|
||||
validators:
|
||||
hosts:
|
||||
validator-1:
|
||||
ansible_host: 10.11.12.161
|
||||
validator_id: vkiplab-v1
|
||||
validator-2:
|
||||
ansible_host: 10.11.12.177
|
||||
validator_id: vkiplab-v2
|
||||
validator-3:
|
||||
ansible_host: 10.11.12.33
|
||||
validator_id: vkiplab-v3
|
||||
validator-4:
|
||||
ansible_host: 10.11.12.41
|
||||
validator_id: vkiplab-v4
|
||||
validator-5:
|
||||
ansible_host: 10.11.12.193
|
||||
validator_id: vkiplab-v5
|
||||
validator-6:
|
||||
ansible_host: 10.11.12.197
|
||||
validator_id: vkiplab-v6
|
||||
validator-7:
|
||||
ansible_host: 10.11.12.198
|
||||
validator_id: vkiplab-v7
|
||||
validator-8:
|
||||
ansible_host: 10.11.12.169
|
||||
validator_id: vkiplab-v8
|
||||
validator-9:
|
||||
ansible_host: 10.11.12.185
|
||||
validator_id: vkiplab-v9
|
||||
validator-10:
|
||||
ansible_host: 10.11.12.186
|
||||
validator_id: vkiplab-v10
|
||||
validator-11:
|
||||
ansible_host: 10.11.12.199
|
||||
validator_id: vkiplab-v11
|
||||
validator-12:
|
||||
ansible_host: 10.11.12.196
|
||||
validator_id: vkiplab-v12
|
||||
validator-13:
|
||||
ansible_host: 10.11.12.194
|
||||
validator_id: vkiplab-v13
|
||||
validator-14:
|
||||
ansible_host: 10.11.12.195
|
||||
validator_id: vkiplab-v14
|
||||
validator-15:
|
||||
ansible_host: 10.11.12.65
|
||||
validator_id: vkiplab-v15
|
||||
validator-16:
|
||||
ansible_host: 10.11.12.189
|
||||
validator_id: vkiplab-v16
|
||||
validator-17:
|
||||
ansible_host: 10.11.12.190
|
||||
validator_id: vkiplab-v17
|
||||
validator-18:
|
||||
ansible_host: 10.11.12.168
|
||||
validator_id: vkiplab-v18
|
||||
validator-19:
|
||||
ansible_host: 10.11.12.191
|
||||
validator_id: vkiplab-v19
|
||||
validator-20:
|
||||
ansible_host: 10.11.12.73
|
||||
validator_id: vkiplab-v20
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
# Доставка validator-agent на все валидаторы: обновить git-клон, собрать образ
|
||||
# на каждом хосте, остановить и удалить старый контейнер, поднять новый.
|
||||
# Запуск (из deploy/ansible): ansible-playbook playbooks/deploy-validator-agent.yml
|
||||
- name: Deliver validator-agent to the validators
|
||||
hosts: validators
|
||||
become: true
|
||||
gather_facts: false
|
||||
# Волны: один хост (канарейка), затем четыре, затем все остальные. Любой
|
||||
# сбой останавливает прогон — следующая волна не начнётся.
|
||||
# Все сразу: -e '{"deploy_serial": ["100%"]}'.
|
||||
serial: "{{ deploy_serial }}"
|
||||
max_fail_percentage: 0
|
||||
vars:
|
||||
deploy_serial: [1, 4, "100%"]
|
||||
roles:
|
||||
- validator_agent
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
# Dockerfile ничего не компилирует: в образ копируется закоммиченный
|
||||
# bin/validator-agent. Не даём выкатить бинарник, не совпадающий с суммой.
|
||||
- name: Check bin/validator-agent against SHA256SUMS
|
||||
ansible.builtin.shell: |
|
||||
set -o pipefail
|
||||
grep -E '[[:space:]]validator-agent$' SHA256SUMS | sha256sum -c -
|
||||
args:
|
||||
chdir: "{{ repo_dir }}/bin"
|
||||
executable: /bin/bash
|
||||
changed_when: false
|
||||
|
||||
# Контекст сборки — корень репозитория (так и в SETUP.md). Слои кэшируются,
|
||||
# при смене bin/ образ пересобирается сам.
|
||||
- name: Build the image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- docker
|
||||
- build
|
||||
- --platform
|
||||
- "{{ platform }}"
|
||||
- --label
|
||||
- "git.rev={{ rev_after.stdout }}"
|
||||
- --label
|
||||
- deployed.by=ansible
|
||||
- -t
|
||||
- "{{ image_ref }}"
|
||||
- -f
|
||||
- "{{ dockerfile }}"
|
||||
- .
|
||||
chdir: "{{ repo_dir }}"
|
||||
|
||||
- name: Tag the image as latest
|
||||
ansible.builtin.command: "docker tag {{ image_ref }} {{ image_name }}:latest"
|
||||
|
||||
- name: Read the image id
|
||||
ansible.builtin.command:
|
||||
argv: [docker, image, inspect, --format, "{% raw %}{{.Id}}{% endraw %}", "{{ image_ref }}"]
|
||||
changed_when: false
|
||||
register: built_image
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
# Команды git вместо модуля git: модуль переписывает URL remote и может
|
||||
# затереть учётные данные, уже настроенные в клоне. Работаем от git_user
|
||||
# (или от SSH-пользователя, если он не задан).
|
||||
- name: Remember the current revision
|
||||
ansible.builtin.command: "{{ git_cmd }} rev-parse HEAD"
|
||||
become: "{{ git_user | length > 0 }}"
|
||||
become_user: "{{ git_user }}"
|
||||
changed_when: false
|
||||
register: rev_before
|
||||
|
||||
- name: Fetch the remote
|
||||
ansible.builtin.command: "{{ git_cmd }} fetch --prune --tags {{ repo_remote }}"
|
||||
become: "{{ git_user | length > 0 }}"
|
||||
become_user: "{{ git_user }}"
|
||||
changed_when: false
|
||||
|
||||
# deploy_ref — ветка, тег или коммит. Ветка берётся из remote (свежая),
|
||||
# тег и коммит — как есть.
|
||||
- name: Resolve deploy_ref as a remote branch
|
||||
ansible.builtin.command: >-
|
||||
{{ git_cmd }} rev-parse --verify --quiet
|
||||
refs/remotes/{{ repo_remote }}/{{ deploy_ref }}^{commit}
|
||||
become: "{{ git_user | length > 0 }}"
|
||||
become_user: "{{ git_user }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
register: ref_branch
|
||||
|
||||
- name: Resolve deploy_ref as a tag or commit
|
||||
ansible.builtin.command: "{{ git_cmd }} rev-parse --verify --quiet {{ deploy_ref }}^{commit}"
|
||||
become: "{{ git_user | length > 0 }}"
|
||||
become_user: "{{ git_user }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
register: ref_other
|
||||
when: ref_branch.rc != 0
|
||||
|
||||
- name: Fail if deploy_ref does not exist
|
||||
ansible.builtin.assert:
|
||||
that: ref_branch.rc == 0 or (ref_other.rc | default(1)) == 0
|
||||
fail_msg: "deploy_ref={{ deploy_ref }} не найден в {{ repo_dir }} ({{ repo_remote }})."
|
||||
quiet: true
|
||||
|
||||
- name: Fix the target revision
|
||||
ansible.builtin.set_fact:
|
||||
target_rev: "{{ ref_branch.stdout if ref_branch.rc == 0 else ref_other.stdout }}"
|
||||
|
||||
# Ветка: остаёмся на локальной ветке (клон не уходит в detached HEAD),
|
||||
# сброс на remote. Тег или коммит: detached HEAD.
|
||||
- name: Check out the branch
|
||||
ansible.builtin.command: "{{ git_cmd }} checkout --force -B {{ deploy_ref }} {{ target_rev }}"
|
||||
become: "{{ git_user | length > 0 }}"
|
||||
become_user: "{{ git_user }}"
|
||||
when: ref_branch.rc == 0
|
||||
changed_when: rev_before.stdout != target_rev
|
||||
|
||||
- name: Check out the tag or commit
|
||||
ansible.builtin.command: "{{ git_cmd }} checkout --force --detach {{ target_rev }}"
|
||||
become: "{{ git_user | length > 0 }}"
|
||||
become_user: "{{ git_user }}"
|
||||
when: ref_branch.rc != 0
|
||||
changed_when: rev_before.stdout != target_rev
|
||||
|
||||
- name: Read the deployed revision
|
||||
ansible.builtin.command: "{{ git_cmd }} rev-parse HEAD"
|
||||
become: "{{ git_user | length > 0 }}"
|
||||
become_user: "{{ git_user }}"
|
||||
changed_when: false
|
||||
register: rev_after
|
||||
|
||||
- name: Check that the clone is at the target revision
|
||||
ansible.builtin.assert:
|
||||
that: rev_after.stdout == target_rev
|
||||
fail_msg: "Клон на {{ rev_after.stdout }}, ожидалось {{ target_rev }}."
|
||||
quiet: true
|
||||
|
||||
- name: Remember the short revision
|
||||
ansible.builtin.set_fact:
|
||||
deploy_rev: "{{ rev_after.stdout[:12] }}"
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
# Порядок важен: сначала всё, что не трогает работающий контейнер (проверки,
|
||||
# обновление кода, сборка образа), и только потом замена контейнера. Если
|
||||
# что-то упало до replace, старый контейнер продолжает работать.
|
||||
- name: Preflight checks
|
||||
ansible.builtin.import_tasks: preflight.yml
|
||||
tags: [preflight]
|
||||
|
||||
- name: Dry run stops after preflight
|
||||
ansible.builtin.debug:
|
||||
msg: "check mode: git, build, replace and verify are skipped"
|
||||
when: ansible_check_mode
|
||||
tags: [always]
|
||||
|
||||
- name: Update the git clone
|
||||
ansible.builtin.import_tasks: git.yml
|
||||
when: not ansible_check_mode
|
||||
tags: [git]
|
||||
|
||||
- name: Build the image
|
||||
ansible.builtin.import_tasks: build.yml
|
||||
when: not ansible_check_mode
|
||||
tags: [build]
|
||||
|
||||
- name: Replace the container
|
||||
ansible.builtin.import_tasks: replace.yml
|
||||
when: not ansible_check_mode
|
||||
tags: [replace]
|
||||
|
||||
- name: Verify the new container
|
||||
ansible.builtin.import_tasks: verify.yml
|
||||
when: not ansible_check_mode
|
||||
tags: [verify]
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
# --- на jump-хосте (один раз) -------------------------------------------
|
||||
- name: Check that the env file exists on the jump host
|
||||
ansible.builtin.stat:
|
||||
path: "{{ local_env_file }}"
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
run_once: true
|
||||
check_mode: false
|
||||
register: env_file_stat
|
||||
|
||||
- name: Fail early without an env file
|
||||
ansible.builtin.assert:
|
||||
that: env_file_stat.stat.exists
|
||||
fail_msg: >-
|
||||
Нет env-файла {{ local_env_file }}. Создайте его:
|
||||
cp env/validator-agent.env.example env/validator-agent.env и заполните.
|
||||
quiet: true
|
||||
run_once: true
|
||||
|
||||
# Содержимое файла (в нём токен) не выводится: разбор идёт в задаче с no_log,
|
||||
# а проверка и её сообщение — по готовым булевым значениям.
|
||||
- name: Inspect the env file without printing it
|
||||
ansible.builtin.set_fact:
|
||||
env_url_set: "{{ env_file_text is regex('(?m)^VALIDATOR_AGENT_CONTROL_API_URL=\\S+') }}"
|
||||
env_url_is_example: "{{ env_file_text is regex('(?m)^VALIDATOR_AGENT_CONTROL_API_URL=\\S*example\\.com') }}"
|
||||
vars:
|
||||
env_file_text: "{{ lookup('ansible.builtin.file', local_env_file) }}"
|
||||
run_once: true
|
||||
no_log: true
|
||||
|
||||
- name: Check that the env file sets the control-api address
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- env_url_set | bool
|
||||
- not (env_url_is_example | bool)
|
||||
fail_msg: >-
|
||||
В {{ local_env_file }} не задан VALIDATOR_AGENT_CONTROL_API_URL
|
||||
(или остался адрес-пример example.com).
|
||||
quiet: true
|
||||
run_once: true
|
||||
|
||||
# --- на каждом валидаторе -----------------------------------------------
|
||||
- name: Check that Docker answers
|
||||
ansible.builtin.command: docker version --format {% raw %}'{{.Server.Version}}'{% endraw %}
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Check that git is installed
|
||||
ansible.builtin.command: git --version
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Check that the git clone exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ repo_dir }}/.git"
|
||||
check_mode: false
|
||||
register: clone_stat
|
||||
|
||||
- name: Fail without a clone
|
||||
ansible.builtin.assert:
|
||||
that: clone_stat.stat.exists
|
||||
fail_msg: "Нет git-клона {{ repo_dir }} на {{ inventory_hostname }}."
|
||||
quiet: true
|
||||
|
||||
- name: Read the CPU architecture
|
||||
ansible.builtin.command: uname -m
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
register: arch
|
||||
|
||||
- name: The image is linux/amd64 only
|
||||
ansible.builtin.assert:
|
||||
that: arch.stdout in ['x86_64', 'amd64']
|
||||
fail_msg: "Архитектура {{ arch.stdout }}: образ {{ platform }} здесь не запустится (exec format error)."
|
||||
quiet: true
|
||||
|
||||
- name: Look at the current container
|
||||
ansible.builtin.command: >-
|
||||
docker container inspect --format
|
||||
{% raw %}'{{.Config.Image}} {{.State.Status}}'{% endraw %}
|
||||
{{ container_name }}
|
||||
register: current_container
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
# Защита от второго агента: если на хосте уже есть другой контейнер этого
|
||||
# агента (по имени или по образу), сценарий остановится, а не запустит
|
||||
# рядом ещё один с тем же validator_id.
|
||||
- name: List containers on the validator
|
||||
ansible.builtin.command: docker ps -a --format {% raw %}'{{.Names}}|{{.Image}}'{% endraw %}
|
||||
register: all_containers
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Check that there is no other agent container
|
||||
ansible.builtin.assert:
|
||||
that: (other_agents | from_json) | length == 0
|
||||
fail_msg: >-
|
||||
{{ inventory_hostname }}: найден другой контейнер агента: {{ (other_agents | from_json) | join(', ') }}.
|
||||
Сценарий заменяет только контейнер {{ container_name }}. Проверьте container_name в
|
||||
inventory/group_vars/validators.yml или удалите лишний контейнер вручную.
|
||||
quiet: true
|
||||
vars:
|
||||
other_agents: >-
|
||||
{%- set found = [] -%}
|
||||
{%- for line in all_containers.stdout_lines -%}
|
||||
{%- set row = line.split('|') -%}
|
||||
{%- if row[0] != container_name and ('validator-agent' in row[0] or row[1] == image_name or row[1].startswith(image_name ~ ':')) -%}
|
||||
{%- set _ = found.append(row[0] ~ ' (' ~ row[1] ~ ')') -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- found | to_json -}}
|
||||
|
||||
# validator_id работающего контейнера — эталон: если он отличается от
|
||||
# inventory, заменять контейнер нельзя (агент зарегистрировался бы под чужим
|
||||
# именем, адреса привязывались бы к порту другой ВМ). Выводится только он,
|
||||
# а не все переменные окружения (там токен).
|
||||
- name: Read validator_id of the running container
|
||||
ansible.builtin.shell: |
|
||||
set -o pipefail
|
||||
docker container inspect --format '{% raw %}{{range .Config.Env}}{{println .}}{{end}}{% endraw %}' {{ container_name }} \
|
||||
| sed -n 's/^VALIDATOR_AGENT_VALIDATOR_ID=//p'
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: running_validator_id
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
when: current_container.rc == 0
|
||||
|
||||
- name: Check validator_id against the running container
|
||||
ansible.builtin.assert:
|
||||
that: >-
|
||||
current_container.rc != 0
|
||||
or (running_validator_id.stdout | trim) == ''
|
||||
or (running_validator_id.stdout | trim) == effective_validator_id
|
||||
fail_msg: >-
|
||||
{{ inventory_hostname }}: в запущенном контейнере validator_id={{ running_validator_id.stdout | default('') | trim }},
|
||||
а в inventory {{ effective_validator_id }}. Проверьте соответствие имени ВМ и validator_id
|
||||
в inventory/hosts.yml; контейнер не тронут.
|
||||
quiet: true
|
||||
|
||||
- name: Report the current container
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ container_name }}:
|
||||
{{ current_container.stdout if current_container.rc == 0 else 'контейнера нет (будет создан)' }};
|
||||
validator_id для запуска: {{ effective_validator_id }}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
# Образ уже собран: простой валидатора — только stop + rm + run.
|
||||
- name: Copy the env file to the validator
|
||||
ansible.builtin.copy:
|
||||
src: "{{ local_env_file }}"
|
||||
dest: "{{ remote_env_file }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
|
||||
- name: Check whether the container exists
|
||||
ansible.builtin.command: "docker container inspect {{ container_name }}"
|
||||
register: container_exists
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Stop the current container
|
||||
ansible.builtin.command: "docker stop -t {{ stop_timeout }} {{ container_name }}"
|
||||
when: container_exists.rc == 0
|
||||
|
||||
- name: Remove the current container
|
||||
ansible.builtin.command: "docker rm -f {{ container_name }}"
|
||||
when: container_exists.rc == 0
|
||||
|
||||
# --restart нужен: агент завершается, если регистрация в control-api не
|
||||
# удалась, и должен подняться снова. validator_id берётся из inventory
|
||||
# (validator_id) и перекрывает env-файл.
|
||||
- name: Start the new container
|
||||
ansible.builtin.command:
|
||||
argv: >-
|
||||
{{ ['docker', 'run', '-d',
|
||||
'--name', container_name,
|
||||
'--restart', restart_policy,
|
||||
'--platform', platform,
|
||||
'--env-file', remote_env_file,
|
||||
'-e', 'VALIDATOR_AGENT_VALIDATOR_ID=' ~ effective_validator_id,
|
||||
'--log-driver', 'json-file',
|
||||
'--log-opt', 'max-size=' ~ log_max_size,
|
||||
'--log-opt', 'max-file=' ~ log_max_file,
|
||||
'--label', 'git.rev=' ~ rev_after.stdout,
|
||||
'--label', 'deployed.by=ansible']
|
||||
+ (capabilities | map('regex_replace', '^(.*)$', '--cap-add=\1') | list)
|
||||
+ [image_ref] }}
|
||||
register: started
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
- name: Verify the new container
|
||||
block:
|
||||
# Агент пишет "registered" после успешной регистрации в control-api.
|
||||
- name: Wait for the agent to register in control-api
|
||||
ansible.builtin.command: "docker logs --tail 200 {{ container_name }}"
|
||||
register: agent_logs
|
||||
changed_when: false
|
||||
until: agent_logs.stdout is search('msg=registered validator_id=' ~ effective_validator_id ~ '(\s|$)') or agent_logs.stderr is search('msg=registered validator_id=' ~ effective_validator_id ~ '(\s|$)')
|
||||
retries: "{{ verify_retries | int }}"
|
||||
delay: "{{ verify_delay | int }}"
|
||||
|
||||
- name: Inspect the container
|
||||
ansible.builtin.command:
|
||||
argv: [docker, inspect, --format, "{% raw %}{{.State.Running}} {{.RestartCount}} {{.Image}}{% endraw %}", "{{ container_name }}"]
|
||||
register: container_state
|
||||
changed_when: false
|
||||
|
||||
- name: Check the container state
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- container_state.stdout.split()[0] == 'true'
|
||||
- container_state.stdout.split()[1] == '0'
|
||||
- container_state.stdout.split()[2] == built_image.stdout
|
||||
fail_msg: >-
|
||||
Контейнер {{ container_name }} в состоянии «{{ container_state.stdout }}»
|
||||
(ожидалось: запущен, 0 перезапусков, образ {{ built_image.stdout }}).
|
||||
quiet: true
|
||||
rescue:
|
||||
- name: Collect the container log
|
||||
ansible.builtin.command: "docker logs --tail 30 {{ container_name }}"
|
||||
register: failed_logs
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Fail the host and stop the next waves
|
||||
ansible.builtin.fail:
|
||||
msg: |-
|
||||
{{ inventory_hostname }}: контейнер не прошёл проверку после запуска.
|
||||
Последние строки лога:
|
||||
{{ failed_logs.stdout }}{{ failed_logs.stderr }}
|
||||
|
||||
# Старые образы с метками ревизий: оставляем keep_images последних (docker
|
||||
# выводит от новых к старым), образ работающего контейнера docker не удалит.
|
||||
- name: List image tags
|
||||
ansible.builtin.command:
|
||||
argv: [docker, images, "{{ image_name }}", --format, "{% raw %}{{.Tag}}{% endraw %}"]
|
||||
register: image_tags
|
||||
changed_when: false
|
||||
|
||||
- name: Remove old revision images
|
||||
ansible.builtin.command: "docker rmi {{ image_name }}:{{ item }}"
|
||||
loop: "{{ (image_tags.stdout_lines | reject('equalto', 'latest') | list)[keep_images | int:] }}"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: Remove dangling images
|
||||
ansible.builtin.command: docker image prune -f
|
||||
changed_when: false
|
||||
|
||||
- name: Summary
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ inventory_hostname }} ({{ effective_validator_id }}): {{ deploy_rev }} ({{ deploy_ref }}),
|
||||
образ {{ built_image.stdout[:19] }}, контейнер {{ container_name }} запущен
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
# git с явным safe.directory: клон может принадлежать другому пользователю.
|
||||
git_cmd: "git -c safe.directory={{ repo_dir }} -C {{ repo_dir }}"
|
||||
# validator_id в control-api: из inventory, иначе имя хоста.
|
||||
effective_validator_id: "{{ validator_id | default(inventory_hostname) }}"
|
||||
# Образ с меткой ревизии, собранный в этом прогоне.
|
||||
image_ref: "{{ image_name }}:{{ deploy_rev | default('unknown') }}"
|
||||
@@ -14,6 +14,19 @@ PROBER_SITE_ID=site-1
|
||||
# --- validator-agent (must match a validator_id in the mounted control-api config) ---
|
||||
VALIDATOR_AGENT_VALIDATOR_ID=validator_01
|
||||
|
||||
# --- authentication (empty = that level stays open; services log a warning) ---
|
||||
# Generate each secret with: openssl rand -hex 32
|
||||
# control-api admin token (protects /api/v1/admin/*) and the dashboard's copy of it:
|
||||
CONTROL_API_ADMIN_TOKEN=
|
||||
ADMIN_DASHBOARD_CONTROL_API_TOKEN=
|
||||
# control-api agent token (protects result/event writes); the same value goes
|
||||
# to prober and validator-agent:
|
||||
CONTROL_API_AGENT_TOKEN=
|
||||
# Dashboard login (both must be set to enable it) and cookie-signing key:
|
||||
ADMIN_DASHBOARD_USERNAME=
|
||||
ADMIN_DASHBOARD_PASSWORD=
|
||||
ADMIN_DASHBOARD_SESSION_SECRET=
|
||||
|
||||
# ADMIN_DASHBOARD_CONTROL_API_URL / PROBER_CONTROL_API_URL /
|
||||
# VALIDATOR_AGENT_CONTROL_API_URL default to http://control-api:8080 in
|
||||
# docker-compose.yml (same Docker network) — only set here to override.
|
||||
@@ -39,3 +39,19 @@ VALIDATOR_AGENT_CONTROL_API_URL=https://control-api.internal.example.com
|
||||
|
||||
# --- admin-dashboard (only needed on a dashboard-profile host) ---
|
||||
ADMIN_DASHBOARD_CONTROL_API_URL=http://control-api:8080
|
||||
|
||||
# --- authentication (fill in only what this host needs; empty = open + warning) ---
|
||||
# Generate each secret with: openssl rand -hex 32
|
||||
# Rollout order without downtime: update all binaries first, then give the
|
||||
# tokens to prober / validator-agent / dashboard, and set the control-api
|
||||
# tokens (and restart it) last.
|
||||
#
|
||||
# control-plane host: tokens that control-api enforces
|
||||
CONTROL_API_ADMIN_TOKEN=
|
||||
CONTROL_API_AGENT_TOKEN=
|
||||
# dashboard host: control-api's admin token, the login, and the cookie-signing key
|
||||
ADMIN_DASHBOARD_CONTROL_API_TOKEN=
|
||||
ADMIN_DASHBOARD_USERNAME=
|
||||
ADMIN_DASHBOARD_PASSWORD=
|
||||
ADMIN_DASHBOARD_SESSION_SECRET=
|
||||
# prober / validator hosts use CONTROL_API_AGENT_TOKEN (same value as above)
|
||||
@@ -25,6 +25,7 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name prober \
|
||||
-e PROBER_TCP_TIMEOUT_SECONDS=5 \
|
||||
-e PROBER_ICMP_TIMEOUT_SECONDS=5 \
|
||||
-e PROBER_ICMP_COUNT=3 \
|
||||
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
|
||||
cloud-ip-validator-prober
|
||||
|
||||
Рабочий пример (стенд 62.176.10.113):
|
||||
@@ -42,6 +43,9 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name prober \
|
||||
PROBER_TCP_TIMEOUT_SECONDS опциональная, дефолт 5
|
||||
PROBER_ICMP_TIMEOUT_SECONDS опциональная, дефолт 5
|
||||
PROBER_ICMP_COUNT опциональная, дефолт 3
|
||||
CONTROL_API_AGENT_TOKEN опциональная: токен агентов control-api (то же
|
||||
значение, что у control-api); без него результаты
|
||||
проверок будут отклонены, если токен включён
|
||||
|
||||
Примечания:
|
||||
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
|
||||
@@ -61,6 +65,10 @@ docker run -d --platform linux/amd64 -p 8090:8090 --name admin-dashboard \
|
||||
-e ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS=10 \
|
||||
-e ADMIN_DASHBOARD_LAST_COMPLETED_COUNT=20 \
|
||||
-e ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS=5 \
|
||||
-e ADMIN_DASHBOARD_CONTROL_API_TOKEN=<admin_token> \
|
||||
-e ADMIN_DASHBOARD_USERNAME=<login> \
|
||||
-e ADMIN_DASHBOARD_PASSWORD=<password> \
|
||||
-e ADMIN_DASHBOARD_SESSION_SECRET=<random_hex> \
|
||||
cloud-ip-validator-admin-dashboard
|
||||
|
||||
Рабочий пример (стенд 62.176.10.113):
|
||||
@@ -75,6 +83,14 @@ docker run -d --platform linux/amd64 -p 8090:8090 --name admin-dashboard \
|
||||
ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS опциональная, дефолт 10
|
||||
ADMIN_DASHBOARD_LAST_COMPLETED_COUNT опциональная, дефолт 20
|
||||
ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS опциональная, дефолт 5
|
||||
ADMIN_DASHBOARD_CONTROL_API_TOKEN опциональная: admin-токен control-api
|
||||
ADMIN_DASHBOARD_USERNAME / _PASSWORD опциональные: логин дашборда; если не
|
||||
заданы оба — вход не требуется (в логе
|
||||
предупреждение)
|
||||
ADMIN_DASHBOARD_SESSION_SECRET опциональная: ключ подписи cookie сессии;
|
||||
без неё случайный, сессии сбрасываются
|
||||
рестартом
|
||||
(секреты генерируются так: openssl rand -hex 32)
|
||||
|
||||
Примечания:
|
||||
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
|
||||
@@ -102,12 +118,18 @@ docker run -d --platform linux/amd64 -p 8080:8080 --name control-api \
|
||||
-e OS_PROJECT_ID=<project_id> \
|
||||
-e OS_REGION_NAME=<region> \
|
||||
-e OS_TOKEN=<token> \
|
||||
-e CONTROL_API_ADMIN_TOKEN=<admin_token> \
|
||||
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
|
||||
cloud-ip-validator-control-api
|
||||
|
||||
(при auth_method: password — вместо OS_TOKEN передать OS_USERNAME,
|
||||
OS_USER_DOMAIN_NAME, OS_PASSWORD; для openstack.mode: mock креденшлы не
|
||||
нужны)
|
||||
|
||||
CONTROL_API_ADMIN_TOKEN закрывает /api/v1/admin/*, CONTROL_API_AGENT_TOKEN —
|
||||
запись результатов/событий агентами и пробером; не заданный токен оставляет
|
||||
соответствующий уровень открытым (в логе предупреждение).
|
||||
|
||||
Примечания:
|
||||
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
|
||||
-p 8080:8080 проброс порта control-api наружу
|
||||
@@ -132,6 +154,7 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name validator-agent \
|
||||
-e VALIDATOR_AGENT_ICMP_COUNT=3 \
|
||||
-e VALIDATOR_AGENT_SSH_ENABLED=false \
|
||||
-e VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS=5 \
|
||||
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
|
||||
cloud-ip-validator-validator-agent
|
||||
|
||||
Переменные окружения:
|
||||
@@ -145,6 +168,8 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name validator-agent \
|
||||
VALIDATOR_AGENT_ICMP_COUNT опциональная, дефолт 3
|
||||
VALIDATOR_AGENT_SSH_ENABLED опциональная, дефолт false
|
||||
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS опциональная, дефолт 5
|
||||
CONTROL_API_AGENT_TOKEN опциональная: токен агентов control-api
|
||||
(то же значение, что у control-api)
|
||||
|
||||
Примечания:
|
||||
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
|
||||
|
||||
@@ -8,6 +8,10 @@ export ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS="${ADMIN_DASHBOARD_CONTROL_AP
|
||||
export ADMIN_DASHBOARD_LAST_COMPLETED_COUNT="${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT:-20}"
|
||||
export ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS="${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS:-5}"
|
||||
|
||||
# Tokens/passwords (CONTROL_API_AGENT_TOKEN, ADMIN_DASHBOARD_*) are deliberately
|
||||
# NOT templated into the YAML: the binary reads them straight from this
|
||||
# container's environment (names default in the config loader), so secrets
|
||||
# never land in a file inside the container.
|
||||
envsubst '${ADMIN_DASHBOARD_LISTEN_ADDR} ${ADMIN_DASHBOARD_CONTROL_API_URL} ${ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS} ${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT} ${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS}' \
|
||||
< /etc/admin-dashboard/admin-dashboard.yaml.tmpl > /etc/admin-dashboard/admin-dashboard.yaml
|
||||
|
||||
|
||||
@@ -16,6 +16,15 @@ database:
|
||||
|
||||
openstack:
|
||||
mode: "mock"
|
||||
# Постраничное чтение Floating IP / page size of the paged listing (default 200)
|
||||
list_page_size: 200
|
||||
|
||||
# Статические bearer-токены читаются из переменных окружения контейнера с
|
||||
# этими именами (задаются в .env, см. deploy/docker/.env.example). Пусто =
|
||||
# соответствующий уровень API открыт (с предупреждением в логе).
|
||||
auth:
|
||||
admin_token_env: "CONTROL_API_ADMIN_TOKEN" # защищает /api/v1/admin/*
|
||||
agent_token_env: "CONTROL_API_AGENT_TOKEN" # защищает запись результатов агентами/пробером
|
||||
|
||||
orchestrator:
|
||||
poll_interval_seconds: 5
|
||||
@@ -27,6 +36,7 @@ orchestrator:
|
||||
heartbeat_timeout_seconds: 30
|
||||
fip_settle_seconds: 0
|
||||
fip_scan_interval_seconds: 0
|
||||
fip_scan_timeout_seconds: 1800 # общий таймаут фонового скана / scan deadline
|
||||
|
||||
aggregation:
|
||||
missing_counts_as_fail: true
|
||||
|
||||
@@ -34,6 +34,11 @@ services:
|
||||
networks: [backend]
|
||||
volumes:
|
||||
- control-api-db:/var/lib/cloud-ip-validator
|
||||
environment:
|
||||
# Static bearer tokens (empty = that API level stays open, with a
|
||||
# warning in the log). Generate with `openssl rand -hex 32`.
|
||||
CONTROL_API_ADMIN_TOKEN: "${CONTROL_API_ADMIN_TOKEN:-}"
|
||||
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8080/healthz"]
|
||||
interval: 5s
|
||||
@@ -55,6 +60,12 @@ services:
|
||||
ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS: "${ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS:-10}"
|
||||
ADMIN_DASHBOARD_LAST_COMPLETED_COUNT: "${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT:-20}"
|
||||
ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS: "${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS:-5}"
|
||||
# Admin token of control-api, and the dashboard's own login. Empty
|
||||
# username/password = login disabled (warning in the log).
|
||||
ADMIN_DASHBOARD_CONTROL_API_TOKEN: "${ADMIN_DASHBOARD_CONTROL_API_TOKEN:-}"
|
||||
ADMIN_DASHBOARD_USERNAME: "${ADMIN_DASHBOARD_USERNAME:-}"
|
||||
ADMIN_DASHBOARD_PASSWORD: "${ADMIN_DASHBOARD_PASSWORD:-}"
|
||||
ADMIN_DASHBOARD_SESSION_SECRET: "${ADMIN_DASHBOARD_SESSION_SECRET:-}"
|
||||
|
||||
prober:
|
||||
build:
|
||||
@@ -72,6 +83,8 @@ services:
|
||||
PROBER_TCP_TIMEOUT_SECONDS: "${PROBER_TCP_TIMEOUT_SECONDS:-5}"
|
||||
PROBER_ICMP_TIMEOUT_SECONDS: "${PROBER_ICMP_TIMEOUT_SECONDS:-5}"
|
||||
PROBER_ICMP_COUNT: "${PROBER_ICMP_COUNT:-3}"
|
||||
# Agent token of control-api (same value as on control-api).
|
||||
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
|
||||
|
||||
validator-agent:
|
||||
build:
|
||||
@@ -87,8 +100,11 @@ services:
|
||||
VALIDATOR_AGENT_CONTROL_API_URL: "${VALIDATOR_AGENT_CONTROL_API_URL:-http://control-api:8080}"
|
||||
VALIDATOR_AGENT_POLL_INTERVAL_SECONDS: "${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS:-5}"
|
||||
VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS:-10}"
|
||||
VALIDATOR_AGENT_SELF_CHECK_METHODS: "${VALIDATOR_AGENT_SELF_CHECK_METHODS:-[ip_echo]}"
|
||||
VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS:-10}"
|
||||
VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS:-5}"
|
||||
VALIDATOR_AGENT_ICMP_COUNT: "${VALIDATOR_AGENT_ICMP_COUNT:-3}"
|
||||
VALIDATOR_AGENT_SSH_ENABLED: "${VALIDATOR_AGENT_SSH_ENABLED:-false}"
|
||||
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS:-5}"
|
||||
# Agent token of control-api (same value as on control-api).
|
||||
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
|
||||
@@ -9,6 +9,10 @@ export PROBER_TCP_TIMEOUT_SECONDS="${PROBER_TCP_TIMEOUT_SECONDS:-5}"
|
||||
export PROBER_ICMP_TIMEOUT_SECONDS="${PROBER_ICMP_TIMEOUT_SECONDS:-5}"
|
||||
export PROBER_ICMP_COUNT="${PROBER_ICMP_COUNT:-3}"
|
||||
|
||||
# Tokens/passwords (CONTROL_API_AGENT_TOKEN, ADMIN_DASHBOARD_*) are deliberately
|
||||
# NOT templated into the YAML: the binary reads them straight from this
|
||||
# container's environment (names default in the config loader), so secrets
|
||||
# never land in a file inside the container.
|
||||
envsubst '${PROBER_SITE_ID} ${PROBER_CONTROL_API_URL} ${PROBER_POLL_INTERVAL_SECONDS} ${PROBER_TCP_TIMEOUT_SECONDS} ${PROBER_ICMP_TIMEOUT_SECONDS} ${PROBER_ICMP_COUNT}' \
|
||||
< /etc/prober/prober.yaml.tmpl > /etc/prober/prober.yaml
|
||||
|
||||
|
||||
@@ -6,13 +6,18 @@ set -eu
|
||||
|
||||
export VALIDATOR_AGENT_POLL_INTERVAL_SECONDS="${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS:-5}"
|
||||
export VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS="${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS:-10}"
|
||||
export VALIDATOR_AGENT_SELF_CHECK_METHODS="${VALIDATOR_AGENT_SELF_CHECK_METHODS:-[ip_echo]}"
|
||||
export VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS="${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS:-10}"
|
||||
export VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS="${VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS:-5}"
|
||||
export VALIDATOR_AGENT_ICMP_COUNT="${VALIDATOR_AGENT_ICMP_COUNT:-3}"
|
||||
export VALIDATOR_AGENT_SSH_ENABLED="${VALIDATOR_AGENT_SSH_ENABLED:-false}"
|
||||
export VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS="${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS:-5}"
|
||||
|
||||
envsubst '${VALIDATOR_AGENT_VALIDATOR_ID} ${VALIDATOR_AGENT_CONTROL_API_URL} ${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS} ${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_ICMP_COUNT} ${VALIDATOR_AGENT_SSH_ENABLED} ${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS}' \
|
||||
# Tokens/passwords (CONTROL_API_AGENT_TOKEN, ADMIN_DASHBOARD_*) are deliberately
|
||||
# NOT templated into the YAML: the binary reads them straight from this
|
||||
# container's environment (names default in the config loader), so secrets
|
||||
# never land in a file inside the container.
|
||||
envsubst '${VALIDATOR_AGENT_VALIDATOR_ID} ${VALIDATOR_AGENT_CONTROL_API_URL} ${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS} ${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_SELF_CHECK_METHODS} ${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_ICMP_COUNT} ${VALIDATOR_AGENT_SSH_ENABLED} ${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS}' \
|
||||
< /etc/validator-agent/validator-agent.yaml.tmpl > /etc/validator-agent/validator-agent.yaml
|
||||
|
||||
exec /usr/local/bin/validator-agent -config /etc/validator-agent/validator-agent.yaml
|
||||
@@ -4,6 +4,7 @@ poll_interval_seconds: ${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS}
|
||||
|
||||
self_check:
|
||||
timeout_seconds: ${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS}
|
||||
methods: ${VALIDATOR_AGENT_SELF_CHECK_METHODS}
|
||||
|
||||
checks:
|
||||
https_timeout_seconds: ${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS}
|
||||
|
||||
@@ -8,6 +8,11 @@ Type=simple
|
||||
User=cloud-ip-validator
|
||||
Group=cloud-ip-validator
|
||||
ExecStart=/usr/local/bin/admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
|
||||
# Optional (leading "-": the file may be absent). Holds
|
||||
# ADMIN_DASHBOARD_CONTROL_API_TOKEN (control-api's admin token) and the
|
||||
# dashboard login: ADMIN_DASHBOARD_USERNAME, ADMIN_DASHBOARD_PASSWORD,
|
||||
# ADMIN_DASHBOARD_SESSION_SECRET. Keep it mode 0600, owned by the service user.
|
||||
EnvironmentFile=-/etc/cloud-ip-validator/admin-dashboard.env
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
|
||||
@@ -13,7 +13,9 @@ ExecStart=/usr/local/bin/control-api -config /etc/cloud-ip-validator/control-api
|
||||
# (auth_method: token) or OS_USERNAME / OS_USER_DOMAIN_NAME / OS_PASSWORD
|
||||
# (auth_method: password) — see docs/SETUP.md. Keep this file mode 0600,
|
||||
# owned by the service user; never commit it or put credentials in the
|
||||
# YAML config.
|
||||
# YAML config. Also holds CONTROL_API_ADMIN_TOKEN and CONTROL_API_AGENT_TOKEN
|
||||
# (generate each with `openssl rand -hex 32`); when unset, the matching API
|
||||
# level stays open and control-api logs a warning.
|
||||
EnvironmentFile=/etc/cloud-ip-validator/control-api.env
|
||||
WorkingDirectory=/var/lib/cloud-ip-validator
|
||||
Restart=on-failure
|
||||
|
||||
@@ -8,6 +8,10 @@ Type=simple
|
||||
User=cloud-ip-validator
|
||||
Group=cloud-ip-validator
|
||||
ExecStart=/usr/local/bin/prober -config /etc/cloud-ip-validator/prober.yaml
|
||||
# Optional (leading "-": the file may be absent). Holds CONTROL_API_AGENT_TOKEN,
|
||||
# the same value as on control-api. Keep it mode 0600, owned by the service
|
||||
# user.
|
||||
EnvironmentFile=-/etc/cloud-ip-validator/prober.env
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
|
||||
@@ -8,6 +8,10 @@ Type=simple
|
||||
User=cloud-ip-validator
|
||||
Group=cloud-ip-validator
|
||||
ExecStart=/usr/local/bin/validator-agent -config /etc/cloud-ip-validator/validator-agent.yaml
|
||||
# Optional (leading "-": the file may be absent). Holds CONTROL_API_AGENT_TOKEN,
|
||||
# the same value as on control-api. Keep it mode 0600, owned by the service
|
||||
# user.
|
||||
EnvironmentFile=-/etc/cloud-ip-validator/validator-agent.env
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# Ручная очистка базы данных control-api (SQL)
|
||||
|
||||
Когда нужна: подготовка к новому полному прогону, разбор после инцидента, освобождение места, удаление отдельных адресов.
|
||||
Все примеры проверены 2026-10-02 на копии боевой БД (20 валидаторов, 4 площадки, 6445 адресов в реестре, 29 889 проверок,
|
||||
19 897 событий): без ошибок, `integrity_check` = `ok`, нарушений внешних ключей нет.
|
||||
|
||||
> **Сначала API.** Если в очереди есть адреса в работе, очищайте очередь штатно: кнопка «Очистить всё» на `/ips` или
|
||||
> `POST /api/v1/admin/ips/clear` (см. [USAGE.md](USAGE.md#удаление-адресов-из-очереди)). Только так control-api отвяжет Floating IP от портов
|
||||
> валидаторов. SQL ниже работает с базой «в покое»: он не обращается к OpenStack.
|
||||
|
||||
## 1. Что в базе и что нельзя трогать
|
||||
|
||||
| Группа | Таблицы | Можно чистить |
|
||||
|---|---|---|
|
||||
| Данные прогона | `ip_queue` (очередь), `ip_registry` (реестр адресов), `checks` (реестр проверок), `ip_site_checks` (признаки площадок по адресам в работе), `check_runs` и `run_results` (запуски и итоги адресов в них), `ip_self_check_failures` (история сбоев self-check по адресам), `events` (журнал событий) | да |
|
||||
| Настройки (не трогать) | `validators`, `sites`, `target_groups` (цели), `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle`, `subnets` (список подсетей для аналитики) | **нет** |
|
||||
| Служебное | `sqlite_sequence` (нумерация записей), `PRAGMA user_version` (версия схемы) | нумерацию можно сбросить, версию не менять |
|
||||
|
||||
Связи (внешние ключи): `checks`, `events`, `ip_site_checks` ссылаются на `ip_queue`; `checks`, `events`, `ip_queue` — на `ip_registry`;
|
||||
`validators.current_ip_id` — на `ip_queue`. Поэтому порядок удаления всегда такой: сначала `validators.current_ip_id` в `NULL`, затем
|
||||
`ip_site_checks`, `checks`, `events`, `ip_queue` и в конце `ip_registry`. Времена в БД хранятся строками `2026-10-02T07:14:58.857Z` (UTC).
|
||||
|
||||
## 2. Подготовка (всегда, перед любой очисткой)
|
||||
|
||||
**2.1. Где лежит база и чем её открывать.** Нужен клиент `sqlite3` на хосте (`apt install sqlite3`).
|
||||
|
||||
| Развёртывание | Файл базы |
|
||||
|---|---|
|
||||
| `rxprod-compose/` (боевой стенд) | `rxprod-compose/capi-db/control-api.db` |
|
||||
| Docker (`deploy/docker`) | в томе `cloud-ip-validator-db`: `docker volume inspect cloud-ip-validator-db --format '{{.Mountpoint}}'`, файл `control-api.db` в этом каталоге (нужен root) |
|
||||
| systemd | `/var/lib/cloud-ip-validator/control-api.db` (`database.path` в `control-api.yaml`) |
|
||||
|
||||
Дальше в примерах `DB=путь/к/control-api.db`.
|
||||
|
||||
**2.2. Проверить, что в очереди ничего не в работе:**
|
||||
|
||||
```bash
|
||||
sqlite3 -readonly "$DB" "
|
||||
SELECT state, COUNT(*) FROM ip_queue
|
||||
WHERE state NOT IN ('done', 'failed', 'occupied') GROUP BY state;"
|
||||
```
|
||||
|
||||
Пустой вывод — всё завершено. Адреса в работе есть — сначала «Очистить всё» через API (см. выше). Затем проверьте в OpenStack, что на портах
|
||||
валидаторов нет лишних Floating IP; по базе видно только то, что control-api считает привязанным:
|
||||
|
||||
```bash
|
||||
sqlite3 -readonly "$DB" "
|
||||
SELECT ip_address, state, fip_id FROM ip_queue
|
||||
WHERE fip_id <> '' AND state NOT IN ('done', 'failed', 'occupied');"
|
||||
```
|
||||
|
||||
**2.3. Остановить control-api.** Он держит базу открытой и пишет в неё на каждом такте; ручные правки поверх работающего процесса
|
||||
ненадёжны.
|
||||
|
||||
```bash
|
||||
cd rxprod-compose && docker compose stop control-api # compose-развёртывание
|
||||
sudo systemctl stop control-api # systemd
|
||||
```
|
||||
|
||||
**2.4. Сделать резервную копию** (штатной командой SQLite, не `cp`: у базы есть журнал `-wal`):
|
||||
|
||||
```bash
|
||||
TS=$(date -u +%Y-%m-%d_%H-%M)
|
||||
sqlite3 "$DB" ".backup '$(dirname "$DB")/backup-$TS-before-cleanup.db'"
|
||||
sqlite3 -readonly "$(dirname "$DB")/backup-$TS-before-cleanup.db" "PRAGMA integrity_check;" # должно быть: ok
|
||||
```
|
||||
|
||||
Лог контейнера до чистки при необходимости сохраните отдельно: `docker logs <контейнер> > backup-$TS.container.log 2>&1`.
|
||||
|
||||
**2.5. Посмотреть, что и сколько лежит** (до и после чистки):
|
||||
|
||||
```bash
|
||||
sqlite3 -readonly "$DB" "
|
||||
SELECT 'ip_queue' AS tbl, COUNT(*) AS n FROM ip_queue
|
||||
UNION ALL SELECT 'ip_registry', COUNT(*) FROM ip_registry
|
||||
UNION ALL SELECT 'checks', COUNT(*) FROM checks
|
||||
UNION ALL SELECT 'ip_site_checks', COUNT(*) FROM ip_site_checks
|
||||
UNION ALL SELECT 'events', COUNT(*) FROM events
|
||||
UNION ALL SELECT 'validators', COUNT(*) FROM validators
|
||||
UNION ALL SELECT 'sites', COUNT(*) FROM sites;"
|
||||
```
|
||||
|
||||
## 3. Сценарии
|
||||
|
||||
Команды выполняются так: `sqlite3 "$DB"` и вставить блок, либо сохранить блок в файл и выполнить `sqlite3 "$DB" < файл.sql`.
|
||||
|
||||
### 3.1. Полный сброс данных прогона (перед новым полным прогоном)
|
||||
|
||||
Очищает очередь, реестр адресов, реестр проверок, историю сбоев self-check, журнал событий. Валидаторы, площадки, цели, типы проверок и все настройки остаются.
|
||||
|
||||
```sql
|
||||
PRAGMA foreign_keys = ON;
|
||||
BEGIN;
|
||||
-- валидаторы больше не ссылаются на адреса очереди
|
||||
UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IS NOT NULL;
|
||||
UPDATE validators SET state = 'idle' WHERE state = 'assigned';
|
||||
-- порядок важен: сначала зависимые таблицы
|
||||
DELETE FROM ip_site_checks;
|
||||
DELETE FROM run_results;
|
||||
DELETE FROM check_runs;
|
||||
DELETE FROM checks;
|
||||
DELETE FROM ip_self_check_failures;
|
||||
DELETE FROM events;
|
||||
DELETE FROM ip_queue;
|
||||
DELETE FROM ip_registry;
|
||||
-- нумерация снова с 1 (необязательно)
|
||||
DELETE FROM sqlite_sequence WHERE name IN ('ip_registry', 'checks', 'ip_queue', 'events', 'check_runs', 'ip_self_check_failures');
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
Затем освободите место (отдельной командой, не внутри транзакции):
|
||||
|
||||
```sql
|
||||
PRAGMA wal_checkpoint(TRUNCATE);
|
||||
VACUUM;
|
||||
```
|
||||
|
||||
Файл сжимается до сотен килобайт (на проверочной копии: 20 МБ → 128 КБ).
|
||||
|
||||
### 3.2. Только журнал событий
|
||||
|
||||
Очередь, реестр и проверки не затрагиваются.
|
||||
|
||||
Весь журнал:
|
||||
|
||||
```sql
|
||||
DELETE FROM events;
|
||||
DELETE FROM sqlite_sequence WHERE name = 'events';
|
||||
```
|
||||
|
||||
Только старше 7 дней (число дней меняйте в `'-7 days'`):
|
||||
|
||||
```sql
|
||||
DELETE FROM events
|
||||
WHERE occurred_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days');
|
||||
```
|
||||
|
||||
### 3.3. Только реестр проверок (история проверок)
|
||||
|
||||
Адреса и их итоги в очереди остаются; пропадает подробная история проверок (на странице «Реестр» исчезнут результаты).
|
||||
|
||||
Вся история:
|
||||
|
||||
```sql
|
||||
DELETE FROM checks;
|
||||
DELETE FROM sqlite_sequence WHERE name = 'checks';
|
||||
```
|
||||
|
||||
Оставить последние 3 цикла каждого адреса (число `3` меняйте):
|
||||
|
||||
```sql
|
||||
DELETE FROM checks
|
||||
WHERE cycle_id <= (SELECT MAX(c2.cycle_id) FROM checks c2 WHERE c2.registry_id = checks.registry_id) - 3;
|
||||
```
|
||||
|
||||
Постоянное ограничение глубины истории лучше задать настройкой `history_retention_cycles` (страница `/settings` или
|
||||
`PUT /api/v1/admin/config/orchestrator`): control-api сам подрезает историю при завершении каждого адреса. SQL выше нужен для разовой чистки.
|
||||
|
||||
### 3.4. Удалить конкретные адреса целиком
|
||||
|
||||
Удаляет адрес из очереди и реестра вместе со всей его историей (проверки, сбои self-check и события). Список адресов подставьте в первую команду
|
||||
`CREATE TEMP TABLE doomed_reg`. Адрес, который сейчас проверяется, удалять этим способом нельзя: используйте API (раздел 5).
|
||||
|
||||
```sql
|
||||
PRAGMA foreign_keys = ON;
|
||||
BEGIN;
|
||||
CREATE TEMP TABLE doomed_reg AS
|
||||
SELECT id FROM ip_registry WHERE ip_address IN ('5.188.140.6', '5.188.140.62'); -- ваши адреса
|
||||
CREATE TEMP TABLE doomed_ip AS
|
||||
SELECT id FROM ip_queue WHERE registry_id IN (SELECT id FROM doomed_reg);
|
||||
UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IN (SELECT id FROM doomed_ip);
|
||||
DELETE FROM ip_site_checks WHERE ip_id IN (SELECT id FROM doomed_ip);
|
||||
DELETE FROM run_results WHERE registry_id IN (SELECT id FROM doomed_reg);
|
||||
DELETE FROM checks WHERE registry_id IN (SELECT id FROM doomed_reg);
|
||||
DELETE FROM ip_self_check_failures WHERE registry_id IN (SELECT id FROM doomed_reg);
|
||||
DELETE FROM events WHERE registry_id IN (SELECT id FROM doomed_reg) OR ip_id IN (SELECT id FROM doomed_ip);
|
||||
DELETE FROM ip_queue WHERE id IN (SELECT id FROM doomed_ip);
|
||||
DELETE FROM ip_registry WHERE id IN (SELECT id FROM doomed_reg);
|
||||
DROP TABLE doomed_ip;
|
||||
DROP TABLE doomed_reg;
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### 3.5. Только освободить место
|
||||
|
||||
Если удалили много, а файл не уменьшился (SQLite не отдаёт место ОС до `VACUUM`):
|
||||
|
||||
```sql
|
||||
PRAGMA wal_checkpoint(TRUNCATE);
|
||||
VACUUM;
|
||||
```
|
||||
|
||||
Размер и свободные страницы:
|
||||
|
||||
```sql
|
||||
SELECT page_count * page_size / 1024 AS size_kb, freelist_count * page_size / 1024 AS free_kb
|
||||
FROM pragma_page_count(), pragma_page_size(), pragma_freelist_count();
|
||||
```
|
||||
|
||||
## 4. После очистки: запуск и проверка
|
||||
|
||||
```bash
|
||||
cd rxprod-compose && docker compose up -d --no-deps control-api # или: sudo systemctl start control-api
|
||||
```
|
||||
|
||||
Если нужно, чтобы и лог контейнера начался с нуля, пересоздайте контейнер: `docker compose up -d --force-recreate --no-deps control-api`
|
||||
(старый лог сохраните заранее, п. 2.4).
|
||||
|
||||
Проверка базы (до запуска или на копии):
|
||||
|
||||
```bash
|
||||
sqlite3 -readonly "$DB" "
|
||||
PRAGMA integrity_check;
|
||||
PRAGMA foreign_key_check;
|
||||
SELECT state, COUNT(*) FROM validators GROUP BY state;
|
||||
SELECT COUNT(*) AS validators_with_address FROM validators WHERE current_ip_id IS NOT NULL;
|
||||
SELECT COUNT(*) AS queue_rows FROM ip_queue;"
|
||||
```
|
||||
|
||||
Ожидается: `ok`; пустой результат `foreign_key_check`; у валидаторов состояние `idle`; `validators_with_address` = 0; для полного сброса `queue_rows` = 0.
|
||||
Через API: `GET /api/v1/admin/status` (`total_ips` = 0, `total_validators` = число валидаторов) и `GET /api/v1/admin/validators`
|
||||
(через 10–15 секунд после запуска у всех свежий `last_heartbeat_at`).
|
||||
|
||||
## 5. Что делать через API, а не через SQL
|
||||
|
||||
| Задача | Как |
|
||||
|---|---|
|
||||
| Остановить проверку адреса, удалить адрес в работе | `POST /api/v1/admin/ips/{ip}/cancel`, `DELETE /api/v1/admin/ips/{ip}` |
|
||||
| Очистить всю очередь с отвязкой Floating IP | `POST /api/v1/admin/ips/clear` |
|
||||
| Перепроверить завершённые адреса | `POST /api/v1/admin/ips` со списком адресов |
|
||||
| Валидатор «завис» с адресом | ничего не править: лизинг истечёт, адрес вернётся в очередь, валидатор освободится сам |
|
||||
| Добавить/убрать валидатор, площадку, цель | `/api/v1/admin/config/*` или страницы дашборда |
|
||||
|
||||
## 6. Восстановление из копии
|
||||
|
||||
```bash
|
||||
cd rxprod-compose && docker compose stop control-api
|
||||
cp capi-db/backup-<метка>-before-cleanup.db capi-db/control-api.db
|
||||
rm -f capi-db/control-api.db-wal capi-db/control-api.db-shm # старый журнал к новой копии не относится
|
||||
docker compose up -d --no-deps control-api
|
||||
```
|
||||
|
||||
## 7. Ловушки
|
||||
|
||||
- Не выполняйте `DELETE` при работающем control-api: он пишет в ту же базу.
|
||||
- Не удаляйте строки настроек (`validators`, `sites`, `target_groups`, `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle`):
|
||||
после этого control-api либо не стартует, либо работает без площадок и целей.
|
||||
- Не нарушайте порядок удаления (раздел 1) и не отключайте `PRAGMA foreign_keys = ON` в блоках выше: база сама остановит ошибочное удаление.
|
||||
- `VACUUM` нельзя вызывать внутри транзакции и пока control-api запущен.
|
||||
- Не копируйте файл базы командой `cp` при работающем процессе: журнал `-wal` останется в неконсистентном состоянии. Используйте `.backup`.
|
||||
- Не меняйте `PRAGMA user_version`: по нему control-api применяет миграции схемы.
|
||||
- Не правьте состояние адресов и валидаторов вручную (`state`, `owner_validator_id`, `lease_expires_at`) вместо API: control-api сверяет их на каждом такте и исправит расхождение,
|
||||
но до этого результат непредсказуем.
|
||||
+295
-45
@@ -4,15 +4,13 @@ Control API — единственная точка входа в систему
|
||||
`prober` и оператора (администратора). Все данные передаются в формате
|
||||
JSON, базовый префикс прикладных методов — `/api/v1`.
|
||||
|
||||
> **Важно.** На данный момент API не защищён аутентификацией/авторизацией
|
||||
> — эндпоинты доступны любому, кто может достучаться до порта control-api
|
||||
> по сети. Это касается и методов из раздела
|
||||
> [«Управление очередью и конфигурацией»](#управление-очередью-и-конфигурацией)
|
||||
> ниже — они меняют, что и как проверяется, без подтверждения личности
|
||||
> вызывающего. Для эксплуатации за пределами доверенного сегмента сети
|
||||
> обязательно ограничьте доступ на уровне сети/файрвола (см.
|
||||
> [SETUP.md](SETUP.md#сетевые-доступы)). Добавление bearer-токена — известное
|
||||
> направление доработки, в текущей версии не реализовано.
|
||||
> **Аутентификация.** Доступ к API определяется двумя статическими
|
||||
> bearer-токенами — см. [«Аутентификация»](#аутентификация). Токен задаётся
|
||||
> переменной окружения; **если токен не задан, соответствующий уровень остаётся
|
||||
> открытым** (control-api стартует с предупреждением в логе) — так сделано для
|
||||
> обратной совместимости. Поэтому для эксплуатации за пределами доверенного
|
||||
> сегмента сети токены нужно задать, а доступ дополнительно ограничить на уровне
|
||||
> сети/файрвола (см. [SETUP.md](SETUP.md#сетевые-доступы)).
|
||||
|
||||
Базовый URL в примерах — `http://control-api.internal:8080`, замените на
|
||||
адрес вашего стенда (см. `server.listen_addr` в конфиге control-api).
|
||||
@@ -23,6 +21,7 @@ JSON, базовый префикс прикладных методов — `/ap
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Аутентификация](#аутентификация)
|
||||
- [Общие соглашения](#общие-соглашения)
|
||||
- [Методы для validator-agent](#методы-для-validator-agent)
|
||||
- [Методы для prober](#методы-для-prober)
|
||||
@@ -33,6 +32,31 @@ JSON, базовый префикс прикладных методов — `/ap
|
||||
- [Модель состояний и связь методов с ней](#модель-состояний-и-связь-методов-с-ней)
|
||||
- [Сквозной пример работы (curl)](#сквозной-пример-работы-curl)
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Токен передаётся заголовком `Authorization: Bearer <токен>`. Токены статические, **без срока жизни**; ротация — смена
|
||||
переменной окружения и перезапуск. Сравнение выполняется в константное время.
|
||||
|
||||
| Уровень | Токен (переменная на control-api) | Какие методы |
|
||||
|---|---|---|
|
||||
| **admin** | `CONTROL_API_ADMIN_TOKEN` | все `/api/v1/admin/*` (очередь, реестр, автоцикл, `config/*`) |
|
||||
| **agent** | `CONTROL_API_AGENT_TOKEN` | запись результатов: `POST /agents/{id}/self-check`, `/events`, `/results`, `/complete` и `POST /probers/{site_id}/results` |
|
||||
| **открыто** | — | `GET /healthz`; `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`, `GET /agents/{id}/observed-ip`; `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments` |
|
||||
|
||||
- Токены разные: токен администратора **не** подходит для методов агентов, и наоборот.
|
||||
- Валидатор и пробер могут без токена зарегистрироваться, слать heartbeat и забирать задание (настройку); валидатор также может спросить, с какого адреса его видит control-api (`observed-ip`); отправка результатов без токена агентов — `401`.
|
||||
- Имена переменных меняются в секции `auth` конфига control-api (`admin_token_env`, `agent_token_env`); сами значения в YAML не хранятся.
|
||||
- Ответ при отказе: `401 {"error": "unauthorized"}` с заголовком `WWW-Authenticate: Bearer`.
|
||||
- Токен не задан (пустая переменная) — уровень открыт; в логе control-api при старте предупреждение. Токены нужно генерировать случайными: `openssl rand -hex 32`.
|
||||
- Токены уходят открытым текстом, если TLS не терминируется перед control-api, — публикуйте API через reverse-proxy с TLS.
|
||||
|
||||
```bash
|
||||
export ADMIN_TOKEN=... # значение CONTROL_API_ADMIN_TOKEN
|
||||
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://<control-api>:8080/api/v1/admin/status
|
||||
```
|
||||
|
||||
> Во всех примерах `curl` ниже заголовок `Authorization` для краткости опущен; если токен администратора задан, добавляйте его к методам `/api/v1/admin/*`.
|
||||
|
||||
## Общие соглашения
|
||||
|
||||
- Тело запроса и ответа — JSON (`Content-Type: application/json`).
|
||||
@@ -121,6 +145,30 @@ JSON, базовый префикс прикладных методов — `/ap
|
||||
`check_config` — уже развёрнутая конфигурация проверок (тип + список
|
||||
целей), агенту не нужно самому сопоставлять группы целей.
|
||||
|
||||
### `GET /api/v1/agents/{id}/observed-ip`
|
||||
|
||||
С какого адреса control-api видит соединение валидатора. Используется
|
||||
self-check способом `control_api` (`self_check.methods` в
|
||||
`validator-agent.yaml`) как альтернатива внешнему IP-echo сервису. Уровень
|
||||
доступа — открыто: отдаётся только адрес самого вызывающего.
|
||||
|
||||
Ответ `200`:
|
||||
```json
|
||||
{"ip": "203.0.113.10", "source": "remote_addr"}
|
||||
```
|
||||
|
||||
- Адрес берётся только из адреса TCP-соединения (`RemoteAddr`), приведённого
|
||||
к каноничному виду (`::ffff:1.2.3.4` → `1.2.3.4`). Заголовки
|
||||
`X-Forwarded-For` / `X-Real-IP` **не учитываются**: иначе валидатор мог бы
|
||||
подделать адрес и пройти проверку. Метод рассчитан на прямое подключение
|
||||
без обратного прокси.
|
||||
- `404`, если `validator_id` не зарегистрирован.
|
||||
- Способ корректен, только если соединение выходит через внешнюю сеть
|
||||
(SNAT Floating IP). Если control-api достижим из облака по внутренней
|
||||
сети, он увидит приватный адрес валидатора. Если порт control-api
|
||||
опубликован через Docker, проверьте, что ручка показывает внешний адрес
|
||||
клиента, а не адрес шлюза Docker.
|
||||
|
||||
### `POST /api/v1/agents/{id}/self-check`
|
||||
|
||||
Отчёт о результате self-check — подтверждение, что исходящий трафик
|
||||
@@ -128,7 +176,11 @@ JSON, базовый префикс прикладных методов — `/ap
|
||||
определяет это **сам**, обращаясь к внешнему (снаружи облака) IP-echo
|
||||
сервису (`self_check.ip_echo_urls` в `validator-agent.yaml`, например
|
||||
`api.ipify.org`) и сравнивая ответ с `ip_address` из задания — control-api
|
||||
в этом определении не участвует. Важно, что ресурс должен быть именно
|
||||
в этом определении не участвует. Дополнительно можно включить способ
|
||||
`control_api` (`self_check.methods`): агент спрашивает у control-api через
|
||||
`GET /agents/{id}/observed-ip`, с какого адреса тот его видит. Способы
|
||||
пробуются по приоритету, достаточно подтверждения любым из них; без
|
||||
настройки работает только IP-echo. Важно, что ресурс должен быть именно
|
||||
внешним: OpenStack применяет SNAT через Floating IP только к трафику,
|
||||
уходящему через внешнюю сеть, поэтому обращение к чему-либо внутри
|
||||
проекта (в том числе к самому control-api, если он в той же внутренней
|
||||
@@ -141,13 +193,18 @@ JSON, базовый префикс прикладных методов — `/ap
|
||||
"ip_id": 42,
|
||||
"detected_egress_ip": "203.0.113.10",
|
||||
"success": true,
|
||||
"detail": "matched"
|
||||
"detail": "matched (control_api)"
|
||||
}
|
||||
```
|
||||
|
||||
Ответ: `{"ok": true}`. При `success: false` control-api сам решает —
|
||||
повторить попытку назначения FIP или пометить IP как `failed` (после
|
||||
исчерпания `orchestrator.max_self_check_retries`).
|
||||
вернуть адрес в очередь или пометить IP как `failed`. Сбой записывается в
|
||||
историю адреса; повтор **не отдаётся этому валидатору** (он остаётся в
|
||||
работе и берёт остальные адреса). Итог `fail` ставится, когда число
|
||||
проваленных self-check у адреса достигло потолка `self_check_max_attempts`
|
||||
(по умолчанию 5, см. [«Настройки оркестратора»](#настройки-оркестратора-apiv1adminconfigorchestrator));
|
||||
`orchestrator.max_self_check_retries` не используется. Сбой привязки FIP и
|
||||
истечение лизинга идут по `max_retries`, как раньше.
|
||||
|
||||
### `POST /api/v1/agents/{id}/events`
|
||||
|
||||
@@ -166,6 +223,19 @@ JSON, базовый префикс прикладных методов — `/ap
|
||||
|
||||
Ответ: `{"ok": true}`.
|
||||
|
||||
### Результаты после вердикта
|
||||
|
||||
Проверки фиксируются в момент вердикта: `POST /agents/{id}/results` и
|
||||
`POST /probers/{site_id}/results` принимают результат только пока адрес
|
||||
проверяется (состояния до `aggregating`) и только для его текущей попытки.
|
||||
Результат, пришедший после начала агрегации или после вердикта, а также
|
||||
результат прежней попытки не сохраняется и не меняет сохранённые проверки.
|
||||
Ответ остаётся `200`, чтобы отправитель не повторял запрос:
|
||||
`{"ok": true, "ignored": N}`, где `N` — число отброшенных проверок. На каждый
|
||||
такой запрос по адресу пишется событие `result_dropped`
|
||||
(`{"source": "egress"|"inbound-site-N", "dropped": N}`). Благодаря этому вердикт
|
||||
всегда совпадает с сохранёнными проверками и пересчитывается из них.
|
||||
|
||||
### `POST /api/v1/agents/{id}/results`
|
||||
|
||||
Отчёт о результатах исходящих (egress) проверок. Можно отправлять по
|
||||
@@ -234,13 +304,17 @@ USAGE.md про состояния площадки).
|
||||
|
||||
Запрос: тело не требуется.
|
||||
|
||||
Ответ: `{"ok": true}`. `404`, если `site_id` не сконфигурирован.
|
||||
Ответ: `{"ok": true}`, либо `{"ok": true, "ignored": N}`, если часть результатов
|
||||
отброшена (см. «Результаты после вердикта» ниже). `404`, если `site_id` не сконфигурирован.
|
||||
|
||||
### `GET /api/v1/probers/{site_id}/assignments`
|
||||
|
||||
Список всех IP, которые сейчас находятся в состоянии `checking` — то есть
|
||||
всё, что нужно прозондировать на этом цикле опроса (валидаторов может
|
||||
работать несколько параллельно, поэтому список, а не один IP).
|
||||
Список IP в состоянии `checking`, которые эта площадка ещё не зондировала
|
||||
в текущей попытке (валидаторов может работать несколько параллельно,
|
||||
поэтому список, а не один IP). Адрес выдаётся площадке, пока она не
|
||||
пришлёт для него результат с `complete: true`; после этого этой площадке он
|
||||
больше не выдаётся (другим площадкам выдаётся). Новая попытка (повтор после
|
||||
сбоя) выдаёт адрес снова. Так каждая площадка зондирует адрес один раз за попытку.
|
||||
|
||||
Ответ:
|
||||
```json
|
||||
@@ -306,15 +380,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, все проверки текущей попытки и
|
||||
@@ -324,10 +420,14 @@ IP на данном проходе". До этого момента control-api
|
||||
{
|
||||
"ip": { "ID": 42, "IPAddress": "203.0.113.10", "State": "done", "OverallResult": "pass", "...": "..." },
|
||||
"checks": [ {"Source": "egress", "CheckType": "https", "Target": "https://github.com", "Success": true, "...": "..."} ],
|
||||
"events": [ {"EventType": "fip_associated", "OccurredAt": "...", "...": "..."} ]
|
||||
"events": [ {"EventType": "fip_associated", "OccurredAt": "...", "...": "..."} ],
|
||||
"self_check_failed_on": ["vkiplab-v17"]
|
||||
}
|
||||
```
|
||||
|
||||
`self_check_failed_on` — валидаторы, у которых self-check на этом адресе не
|
||||
прошёл в текущем запуске (по алфавиту; `[]`, если сбоев не было).
|
||||
|
||||
> Обратите внимание: вложенные объекты `ip`/`checks`/`events` сериализуются
|
||||
> без переопределения имён полей (используются имена Go-структур, например
|
||||
> `IPAddress`, `State`, `Success`) — в отличие от методов для
|
||||
@@ -446,33 +546,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» в дашборде). Пока включён
|
||||
[автоматический цикл](#автоматический-цикл-проверок), периодический скан не выполняется.
|
||||
|
||||
## Автоматический цикл проверок
|
||||
|
||||
@@ -551,7 +670,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
|
||||
[
|
||||
@@ -563,7 +685,16 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/stop
|
||||
"last_result": "pass",
|
||||
"last_checked_at": "2026-02-01T09:05:00Z",
|
||||
"in_queue": true,
|
||||
"current_state": "done"
|
||||
"current_state": "done",
|
||||
"last_cycle_id": 4,
|
||||
"egress": {"total": 5, "ok": 5, "by_type": [
|
||||
{"type": "https", "total": 3, "ok": 3},
|
||||
{"type": "icmp", "total": 2, "ok": 2}
|
||||
]},
|
||||
"ingress": {"total": 4, "ok": 3, "by_type": [
|
||||
{"type": "icmp", "total": 1, "ok": 1},
|
||||
{"type": "tcp", "total": 3, "ok": 2}
|
||||
]}
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -571,6 +702,22 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/stop
|
||||
`in_queue`/`current_state` отражают, есть ли у адреса сейчас живая строка в
|
||||
`ip_queue`, а не только в реестре.
|
||||
|
||||
**Уровни `egress` и `ingress`.** Результат последнего цикла (`last_cycle_id` —
|
||||
наибольший цикл с записанными проверками, `0` — проверок нет), разделённый
|
||||
на выходные проверки валидатора (`egress`) и входные проверки пробера со всех
|
||||
площадок (`ingress`). В каждом уровне: `total` — сколько проверок записано,
|
||||
`ok` — сколько успешных, `by_type` — то же по типам, по алфавиту. Тип — это
|
||||
`check_type` до первого дефиса: `tcp-22` и `tcp-443` дают `tcp`, `tls-443` —
|
||||
`tls`; `https`, `icmp`, `ssh` остаются как есть, новый тип проверки появляется
|
||||
в `by_type` сам. Без проверок на уровне: `{"total": 0, "ok": 0, "by_type": []}`.
|
||||
Счёт идёт по записанным проверкам, а `last_result` учитывает ещё и недостающие
|
||||
результаты, поэтому при неполном наборе вердикт может быть хуже, чем «`ok` из
|
||||
`total`».
|
||||
|
||||
Фильтры постраничного режима (только вместе с `limit`): `run` — только адреса,
|
||||
у которых есть результат в этом запуске (см. [«Аналитика запусков»](#аналитика-запусков)); `subnet` — только
|
||||
адреса внутри подсети (CIDR, например `203.0.113.0/24`). Неверный `run` или `subnet` — `400`.
|
||||
|
||||
### `GET /api/v1/admin/registry/{ip}`
|
||||
|
||||
Реестровая запись по одному адресу плюс вся сохранённая история проверок
|
||||
@@ -584,10 +731,14 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/stop
|
||||
"checks": [
|
||||
{"CycleID": 4, "Source": "egress", "CheckType": "https", "Success": true, "...": "..."},
|
||||
{"CycleID": 3, "Source": "egress", "CheckType": "https", "Success": false, "...": "..."}
|
||||
]
|
||||
],
|
||||
"self_check_failed_on": ["vkiplab-v17"]
|
||||
}
|
||||
```
|
||||
|
||||
`self_check_failed_on` — валидаторы, у которых self-check на этом адресе не
|
||||
прошёл, по всем запускам (по алфавиту; `[]`, если сбоев не было).
|
||||
|
||||
`404`, если адрес никогда не ставился на проверку.
|
||||
|
||||
**Глубина хранения.** Сколько последних циклов на адрес хранится в
|
||||
@@ -659,7 +810,7 @@ curl -s -X POST "$BASE/api/v1/admin/ips/clear"
|
||||
|
||||
### Настройки оркестратора: `/api/v1/admin/config/orchestrator`
|
||||
|
||||
Два параметра:
|
||||
Три параметра:
|
||||
|
||||
- `fip_settle_seconds` — пауза между привязкой Floating IP к валидатору и
|
||||
моментом, когда self-check по этому адресу становится доступен агенту
|
||||
@@ -673,11 +824,19 @@ curl -s -X POST "$BASE/api/v1/admin/ips/clear"
|
||||
на адрес в реестре (`GET /api/v1/admin/registry/{ip}`, см.
|
||||
[«Реестр адресов»](#реестр-адресов-и-история-проверок)). `0` — без
|
||||
ограничения (поведение по умолчанию).
|
||||
- `self_check_max_attempts` — потолок провалов self-check на один адрес
|
||||
(1…50, по умолчанию 5). Когда у адреса провалено столько self-check,
|
||||
он получает итог `fail`. Не зависит от числа валидаторов. Валидатор,
|
||||
проваливший self-check на адресе, этому адресу больше не выдаётся (на
|
||||
остальные адреса это не влияет); если все рабочие валидаторы уже
|
||||
провалили адрес, исключения сбрасываются и повторы продолжаются до
|
||||
потолка. Значение действует на следующих повторах без перезапуска.
|
||||
В `PUT` поле необязательно: если не передано, не меняется.
|
||||
|
||||
| Метод | Путь | Тело | Успех | Ошибки |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/api/v1/admin/config/orchestrator` | — | `{"fip_settle_seconds":N,"history_retention_cycles":M}` | |
|
||||
| PUT | `/api/v1/admin/config/orchestrator` | `{"fip_settle_seconds":N,"history_retention_cycles":M}` | `200` | `400`, если `N < 0` или `M < 0`, или если `fip_settle_seconds + self_check_timeout_seconds >= lease_ttl_seconds` (пауза не должна съедать весь лизинг адреса — иначе self-check не успеет пройти до истечения `lease_ttl_seconds`, и адрес будет вечно возвращаться в очередь) |
|
||||
| GET | `/api/v1/admin/config/orchestrator` | — | `{"fip_settle_seconds":N,"history_retention_cycles":M,"self_check_max_attempts":K}` | |
|
||||
| PUT | `/api/v1/admin/config/orchestrator` | `{"fip_settle_seconds":N,"history_retention_cycles":M,"self_check_max_attempts":K}` | `200` | `400`, если `N < 0`, `M < 0` или `K` вне 1…50, или если `fip_settle_seconds + self_check_timeout_seconds >= lease_ttl_seconds` (пауза не должна съедать весь лизинг адреса — иначе self-check не успеет пройти до истечения `lease_ttl_seconds`, и адрес будет вечно возвращаться в очередь) |
|
||||
|
||||
Как и остальные разделы этой группы, YAML-поле `orchestrator.
|
||||
fip_settle_seconds` в `control-api.yaml` — только одноразовый bootstrap
|
||||
@@ -859,3 +1018,94 @@ curl -s "$BASE/api/v1/admin/ips/203.0.113.10" | python3 -m json.tool
|
||||
|
||||
Для полностью автоматизированного локального прогона (без ручных curl)
|
||||
см. `scripts/run-local-e2e.sh` и [docs/LOCAL_E2E.md](LOCAL_E2E.md).
|
||||
|
||||
## Аналитика запусков
|
||||
|
||||
Запуск — одна «партия» проверок. Он открывается, когда адрес попадает в пустую (или полностью обработанную)
|
||||
очередь; пока он открыт, в него входят все добавленные и перепроверяемые адреса. Запуск завершается, когда все его
|
||||
адреса получили итог (`done`, `failed`, `occupied`) либо удалены из очереди. Перепроверка после завершения запуска
|
||||
открывает **новый** запуск, прежний не меняется. Тип запуска: `auto` (скан автоцикла) или `manual`. Для одного адреса
|
||||
в запуске хранится результат его последнего цикла. Для данных, накопленных до появления запусков, запуски выделены по
|
||||
паузам: циклы, которые заканчиваются с промежутком меньше часа, образуют один запуск.
|
||||
|
||||
### `GET /api/v1/admin/analytics/runs`
|
||||
|
||||
Список запусков, новые первыми, для выбора на странице «Аналитика».
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": 2, "kind": "manual", "state": "open", "started_at": "2026-10-03T15:30:00Z", "finalized_at": null,
|
||||
"addresses": 120, "pass": 40, "partial": 80, "fail": 0, "cancelled": 0, "total": 200, "pending": 80},
|
||||
{"id": 1, "kind": "manual", "state": "finalized", "started_at": "2026-10-02T13:46:45Z", "finalized_at": "2026-10-02T22:28:54Z",
|
||||
"addresses": 6440, "pass": 1962, "partial": 4478, "fail": 0, "cancelled": 0, "total": 6440, "pending": 0}
|
||||
]
|
||||
```
|
||||
|
||||
`addresses` — адреса с итогом, `total` — все адреса запуска в очереди, `pending` — ещё в работе.
|
||||
|
||||
### `GET /api/v1/admin/analytics/runs/{id}`
|
||||
|
||||
Все показатели страницы по одному **завершённому** запуску; открытый запуск — `409`, неизвестный — `404`.
|
||||
Считаются проверки последнего цикла каждого адреса в запуске, в том числе пришедшие позже вердикта (как факты).
|
||||
Результат кэшируется, пока данные запуска и список подсетей не менялись.
|
||||
|
||||
| Блок | Содержимое |
|
||||
|---|---|
|
||||
| `run` | `id`, `kind`, `state`, `started_at`, `finalized_at`, `duration_seconds`, `rechecked` (адресов с несколькими циклами в запуске) |
|
||||
| `summary` | `addresses`, `pass`, `partial`, `fail`, `cancelled`; `egress_ok`, `ingress_ok` (адреса, у которых все записанные проверки уровня успешны); `egress_https_any_failed` и `egress_https_all_failed` (хотя бы одна / все https-проверки провалены), `egress_https_all_targets_failed` (все цели полного набора); `ingress_ssh_any_failed`, `ingress_ssh_all_failed`; `addresses_per_minute` |
|
||||
| `reasons` | причины `partial`, каждый адрес один раз: «Только egress», «Ingress и egress», «Egress и неполный набор», «Ingress, egress и неполный набор», «Только неполный набор», «Только ingress»; нулевые не выдаются |
|
||||
| `quality` | `late_failed_checks_at_pass`, `late_failed_addresses_at_pass`, `ingress_failed_checks`, `ingress_failed_late`, `incomplete_addresses`, `pass_with_failed_addresses`, `pass_by_facts` |
|
||||
| `subnets` | по подсети: `cidr`, `label`, `addresses`, `pass`, `egress_ok`, `ingress_ok` (без списка подсетей — группы по /24; адрес вне списка — «прочие») |
|
||||
| `targets` | `types` (семейства egress-проверок), `targets` (хосты, по убыванию провалов https), `failed` (по типу: число адресов с провалом на каждую цель) |
|
||||
| `matrix` | по типу: строки «подсеть × цель» для подсетей с `partial` (`partial`, `percent` по целям) |
|
||||
| `sites` | `types` и строки площадок: `total` и `ok` проверок по типу |
|
||||
| `errors` | классы ошибок проваленных ingress-проверок («SSH: таймаут», «ICMP: нет ответа», …) со счётчиками |
|
||||
| `validators` | по валидатору: `total`, `ok` https-проверок egress |
|
||||
|
||||
Тип проверки — это `check_type` до первого дефиса: `tcp-22` и `tcp-443` дают `tcp`.
|
||||
|
||||
### `GET /api/v1/admin/analytics/runs/{id}/lists/{kind}`
|
||||
|
||||
Таблица адресов за показателем или классом ошибки: `{"kind", "class", "columns": [...], "rows": [[...]]}`.
|
||||
`kind`: `verdict_pass`, `verdict_partial`, `verdict_fail`, `egress_https_any`, `egress_https_all`, `ingress_ssh_any`, `ingress_ssh_all` или `error` (с `?class=SSH: таймаут`;
|
||||
без класса и неизвестный `kind` — `404`). С `?format=csv` — файл CSV (UTF-8 с BOM, `Content-Disposition: attachment`,
|
||||
имя вида `ingress_ssh_all_run1.csv`). Для `verdict_*` — адреса запуска с этим вердиктом (без `cancelled`, по числовому порядку; число строк равно `summary.pass`/`partial`/`fail`): адрес, подсеть, валидатор (по https-проверкам, «—», если их нет), `Egress` и `Ingress` («успешно из всех», «—» без проверок), «Проверок в цикле» (записано из ожидаемых) и у `partial` ещё «Причина» (как в блоке `reasons`). Для `error` строка — одна проваленная проверка: адрес, подсеть, площадка,
|
||||
валидатор, вердикт адреса, статус («провал, в вердикте» или «провал, после вердикта»).
|
||||
|
||||
### `GET /api/v1/admin/analytics/compare?base=A&target=B`
|
||||
|
||||
Сравнение двух завершённых запусков: `base` — старый (A), `target` — новый (B). Адрес — это IP; адрес с итогом
|
||||
`cancelled` в запуск не входит (их число — в `cancelled`). Ответ:
|
||||
|
||||
| Поле | Содержимое |
|
||||
|---|---|
|
||||
| `runs` | `base` и `target`: сведения о запуске и `addresses` |
|
||||
| `groups` | `new` (есть в B, нет в A), `left` (были в A, нет в B), `common` (в обоих), `changed`, `same`; `common = changed + same` |
|
||||
| `indicators` | по семи индикаторам (`verdict_pass`, `verdict_partial`, `verdict_fail`, `egress_https_any`, `egress_https_all`, `ingress_ssh_any`, `ingress_ssh_all`): `base`, `target`, `delta`, `new`, `left`, `entered`, `exited`; `delta = new − left + entered − exited` |
|
||||
| `transitions` | `verdicts`, `matrix[из][в]` вердиктов общих адресов, `new` и `left` — новые/выбывшие по вердикту |
|
||||
| `cancelled` | отменённые адреса в `base` и `target` |
|
||||
|
||||
Общий адрес **изменился**, если его принадлежность хотя бы к одному из семи индикаторов в A и B разная (другой набор
|
||||
проваленных целей или площадок при тех же индикаторах — не изменение). `400` — нет или неверные `base`/`target` либо
|
||||
они совпадают, `404` — запуска нет, `409` — запуск ещё идёт.
|
||||
|
||||
### `GET /api/v1/admin/analytics/compare/lists/{group}?base=A&target=B`
|
||||
|
||||
Таблица адресов группы `new`, `left`, `common`, `changed`, `same`, `entered` или `exited`. Фильтры: `indicator` (ключ
|
||||
индикатора; для `entered` и `exited` обязателен; для `new`/`left` — адрес входит в индикатор в своём запуске, для
|
||||
`common`/`changed`/`same` — хотя бы в одном из запусков), `from` и `to` вместе (вердикт в A и в B; только для общих групп).
|
||||
Неизвестные группа, индикатор или фильтр — `404`. Столбцы `new` и `left`: адрес, подсеть, вердикт, Egress, Ingress,
|
||||
индикаторы. Остальные группы: адрес, подсеть, вердикт, Egress и Ingress в виде `A → B` и «Что изменилось» (вердикт,
|
||||
вход в индикаторы и выход из них, добавленные и убранные цели https и площадки ssh, смена валидатора; у группы без
|
||||
изменений — «без изменений»). С `?format=csv` — файл CSV (UTF-8 с BOM), имя `compare_<group>[_<indicator>][_<from>-<to>]_run<A>-<B>.csv`.
|
||||
|
||||
### `GET /api/v1/admin/config/subnets`, `PUT /api/v1/admin/config/subnets`
|
||||
|
||||
Список подсетей, по которым группируются адреса на странице «Аналитика». `PUT` заменяет список целиком:
|
||||
|
||||
```json
|
||||
{"subnets": [{"cidr": "83.166.248.0/21", "label": "москва"}, {"cidr": "10.0.0.0/8"}]}
|
||||
```
|
||||
|
||||
CIDR приводится к канонической записи (`10.1.2.3/24` → `10.1.2.0/24`), повторы схлопываются; неверный CIDR — `400`,
|
||||
список остаётся прежним. Адрес относится к самой узкой подходящей подсети.
|
||||
+73
-29
@@ -46,54 +46,56 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
|
||||
|
||||
## Страницы и что на них можно делать
|
||||
|
||||
Сайдбар слева: вверху логотип, под ним блок сессии (состояние связи с `control-api` — зелёный индикатор, красный
|
||||
«нет связи» при сбое, переключатель темы, при включённом входе имя пользователя и кнопка «Выйти»), ниже разделы в двух
|
||||
группах: «Мониторинг» (Обзор, Очередь IP, Реестр, Аналитика) и «Настройка» (Валидаторы, Площадки, Цели, Типы проверок,
|
||||
Настройки).
|
||||
|
||||
| Страница | Назначение |
|
||||
|---|---|
|
||||
| `/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` | **Реестр** — все адреса, когда-либо поставленные на проверку, независимо от того, стоят ли они сейчас в очереди. Переживает удаление адреса из `/ips` и повторное добавление того же адреса позже (см. «Реестр адресов» ниже). Поиск по IP и фильтр по статусу — то же самое, что на `/overview`, плюс отражается в адресной строке (`?q=&status=`), так что отфильтрованную ссылку можно сохранить/переслать. В колонке «Последний результат» под вердиктом — уровни **Egress** и **Ingress** в виде «N из M» с разбивкой по типам проверок (`https`, `icmp`, `ssh`, `tcp`, `tls`…), см. [USAGE.md](USAGE.md#реестр-адресов-и-глубина-истории). |
|
||||
| `/registry/{ip}` | Полная сохранённая история проверок одного адреса по всем циклам (не только текущему) — в отличие от `/ips/{ip}`, которая показывает только текущую попытку. |
|
||||
| `/analytics` | **Аналитика** одного завершённого запуска (`?run=ID`, по умолчанию последний): выбор запуска (идущий виден, но недоступен), показатели, причины `partial`, качество данных, подсети, egress по целям (по типу проверки, тепловая карта «подсеть × цель»), ingress по площадкам, классы ошибок, валидаторы. Карточки `pass`/`partial`/`fail`, карточки провалов `https`/`ssh` и классы ошибок открывают список адресов с выгрузкой в CSV. Кнопка «Сравнить с другим запуском» ведёт на `/analytics/compare?base=A&target=B`: сравнение двух запусков (новые, выбывшие и изменившиеся адреса, динамика по семи индикаторам, матрица переходов вердикта; числа открывают списки с CSV). Подробности — [USAGE.md](USAGE.md#аналитика-запусков). |
|
||||
| `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. |
|
||||
| `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). |
|
||||
| `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. |
|
||||
| `/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#управление-типами-проверок-пробера)). |
|
||||
| `/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)); `self_check_max_attempts` — «Потолок провалов self-check на адрес» (1…50, по умолчанию 5; повторы идут на других валидаторах, см. [USAGE.md](USAGE.md#повтор-после-сбоя-self-check)); `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 +159,43 @@ 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/*`, требуют входа.
|
||||
Не заданы — дашборд открыт, в логе предупреждение `dashboard login is disabled`.
|
||||
|
||||
- **Вход:** страница `/login` (логин и пароль единственного администратора). Неверная пара — «Неверный логин или пароль», cookie не выдаётся.
|
||||
Без сессии обычный запрос получает редирект `303` на `/login?next=…` (после входа — возврат на исходную страницу; `next` принимается только как
|
||||
относительный путь на этом же сайте).
|
||||
- **Сессия** хранится в cookie `session` (подпись HMAC-SHA256, `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS), состояния на сервере нет —
|
||||
дашборд остаётся stateless. Срок — `auth.session_ttl_minutes` (по умолчанию 480 минут). Кнопка «Выйти» (вверху сайдбара) стирает cookie в браузере;
|
||||
скопированная cookie остаётся валидной до истечения срока. Сбросить все сессии сразу — сменить `ADMIN_DASHBOARD_SESSION_SECRET` и перезапустить дашборд.
|
||||
- **Фоновое обновление.** Когда сессия истекла, htmx-запросы (опрос `/overview/fragment`) получают `401` с `HX-Redirect: /login` — браузер
|
||||
уходит на страницу входа целиком, а не подставляет её внутрь фрагмента.
|
||||
- **CSRF:** изменяющие запросы (`POST`/`PUT`/`DELETE`) принимаются, только если `Origin` (или `Referer`) совпадает с хостом дашборда;
|
||||
токены в формах не нужны. Reverse-proxy, подменяющий заголовок `Host`, получит `403` на изменяющие запросы.
|
||||
- **Перебор пароля:** 5 неудачных попыток входа с одного IP за 10 минут → `429` с `Retry-After`; пока действует блокировка, отклоняется и верный пароль.
|
||||
Счётчик считает по адресу TCP-соединения и не доверяет `X-Forwarded-For`, поэтому за reverse-proxy все клиенты окажутся в одной корзине.
|
||||
- **Токен к control-api.** Дашборд обращается к API с токеном администратора (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`); если он неверен, страницы
|
||||
показывают баннер с ответом `401` от control-api.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
См. `configs/admin-dashboard.example.yaml`. Ключевые поля:
|
||||
@@ -168,6 +207,11 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
|
||||
на странице обзора.
|
||||
- `overview.poll_interval_seconds` — как часто браузер опрашивает
|
||||
`/overview/fragment` для live-обновления.
|
||||
- `control_api.token_env` — имя переменной окружения с токеном администратора control-api
|
||||
(по умолчанию `ADMIN_DASHBOARD_CONTROL_API_TOKEN`).
|
||||
- `auth.username_env`, `auth.password_env`, `auth.session_secret_env` — имена переменных с логином, паролем и ключом подписи сессии
|
||||
(по умолчанию `ADMIN_DASHBOARD_USERNAME`, `ADMIN_DASHBOARD_PASSWORD`, `ADMIN_DASHBOARD_SESSION_SECRET`); `auth.session_ttl_minutes` — срок сессии
|
||||
(480). Подробности — [«Вход и сессия»](#вход-и-сессия).
|
||||
|
||||
## Отображение ошибок
|
||||
|
||||
|
||||
+2
-1
@@ -226,7 +226,8 @@ flowchart TB
|
||||
только все ожидаемые флаги выставлены — либо истекло время ожидания
|
||||
(`checking_window_seconds`) — фоновая агрегация суммирует все строки
|
||||
`checks` по текущей попытке и записывает итог (`pass`/`partial`/`fail`)
|
||||
обратно в `ip_queue`. Оператор в любой момент читает уже накопленные
|
||||
обратно в `ip_queue`. С началом агрегации запись проверок по адресу
|
||||
закрыта: опоздавшие результаты отбрасываются (событие `result_dropped`). Оператор в любой момент читает уже накопленные
|
||||
данные через административные `GET`-методы, не дожидаясь завершения
|
||||
проверки — подробнее о значениях полей см.
|
||||
[USAGE.md](USAGE.md#значения-полей-ip).
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+47
-5
@@ -287,6 +287,35 @@ cp configs/prober.example.yaml /etc/cloud-ip-validator/prober.yaml
|
||||
- `control_api_url` — адрес control-api, доступный с площадки (обычно
|
||||
через интернет — площадки внешние).
|
||||
|
||||
### 5. Аутентификация: токены и пароль дашборда
|
||||
|
||||
Доступ к API и дашборду защищается секретами из переменных окружения (в YAML значения не хранятся; в `*.yaml` — только *имена*
|
||||
переменных, и менять их нужно редко). Токены генерируются случайными: `openssl rand -hex 32`. Схема доступа к методам —
|
||||
[API.md](API.md#аутентификация).
|
||||
|
||||
| Где | Переменная | Назначение |
|
||||
|---|---|---|
|
||||
| control-api | `CONTROL_API_ADMIN_TOKEN` | токен администратора: закрывает `/api/v1/admin/*` |
|
||||
| control-api | `CONTROL_API_AGENT_TOKEN` | токен агентов: закрывает запись результатов валидаторов и проберов |
|
||||
| validator-agent, prober | `CONTROL_API_AGENT_TOKEN` | тот же токен агентов (отправляется как Bearer) |
|
||||
| admin-dashboard | `ADMIN_DASHBOARD_CONTROL_API_TOKEN` | токен администратора control-api (то же значение, что `CONTROL_API_ADMIN_TOKEN`) |
|
||||
| admin-dashboard | `ADMIN_DASHBOARD_USERNAME`, `ADMIN_DASHBOARD_PASSWORD` | логин и пароль единственного администратора дашборда |
|
||||
| admin-dashboard | `ADMIN_DASHBOARD_SESSION_SECRET` | ключ подписи cookie-сессии (случайная строка; без него — случайный на каждый запуск, сессии сбрасываются рестартом) |
|
||||
|
||||
- **Пустое значение = защита выключена.** Токен не задан — соответствующий уровень API открыт; логин/пароль не заданы — дашборд открыт. В обоих
|
||||
случаях в логе при старте — предупреждение. Это сделано для обратной совместимости; на реальном стенде задайте всё.
|
||||
- systemd: добавьте переменные в `/etc/cloud-ip-validator/<компонент>.env` (подключается `EnvironmentFile=`, файл `chmod 600`). Docker: переменные
|
||||
из `.env` (см. `.env.example`).
|
||||
- Ключи `auth.*` и `*_token_env` в YAML меняют только имена переменных; время жизни сессии — `auth.session_ttl_minutes` дашборда (по умолчанию 480).
|
||||
- Токены и пароль передаются открытым текстом, если перед сервисами нет TLS: публикуйте API и дашборд через reverse-proxy с TLS.
|
||||
|
||||
**Порядок включения без простоя** (особенно когда валидаторы и пробер на других машинах):
|
||||
1. обновите бинарники всех компонентов — токены ещё не заданы, всё работает как раньше;
|
||||
2. задайте `CONTROL_API_AGENT_TOKEN` на валидаторах и проберах, `ADMIN_DASHBOARD_*` на дашборде и перезапустите их;
|
||||
3. **последним** задайте `CONTROL_API_ADMIN_TOKEN` и `CONTROL_API_AGENT_TOKEN` на control-api и перезапустите его.
|
||||
Если включить токен агентов на control-api раньше, чем он появится у валидатора или пробера, их результаты будут получать `401` и проверки не завершатся.
|
||||
Ротация токена — та же последовательность с новым значением.
|
||||
|
||||
## Развёртывание control-api
|
||||
|
||||
```bash
|
||||
@@ -672,10 +701,17 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name validator-agent \
|
||||
| `VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS` | нет | `5` |
|
||||
|
||||
`--cap-add NET_RAW` обязателен для ICMP-проверок, как и у `prober`.
|
||||
`self_check.ip_echo_urls` в переменные не вынесен — при отсутствии в
|
||||
конфиге агент сам подставляет дефолт (`api.ipify.org`, `ifconfig.me`);
|
||||
свой список задавайте через смонтированный конфиг вместо шаблона, если
|
||||
нужно переопределить.
|
||||
`self_check.ip_echo_urls` и `self_check.methods` в переменные не вынесены —
|
||||
при отсутствии в конфиге агент сам подставляет дефолты (`api.ipify.org`,
|
||||
`ifconfig.me` и `methods: [ip_echo]`); свои значения задавайте через
|
||||
смонтированный конфиг вместо шаблона, если нужно переопределить.
|
||||
`methods` — способы самопроверки в порядке приоритета (`ip_echo`,
|
||||
`control_api`), достаточно подтверждения любым. Способ `control_api`
|
||||
спрашивает у control-api, с какого адреса он видит валидатора
|
||||
(`GET /agents/{id}/observed-ip`); при внешнем размещении control-api
|
||||
рекомендуется `[control_api, ip_echo]`. Ограничение: если control-api
|
||||
достижим из облака по внутренней сети, он увидит приватный адрес
|
||||
валидатора и этот способ всегда даст несовпадение — используйте `ip_echo`.
|
||||
|
||||
### Обновление образов после изменения кода
|
||||
|
||||
@@ -703,6 +739,10 @@ docker compose up -d --build
|
||||
сборке) и `docker rm -f <имя> && docker run ... ` (или `docker restart`,
|
||||
если менялись только переменные окружения, а не сам бинарник/образ).
|
||||
|
||||
Для массового обновления валидаторов (git-клон, сборка образа на хосте,
|
||||
замена контейнера, проверка регистрации) есть Ansible-сценарий:
|
||||
[`deploy/ansible/`](../deploy/ansible/README.md).
|
||||
|
||||
### Диагностика Docker-развёртывания
|
||||
|
||||
- **Контейнер сразу падает, в логах `exec format error`** — образ собран
|
||||
@@ -775,7 +815,9 @@ curl -s http://<control-api>:8080/api/v1/admin/validators | python3 -m json.tool
|
||||
через floating IP, который в данный момент привязан к валидатору.
|
||||
- `validator-agent` → внешние IP-echo сервисы из `self_check.ip_echo_urls`
|
||||
(по умолчанию `api.ipify.org`, `ifconfig.me`) — **обязательно вне
|
||||
облака**: это и есть механизм self-check (см.
|
||||
облака**: это и есть механизм self-check способом `ip_echo` (при
|
||||
`self_check.methods` с `control_api` достаточно ещё и доступа к control-api
|
||||
по внешней сети; см.
|
||||
[DIAGRAMS.md](DIAGRAMS.md#2-поток-данных-от-валидатора-к-целевому-серверу-egress-проверка)).
|
||||
Если валидатор не может достучаться ни до одного из этих адресов,
|
||||
self-check никогда не пройдёт и IP будет бесконечно возвращаться в
|
||||
|
||||
+179
-12
@@ -7,6 +7,12 @@
|
||||
[API.md](API.md) — здесь мы используем их только как инструмент, не
|
||||
углубляясь в протокол.
|
||||
|
||||
> **Токен в примерах.** Если на стенде включена аутентификация
|
||||
> ([SETUP.md](SETUP.md#5-аутентификация-токены-и-пароль-дашборда)), к вызовам
|
||||
> `/api/v1/admin/*` в примерах `curl` ниже нужно добавлять заголовок
|
||||
> `-H "Authorization: Bearer $ADMIN_TOKEN"` (значение `CONTROL_API_ADMIN_TOKEN`);
|
||||
> для краткости он опущен. Дашборд запрашивает логин и пароль.
|
||||
|
||||
## Содержание
|
||||
|
||||
- [Как устроена работа с системой](#как-устроена-работа-с-системой)
|
||||
@@ -18,6 +24,7 @@
|
||||
- [Как читать итоговый результат (pass/partial/fail)](#как-читать-итоговый-результат-passpartialfail)
|
||||
- [Просмотр деталей и истории по конкретному адресу](#просмотр-деталей-и-истории-по-конкретному-адресу)
|
||||
- [Реестр адресов и глубина истории](#реестр-адресов-и-глубина-истории)
|
||||
- [Аналитика запусков](#аналитика-запусков)
|
||||
- [Управление валидаторами](#управление-валидаторами)
|
||||
- [Управление площадками (проберами)](#управление-площадками-проберами)
|
||||
- [Управление типами проверок пробера](#управление-типами-проверок-пробера)
|
||||
@@ -26,6 +33,7 @@
|
||||
- [Принудительная остановка проверки](#принудительная-остановка-проверки)
|
||||
- [Удаление адресов из очереди](#удаление-адресов-из-очереди)
|
||||
- [Пауза перед self-check (fip_settle_seconds)](#пауза-перед-self-check-fip_settle_seconds)
|
||||
- [Повтор после сбоя self-check](#повтор-после-сбоя-self-check)
|
||||
- [Частые проблемы и что с ними делать](#частые-проблемы-и-что-с-ними-делать)
|
||||
|
||||
## Как устроена работа с системой
|
||||
@@ -84,7 +92,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, но в очередь
|
||||
@@ -95,8 +104,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`
|
||||
@@ -105,6 +129,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» и оценка оставшегося времени.
|
||||
|
||||
## Автоматический цикл проверок
|
||||
|
||||
Опциональный режим, который сам повторяет то, что оператор делает руками:
|
||||
@@ -116,7 +145,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`). К этому моменту результат каждого
|
||||
@@ -130,7 +161,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` применяется к паузе
|
||||
@@ -164,7 +195,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`):
|
||||
|
||||
@@ -179,7 +211,8 @@ curl -s http://<control-api>:8080/api/v1/admin/auto-cycle
|
||||
### Что важно знать
|
||||
|
||||
- **Выключение не прерывает проверки**, которые уже идут: они закончатся и попадут
|
||||
в реестр, остановится только повторение.
|
||||
в реестр, остановится только повторение. Если выключить цикл во время фазы `scanning`,
|
||||
сканирование отменяется (уже поставленные в очередь куски остаются).
|
||||
- Автоцикл **владеет очередью**: каждый цикл начинается с её полной очистки,
|
||||
поэтому адреса, добавленные вручную, будут удалены (их история в реестре
|
||||
остаётся). Ручные «Очистить всё» и «Сканировать Floating IP» во время цикла
|
||||
@@ -189,6 +222,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`.
|
||||
@@ -268,8 +307,9 @@ curl -s http://<control-api>:8080/api/v1/admin/ips \
|
||||
оператора: годится ли адрес для данного случая использования.
|
||||
- **`fail`** — либо ни одна проверка не прошла, либо адрес вообще не
|
||||
дошёл до стадии проверок (например, self-check не подтвердился —
|
||||
трафик валидатора не пошёл через назначенный FIP — и попытки
|
||||
исчерпались). Смотрите `events` по этому адресу (см. ниже), чтобы
|
||||
трафик валидатора не пошёл через назначенный FIP — и число провалов
|
||||
достигло потолка `self_check_max_attempts`, см.
|
||||
[«Повтор после сбоя self-check»](#повтор-после-сбоя-self-check)). Смотрите `events` по этому адресу (см. ниже), чтобы
|
||||
понять, на каком шаге и почему.
|
||||
- **`cancelled`** — проверку остановил оператор через `POST
|
||||
/api/v1/admin/ips/{ip}/cancel` (`State` при этом — `failed`), а не
|
||||
@@ -294,7 +334,19 @@ curl -s http://<control-api>:8080/api/v1/admin/ips \
|
||||
Отсутствие ответа от источника (площадка не прислала результат до
|
||||
истечения `checking_window_seconds`) засчитывается как провал — это
|
||||
управляется настройкой `aggregation.missing_counts_as_fail` в конфиге
|
||||
control-api (по умолчанию включено).
|
||||
control-api (по умолчанию включено). Окно `checking_window_seconds`
|
||||
отсчитывается от начала проверки (состояние `checking`), а не от выдачи
|
||||
адреса валидатору, поэтому привязка Floating IP, пауза `fip_settle_seconds`
|
||||
и self-check в окно не входят.
|
||||
|
||||
**Вердикт и проверки не расходятся.** Проверки фиксируются в момент
|
||||
вердикта: результат, пришедший после него (опоздавший), не сохраняется и не
|
||||
меняет проверки адреса — Floating IP к этому времени уже отвязан, и такая
|
||||
проверка ничего не доказывает. Каждая площадка зондирует адрес один раз за
|
||||
попытку. Отброшенные результаты видны как события `result_dropped` у адреса;
|
||||
в нормальном прогоне их нет или единицы. Недостающий результат (источник не
|
||||
прислал его вовсе) по-прежнему считается провалом, вердикт тогда не выше
|
||||
`partial`.
|
||||
|
||||
## Просмотр деталей и истории по конкретному адресу
|
||||
|
||||
@@ -348,6 +400,18 @@ curl -s http://<control-api>:8080/api/v1/admin/registry/203.0.113.10 | python3 -
|
||||
`/overview`, плюс он отражается в адресной строке (`?q=&status=`), так что
|
||||
отфильтрованную ссылку можно сохранить или переслать.
|
||||
|
||||
В колонке «Последний результат» под вердиктом (`pass`/`partial`/`fail`)
|
||||
показаны два уровня последнего цикла: **Egress** (выходные проверки
|
||||
валидатора) и **Ingress** (входные проверки пробера со всех площадок) в виде
|
||||
«успешно из всего», например `Egress 5 из 5`, `Ingress 3 из 4`. Под каждым
|
||||
уровнем — то же по типам проверок (`https 3 из 3`, `icmp 2 из 2`, `tcp 2 из 3`);
|
||||
порты объединены в тип (`tcp-22` и `tcp-443` — один `tcp`), новый тип
|
||||
проверки появится в списке сам. Цвет: зелёный — все успешны, красный — ни одной,
|
||||
жёлтый — часть. Считаются записанные проверки цикла; вердикт учитывает ещё и
|
||||
недостающие результаты, поэтому при неполном наборе он может быть хуже, чем
|
||||
видно по счётчикам (подсказка при наведении). Те же данные — в
|
||||
`GET /api/v1/admin/registry` ([API.md](API.md#get-apiv1adminregistry)).
|
||||
|
||||
**Глубина хранения.** Чтобы история не росла бесконечно на адресах,
|
||||
которые перепроверяют очень часто, можно ограничить, сколько последних
|
||||
циклов проверки хранить на каждый адрес — `history_retention_cycles` на
|
||||
@@ -358,6 +422,53 @@ curl -s http://<control-api>:8080/api/v1/admin/registry/203.0.113.10 | python3 -
|
||||
существует, когда впервые встречен, сколько всего было циклов) не
|
||||
удаляется никогда.
|
||||
|
||||
## Аналитика запусков
|
||||
|
||||
Страница `/analytics` в дашборде показывает результаты **одного завершённого запуска проверки**: итоги, причины
|
||||
`partial`, подсети, провалы по целям, ingress по площадкам, классы ошибок, валидаторы и качество данных. Данные
|
||||
других запусков на странице не участвуют, поэтому результаты разных прогонов не пересекаются.
|
||||
|
||||
**Что такое запуск.** Запуск открывается, когда адрес попадает в пустую (или полностью обработанную) очередь, а
|
||||
скан автоцикла помечает его как `авто`. Пока он открыт, в него входят все добавленные и перепроверяемые адреса.
|
||||
Когда у всех адресов запуска есть итог, запуск завершается и появляется в списке. Перепроверка после этого
|
||||
открывает **новый** запуск; результаты прежнего остаются как были. Идущий запуск виден в списке, но недоступен
|
||||
(«идёт, 120 из 800»). Запуски, накопленные до появления этой функции, выделены по паузам больше часа.
|
||||
|
||||
**Как читать числа.** Показатели считаются по фактическим проверкам последнего цикла каждого адреса, включая
|
||||
пришедшие позже вердикта; вердикт системы показан рядом. `Egress OK` и `Ingress OK` — доли адресов, у которых все
|
||||
записанные проверки уровня успешны. Блок «Качество данных» показывает, насколько вердикт расходится с проверками
|
||||
(поздние результаты, неполный набор). После изменения «вердикт без опоздавших результатов» поздних результатов в новых
|
||||
запусках быть не должно.
|
||||
|
||||
**Что можно открыть.** Карточки `pass`, `partial` и `fail` (список адресов с этим вердиктом: подсеть, валидатор, Egress и Ingress «успешно из всех», число проверок в цикле, у `partial` ещё причина; `fail` с нулём не кликается), карточки «Egress https: есть провалы / все провалены» и «Ingress ssh: есть провалы / все
|
||||
провалены», а также каждая строка блока «Классы ошибок ingress» открывают окно со списком адресов (для класса ошибок
|
||||
— с распределением по валидаторам и статусом каждой проверки). В окне кнопки «Скачать CSV» (файл от control-api,
|
||||
UTF-8 с BOM, открывается в Excel) и «Копировать». Строка подсети и строка матрицы «подсеть × цель» ведут в «Реестр»
|
||||
с фильтром по запуску и подсети (`/registry?run=…&subnet=…`).
|
||||
|
||||
**Сравнение запусков.** Страница `/analytics/compare` (кнопка «Сравнить с другим запуском» на `/analytics`) показывает
|
||||
динамику между двумя завершёнными запусками: A (старый) и B (новый) выбираются из списков, «Поменять местами» меняет
|
||||
их роли; по умолчанию B — последний запуск, A — предыдущий; выбор хранится в адресе страницы. Страница выделяет три
|
||||
группы адресов: **новые** (есть в B, не было в A: пришли в проект), **выбывшие** (были в A, в B нет) и **изменившиеся**
|
||||
(есть в обоих, но принадлежность к индикаторам разная; в списке столбец «Что изменилось»). Таблица «Динамика по
|
||||
индикаторам» даёт по каждому из семи индикаторов (`pass`, `partial`, `fail`, «Egress https» и «Ingress ssh» — «есть
|
||||
провалы» и «все провалены») значения в A и B, разность и её разложение: новые, выбывшие, вошли, вышли. «Переходы
|
||||
вердикта» — матрица 3×3 по общим адресам. Все числа открывают список адресов с выгрузкой в CSV. Адреса с итогом
|
||||
`cancelled` в сравнение не входят. Если запуски разного состава (например, перепроверка части адресов), новых и
|
||||
выбывших будет много: это состав запусков, а не динамика. API — [API.md](API.md#get-apiv1adminanalyticscomparebasea-targetb).
|
||||
|
||||
**Подсети.** Список задаётся на `/settings` (блок «Подсети»): по одной в строке, CIDR и, через пробел, подпись. Адрес
|
||||
относится к самой узкой подходящей подсети, остальные идут в строку «прочие». Пока список пуст, адреса
|
||||
группируются по /24. То же через API: `PUT /api/v1/admin/config/subnets`.
|
||||
|
||||
```bash
|
||||
curl -s http://<control-api>:8080/api/v1/admin/analytics/runs | python3 -m json.tool
|
||||
curl -s http://<control-api>:8080/api/v1/admin/analytics/runs/1 | python3 -m json.tool
|
||||
curl -s -o egress.csv "http://<control-api>:8080/api/v1/admin/analytics/runs/1/lists/egress_https_all?format=csv"
|
||||
```
|
||||
|
||||
Подробности и состав ответов — в [API.md](API.md#аналитика-запусков).
|
||||
|
||||
## Управление валидаторами
|
||||
|
||||
Список валидаторов и их текущее состояние:
|
||||
@@ -371,6 +482,17 @@ curl -s http://<control-api>:8080/api/v1/admin/validators | python3 -m json.tool
|
||||
(занят), `unreachable` (пропустил heartbeat дольше
|
||||
`orchestrator.heartbeat_timeout_seconds`).
|
||||
|
||||
Правила, которые держат состояние валидатора согласованным:
|
||||
- валидатор держит **не более одного адреса**; адрес освобождает валидатор
|
||||
только пока он остаётся его текущим (запоздалое завершение старого адреса
|
||||
чужого валидатора не освобождает);
|
||||
- после пропущенного heartbeat валидатор, у которого есть адрес, возвращается
|
||||
в `assigned`, а не в `idle`, и не получает второй адрес; без адреса — в `idle`;
|
||||
- `unreachable`-валидатор не получает адресов, пока не пришлёт heartbeat
|
||||
(даже если лизинг его адреса истёк и адрес вернулся в очередь);
|
||||
- на каждом такте оркестратор сверяет валидаторы с очередью и исправляет
|
||||
расхождения (в логе `repaired validators that disagreed with the queue`).
|
||||
|
||||
**Добавление нового валидатора (без перезапуска control-api):**
|
||||
1. Поднимите новую ВМ в сервисном проекте облака, узнайте её Neutron
|
||||
`port_id`.
|
||||
@@ -653,12 +775,49 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/delete \
|
||||
curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/clear
|
||||
```
|
||||
|
||||
Очистка отвязывает Floating IP только у адресов, которые ещё в работе
|
||||
(завершённые уже свободны), затем сама опрашивает порты валидаторов в облаке и
|
||||
снимает оставшиеся привязки адресов из реестра. Занимает секунды. Она не
|
||||
прерывается разрывом соединения (таймаутом клиента или дашборда): операция
|
||||
доводится до конца на стороне control-api, предел — 10 минут.
|
||||
|
||||
В `admin-dashboard` то же самое доступно на странице `/ips`: чекбоксы у
|
||||
каждой строки + кнопка «Удалить выбранные» для точечного/массового
|
||||
удаления, кнопка «Удалить» в каждой строке, и отдельная кнопка «Очистить
|
||||
всё» — каждая с подтверждением, явно предупреждающим о необратимости
|
||||
(см. [DASHBOARD.md](DASHBOARD.md)).
|
||||
|
||||
## Повтор после сбоя self-check
|
||||
|
||||
Если self-check адреса не прошёл на валидаторе N, адрес возвращается в
|
||||
очередь, но **этому же валидатору больше не выдаётся**: повтор достаётся
|
||||
другому. Правило действует только для этого адреса. Валидатор N не
|
||||
блокируется и не помечается неисправным, он продолжает брать остальные
|
||||
адреса. Так один временно неисправный валидатор (например, у него не
|
||||
работает трансляция плавающего IP) не расходует все попытки адреса.
|
||||
|
||||
Итог `fail` ставится, когда число проваленных self-check у адреса достигло
|
||||
потолка **«Потолок провалов self-check на адрес»** (`self_check_max_attempts`,
|
||||
по умолчанию 5; `/settings` или `PUT /api/v1/admin/config/orchestrator`,
|
||||
допустимо 1…50). Потолок не зависит от числа валидаторов и действует на
|
||||
следующих повторах без перезапуска. Если все рабочие валидаторы
|
||||
(`idle`, `assigned`, `checking`) уже провалили адрес, а потолок не достигнут
|
||||
(валидаторов меньше потолка, в том числе один), исключения сбрасываются и
|
||||
повторы продолжаются до потолка.
|
||||
|
||||
Исключение создаёт только проваленный self-check. Ошибка привязки
|
||||
плавающего IP, потеря валидатора и истечение лизинга исключений не создают и
|
||||
идут по `orchestrator.max_retries`, как раньше. Поле
|
||||
`orchestrator.max_self_check_retries` в YAML больше не используется.
|
||||
|
||||
Ручная перепроверка или повторная постановка адреса начинает серию заново
|
||||
(счётчик сбоев обнуляется). История сбоев хранится постоянно (таблица
|
||||
`ip_self_check_failures`) и отдаётся в поле `self_check_failed_on` ответов
|
||||
`GET /admin/ips/{ip}` и `GET /admin/registry/{ip}`; на страницах `/ips/{ip}` и
|
||||
`/registry/{ip}` показана строка «Self-check не прошёл на: …». В журнале
|
||||
событий при повторе появляется `validator_excluded`. Показ на странице
|
||||
аналитики — отдельный следующий шаг.
|
||||
|
||||
## Пауза перед self-check (fip_settle_seconds)
|
||||
|
||||
Как только Floating IP привязывается к валидатору, control-api по
|
||||
@@ -705,7 +864,9 @@ curl -s -X PUT http://<control-api>:8080/api/v1/admin/config/orchestrator \
|
||||
дело обычно в self-check: он запрашивает внешние (вне облака) сервисы из
|
||||
`self_check.ip_echo_urls` в конфиге валидатора (по умолчанию
|
||||
`api.ipify.org`, `ifconfig.me`) — если у ВМ-валидатора нет исходящего
|
||||
доступа в интернет к этим адресам, запрос не проходит вообще, и агент
|
||||
доступа в интернет к этим адресам, запрос не проходит вообще (при
|
||||
`self_check.methods: [control_api, ip_echo]` агент сперва спросит адрес у
|
||||
control-api, и проверка может пройти и без IP-echo), и агент
|
||||
даже не может *сообщить* результат control-api (ни успешный, ни
|
||||
неуспешный) — тогда статус реально зависает до истечения
|
||||
`orchestrator.lease_ttl_seconds`, после чего адрес возвращается в
|
||||
@@ -716,6 +877,7 @@ https://api.ipify.org`) и логи `journalctl -u validator-agent` на пре
|
||||
|
||||
**Адрес постоянно проваливает self-check (не зависает, а именно
|
||||
возвращается в очередь снова и снова).**
|
||||
См. также [«Повтор после сбоя self-check»](#повтор-после-сбоя-self-check): повторы идут на других валидаторах, а список «Self-check не прошёл на: …» показан на странице адреса.
|
||||
Смотрите `events` по адресу (`GET /api/v1/admin/ips/{ip}`) — в детали
|
||||
события `self_check_result` будет указан обнаруженный исходящий адрес.
|
||||
Если он не совпадает с ожидаемым — вероятно, на ВМ-валидаторе есть другой
|
||||
@@ -726,7 +888,12 @@ https://api.ipify.org`) и логи `journalctl -u validator-agent` на пре
|
||||
облака (см. `self_check.ip_echo_urls`) — запрос к чему-либо внутри
|
||||
проекта (в том числе к самому control-api, если он в той же внутренней
|
||||
сети) покажет приватный адрес валидатора независимо от того, правильно
|
||||
ли привязан FIP, и всегда будет давать ложный провал.
|
||||
ли привязан FIP, и всегда будет давать ложный провал. Это относится и к
|
||||
способу `control_api` (`self_check.methods`): он корректен только когда
|
||||
валидатор ходит к control-api через внешнюю сеть; при внутреннем доступе в
|
||||
`detail` будет подсказка про приватный адрес — оставьте `ip_echo`. В
|
||||
`detail` события `self_check_result` указан сработавший способ
|
||||
(`matched (control_api)`) либо причина по каждому способу.
|
||||
|
||||
**Площадка (`site-N`) никогда не отчитывается по конкретному IP.**
|
||||
Сперва проверьте статус самой площадки — `GET
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# План: аутентификация API и UI
|
||||
|
||||
> Дата: 2026-10-01 11:12 MSK · Статус: **реализовано** — результаты ревью и тестов: [2026-10-01_11-31_authentication-review.md](2026-10-01_11-31_authentication-review.md)
|
||||
|
||||
## Context
|
||||
|
||||
Сейчас ни `control-api`, ни `admin-dashboard` не имеют аутентификации (прямо сказано в `docs/API.md`, раздел «Важно»):
|
||||
любой, кто достучится до порта, может менять очередь и конфигурацию, а в дашборд зайти без пароля. Нужно:
|
||||
1. закрыть **ручки управления** (`/api/v1/admin/*`) токеном администратора;
|
||||
2. оставить `validator-agent` и `prober` возможность **забрать свою настройку/задание без аутентификации**, но не дать
|
||||
подделывать результаты проверок — записывающие вызовы закрыть **отдельным токеном агентов** (без срока жизни);
|
||||
3. закрыть UI **логином и паролем** (сессия по cookie).
|
||||
|
||||
Решения пользователя: вариант «открыты только получение настройки/задания», токен агентов отдельный от админского и
|
||||
бессрочный (статический, из env); `control-api` без токена **стартует с предупреждением** в логе (обратная совместимость);
|
||||
пароль администратора дашборда — в env, сравнение в константное время.
|
||||
|
||||
## Карта кодовой базы (по графу, сверено с кодом)
|
||||
|
||||
Единые точки врезки — их мало, поэтому изменения локальны:
|
||||
- `httpapi.Server.Handler()` (`internal/httpapi/server.go:29`) — один `mux` + `loggingMiddleware`; все 45 маршрутов в
|
||||
`internal/httpapi/routes.go`: 33 admin, 7 agents, 4 probers, `GET /healthz`.
|
||||
- `apiclient.Client.Do` (`internal/apiclient/apiclient.go`) — единственный HTTP-клиент `agentcore` (`internal/agentcore/agentcore.go`)
|
||||
и `probercore` (`internal/probercore/probercore.go`) к control-api. Запросы к внешним целям/IP-echo идут через
|
||||
`http.DefaultClient`/`checkrunner` — токен туда попасть **не должен**.
|
||||
- `dashboard.client.do` (`internal/dashboard/client.go:48`) — единственный клиент дашборда к control-api (не `apiclient`).
|
||||
- `dashboard.Server.Handler()` (`internal/dashboard/server.go:43`) — один `mux` + `loggingMiddleware`; маршруты в
|
||||
`internal/dashboard/routes.go`; статика `GET /static/`; htmx опрашивает `/overview/fragment` каждые N секунд.
|
||||
- Конфиг: `internal/config/config.go` (`ControlAPI`, `AdminDashboard`/`DashboardControlAPIConfig`, агент/пробер),
|
||||
`Load*` с дефолтами «if x == 0». Секреты уже передаются **именами env-переменных** (`openstack.*_env`) — тот же приём.
|
||||
- `go.mod` без `x/crypto`; `crypto/subtle`, `crypto/hmac`, `crypto/sha256` — из стандартной библиотеки, новых зависимостей нет.
|
||||
|
||||
## Классификация маршрутов (45)
|
||||
|
||||
| Доступ | Маршруты |
|
||||
|---|---|
|
||||
| **admin-токен** (33) | все `/api/v1/admin/*`: status, ips (+scan/clear/delete/cancel/{ip}), validators, registry, auto-cycle ×4, config/* |
|
||||
| **agent-токен** (5) | `POST /agents/{id}/self-check`, `/events`, `/results`, `/complete`; `POST /probers/{site_id}/results` |
|
||||
| **открыто** (7) | `GET /healthz`; `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`; `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments` |
|
||||
|
||||
Токены разные: admin-токен **не** открывает agent-маршруты и наоборот. Heartbeat оставлен открытым, как в выбранном варианте
|
||||
(риск: подделка heartbeat «оживляет» упавший валидатор; при желании переносится под agent-токен одной строкой в таблице).
|
||||
|
||||
## Дизайн
|
||||
|
||||
### 1. control-api (`internal/httpapi`, `internal/config`, `cmd/control-api`)
|
||||
- **Таблица маршрутов с обязательным уровнем доступа.** `routes.go` переписывается на таблицу
|
||||
`[]route{pattern, handler, access}` (`accessOpen|accessAgent|accessAdmin`) — поле обязательное, забыть защитить новый
|
||||
маршрут нельзя; одна и та же таблица используется в тесте покрытия.
|
||||
- `internal/httpapi/auth.go`: `Authenticator{AdminToken, AgentToken string}`; `require(access, h)` читает
|
||||
`Authorization: Bearer …`, сравнивает через `sha256` + `subtle.ConstantTimeCompare`; при несовпадении — `401`
|
||||
`{"error":"unauthorized"}` + `WWW-Authenticate: Bearer`, в лог — метод/путь/remote без токена. Пустой токен соответствующего
|
||||
уровня ⇒ проверка этого уровня отключена (совместимость, как выбрал пользователь).
|
||||
- `Server` получает поле `Auth`, выставляемое методом `WithAuth(admin, agent)`; `httpapi.New(...)` не меняется ⇒ существующие
|
||||
тесты и `newConfigTestHarness` работают без токенов.
|
||||
- Конфиг: секция `auth:` в `control-api.yaml` — `admin_token_env` (по умолчанию `CONTROL_API_ADMIN_TOKEN`) и
|
||||
`agent_token_env` (`CONTROL_API_AGENT_TOKEN`); значения читаются из env в `run()`, в YAML секретов нет.
|
||||
- `cmd/control-api/main.go`: при пустом токене — `log.Warn("admin API is open: CONTROL_API_ADMIN_TOKEN is not set")`
|
||||
(аналогично для agent); значения токенов в логи не попадают.
|
||||
|
||||
### 2. validator-agent и prober (`internal/apiclient`, `internal/agentcore`, `internal/probercore`, `internal/config`)
|
||||
- `apiclient.Client` получает поле `Token`; `Do` добавляет `Authorization: Bearer <token>` ко **всем** запросам к control-api
|
||||
(на открытых маршрутах он безвреден). `apiclient.New` не меняется (поле выставляется отдельно).
|
||||
- Конфиги агента/пробера: `control_api_token_env` (по умолчанию `CONTROL_API_AGENT_TOKEN`); читается в `cmd/*/main.go`.
|
||||
Токен не попадает в `fetchIPEcho`/проверки (они используют отдельные клиенты).
|
||||
- Агент и пробер продолжают без токена регистрироваться и получать задания; результаты без токена получат `401` и будут
|
||||
залогированы существующим кодом обработки ошибок.
|
||||
|
||||
### 3. admin-dashboard (`internal/dashboard`, `internal/config`, `cmd/admin-dashboard`)
|
||||
- **Токен к control-api:** `control_api.token_env` (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`); `client.do` добавляет Bearer.
|
||||
Ответ `401/403` от API отображается существующим баннером (`apiErr`, `bannerFor`).
|
||||
- **Логин и пароль:** секция `auth:` — `username_env`, `password_env`, `session_secret_env`, `session_ttl_minutes` (480).
|
||||
Учётные данные сравниваются через `sha256` + `subtle.ConstantTimeCompare`. Если логин/пароль не заданы — вход не требуется,
|
||||
в лог предупреждение (как политика control-api).
|
||||
- `internal/dashboard/auth.go`: middleware вокруг `mux` в `Server.Handler()`:
|
||||
- открыто: `GET|POST /login`, `GET /static/*`; всё остальное требует валидной сессии;
|
||||
- без сессии: обычный запрос → `303 /login?next=…`; **htmx-запрос** (`HX-Request`) → `401` + `HX-Redirect: /login`, чтобы
|
||||
опрос `/overview/fragment` не подставлял страницу входа внутрь фрагмента;
|
||||
- **сессия без состояния** (дашборд остаётся stateless): cookie `session = base64(payload).hmac`, payload `{user, exp}`,
|
||||
HMAC-SHA256 ключом из `session_secret_env` (если не задан — случайный на старте + предупреждение: сессии сбрасываются
|
||||
рестартом); `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS (`X-Forwarded-Proto`/TLS);
|
||||
- **CSRF** для `POST/PUT/DELETE`: проверка `Origin`/`Referer` на совпадение с `Host` (+ `SameSite=Strict`); токены в
|
||||
шаблонах не нужны — htmx-формы не меняются;
|
||||
- **защита от перебора**: счётчик неудачных входов по IP в памяти (5 за 10 минут → `429` с `Retry-After`);
|
||||
- `POST /logout` — стирает cookie; пункт «Выйти» и имя пользователя в `sidebar_nav` (`templates/layout.html`).
|
||||
- `templates/login.html` — страница входа в стиле существующих (`html_head`, `.panel`, `.field`, `btn-primary`), баннер ошибки
|
||||
«Неверный логин или пароль». `loggingMiddleware` не логирует cookie и тело формы.
|
||||
|
||||
### 4. Развёртывание и конфигурация
|
||||
- Примеры: `configs/control-api.example.yaml` (`auth`), `admin-dashboard.example.yaml`, `validator-agent.example.yaml`,
|
||||
`prober.example.yaml`; копии в `rxprod-compose/sources/` и `deploy/docker/control-api/control-api.docker.example.yaml`.
|
||||
- Docker: `docker-entrypoint.sh` + `*.yaml.tmpl` дашборда/агента/пробера (whitelist `envsubst` + новые переменные),
|
||||
`deploy/docker/docker-compose*.yml`, `.env.example`/`.env.prod.example`, `RUN.txt`; `rxprod-compose/docker-compose.yml`
|
||||
(прокидывает env; реальные секреты — в gitignored `.env`).
|
||||
- systemd: `EnvironmentFile=` для `validator-agent`, `prober`, `admin-dashboard` (сейчас только у `control-api`).
|
||||
- Порядок раскатки без простоя: (1) обновить все бинарники — токены не заданы, API открыт; (2) выдать токены агентам, пробером
|
||||
и дашборду; (3) последним задать токены в `control-api` и перезапустить. Генерация: `openssl rand -hex 32`.
|
||||
- Токены по HTTP передаются открытым текстом — в документации рекомендация TLS на reverse-proxy; ротация = смена env + рестарт.
|
||||
|
||||
### 5. Тесты (plain `testing`, стиль существующих)
|
||||
- `internal/httpapi`: тест по **той же таблице маршрутов** — для каждого маршрута матрица {без токена, чужой токен, верный}
|
||||
× уровни; проверка, что admin-токен не открывает agent-маршруты и наоборот; открытые маршруты работают без токена;
|
||||
`/healthz` открыт; пустые токены ⇒ всё открыто; сквозной `TestEndToEndHTTPFlow` с токенами.
|
||||
- `internal/apiclient`: заголовок Bearer ставится, без токена — нет; `agentcore`/`probercore`: токен уходит только в control-api.
|
||||
- `internal/dashboard`: редирект на `/login`; `HX-Redirect` для htmx; вход верный/неверный; cookie-флаги; подделанная и
|
||||
просроченная cookie; выход; CSRF по `Origin`; throttle; `/static/*` открыт; токен уходит в control-api (fake API проверяет
|
||||
заголовок); при незаданных учётных данных вход не требуется.
|
||||
- `scripts/run-local-e2e.sh`: токены для control-api/агента/пробера; проверки: admin-ручка без токена → `401`, с токеном → `200`;
|
||||
`POST …/results` без токена → `401`; `register`/`assignment` без токена работают; весь прогон проходит с токенами.
|
||||
|
||||
### 6. Документация
|
||||
`docs/API.md` (заменить блок «Важно»: схема токенов, таблица доступа по маршрутам, `401`), `docs/SETUP.md` (переменные, генерация
|
||||
токенов, порядок раскатки), `docs/USAGE.md` (в curl-примерах — заголовок `Authorization`, общая пометка в начале), `docs/DASHBOARD.md`
|
||||
(вход, сессия, выход, ключи `auth.*`), `docs/DIAGRAMS.md` (границы доступа), `docs/LOCAL_E2E.md`, `README.md` (разделы
|
||||
«Конфигурация» и «Безопасность»). После реализации — пересборка `bin/` + `SHA256SUMS` и обновление графа graphify.
|
||||
|
||||
## Допущения (проверьте при утверждении)
|
||||
- **Один общий токен агентов** для `validator-agent` и `prober` (отличный от admin-токена). Если нужны раздельные токены для
|
||||
валидаторов и проберов — добавляется второй уровень доступа в ту же таблицу.
|
||||
- Политика «стартовать с предупреждением» распространена и на дашборд (без заданных логина/пароля вход не требуется).
|
||||
Строгий режим (отказ стартовать) — отдельное небольшое изменение.
|
||||
- Один пользователь-администратор дашборда; многопользовательность и роли не вводятся.
|
||||
|
||||
## Верификация
|
||||
1. `go build ./... && go vet ./... && go test ./...` и `go test -race` для `httpapi`, `dashboard`, `apiclient`.
|
||||
2. `scripts/run-local-e2e.sh` с токенами: `401` без токена на admin- и agent-write-ручках, штатный проход цикла с токенами.
|
||||
3. Вручную (curl): `GET /api/v1/admin/status` без токена → `401`, с `Bearer $ADMIN` → `200`; `POST /agents/{id}/results` с admin-токеном
|
||||
→ `401`; `GET /agents/{id}/assignment` без токена → `200/204`.
|
||||
4. Вручную (браузер): дашборд редиректит на `/login`; неверный пароль → ошибка, 5 неудач → `429`; после входа «Обзор» обновляется
|
||||
без перезагрузки; «Выйти» возвращает на `/login`; через 8 часов (или `session_ttl_minutes`) сессия истекает.
|
||||
5. Раскатка по шагам из раздела 4 на стенде `rxprod-compose`: сервисы не теряют регистрацию в момент включения токенов.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Ревью и тестирование: аутентификация API и UI
|
||||
|
||||
> Дата: 2026-10-01 11:31 MSK · План: [2026-10-01_11-12_authentication-plan.md](2026-10-01_11-12_authentication-plan.md)
|
||||
> Статус: **реализовано и проверено на живом окружении**; токен агентов на живом стенде **намеренно не включён** (см. «Состояние живого стенда»).
|
||||
|
||||
## Итог
|
||||
|
||||
Реализовано по плану: ручки управления закрыты токеном администратора, записывающие вызовы валидатора и пробера — отдельным
|
||||
бессрочным токеном агентов, получение настройки и задания остаётся открытым, дашборд закрыт логином и паролем (сессия в подписанной cookie).
|
||||
Код написан отдельным агентом (Sonnet 5.5), ревью и все проверки выполнены независимо (Sonnet 5.5, high).
|
||||
Найден и исправлен один реальный дефект (вёрстка страницы входа); остальные замечания — ограничения дизайна, перечислены ниже.
|
||||
|
||||
## Что реализовано
|
||||
|
||||
| Область | Изменения |
|
||||
|---|---|
|
||||
| control-api | `internal/httpapi/routes.go` — таблица маршрутов `{pattern, handler, access}` с **обязательным** уровнем доступа (33 admin, 5 agent, 7 open); `internal/httpapi/auth.go` — `Authenticator`, Bearer, сравнение `sha256` + `subtle.ConstantTimeCompare`, `401` + `WWW-Authenticate: Bearer`, отказы пишутся в лог без токена; `Server.WithAuth`; `config.auth.{admin_token_env,agent_token_env}` |
|
||||
| validator-agent, prober | `apiclient.Client.Token` — Bearer на каждом запросе к control-api; `WithToken` в `agentcore`/`probercore`; `control_api_token_env`. Токен не попадает в IP-echo и проверки |
|
||||
| admin-dashboard | `internal/dashboard/auth.go` — вход по логину/паролю, сессия без состояния (HMAC-SHA256), CSRF по `Origin`/`Referer`, защита от перебора, `HX-Redirect` для htmx, защита от open-redirect в `next`; `templates/login.html`; «Выйти» и имя пользователя в сайдбаре; Bearer к control-api в `client.do`; индикатор — только при включённой аутентификации |
|
||||
| Развёртывание | переменные в `deploy/docker/*`, `rxprod-compose/docker-compose.yml`, `.env*.example`, `RUN.txt`; `EnvironmentFile=` в 4 systemd-юнитах; ключи в `configs/*.example.yaml` и копиях `rxprod-compose/sources/` |
|
||||
| Тесты | `internal/httpapi/auth_test.go` (матрица по таблице маршрутов), `internal/apiclient/apiclient_test.go`, токены в `agentcore`/`probercore`, `internal/dashboard/auth_test.go` (16 тестов); `scripts/run-local-e2e.sh` — токены и явные проверки 401/open |
|
||||
|
||||
Новых зависимостей нет (`crypto/*` стандартной библиотеки). Режим по умолчанию — обратная совместимость: пустой токен ⇒ соответствующий уровень открыт, в логе предупреждение.
|
||||
|
||||
## Результаты проверок
|
||||
|
||||
| Проверка | Результат |
|
||||
|---|---|
|
||||
| `gofmt`, `go build ./...`, `go vet ./...` | чисто |
|
||||
| `go test ./...` | все пакеты зелёные |
|
||||
| `go test -race` (httpapi, dashboard, apiclient, agentcore, probercore) | зелёные |
|
||||
| `scripts/run-local-e2e.sh` с токенами | exit 0: 5 проверок доступа ok, реальные агент и пробер с токеном довели адрес до `pass`, автоцикл ok |
|
||||
| Живые контейнеры `cloud-ip-validator-*`, `curl` | **40/40** (control-api 401/200, открытые маршруты, heartbeat внешних валидаторов продолжается; дашборд: редиректы, `HX-Redirect`, CSRF, cookie-флаги, open-redirect, tampered cookie, выход) |
|
||||
| Живой дашборд в настоящем Chromium (Playwright) | **15/15**: вход/ошибка пароля, фоновый htmx-опрос с сессией, htmx `PUT /settings` и `PUT /settings/auto-cycle` (Origin-проверка проходит), выход, нет JS-ошибок |
|
||||
| Изолированный стенд `civ-authtest-*` (mock, отдельная сеть, удалён после прогона) | токен агентов: `401` без токена / с токеном администратора / с чужим, принят с токеном агентов; реальный агент **без токена** регистрируется и шлёт heartbeat (открытые маршруты), а его записи отклоняются (41 отказ в логе); **с токеном** новых отказов нет, self-check уходит; пробер с токеном регистрируется; перебор: 6-я неверная попытка → `429`, `Retry-After` 600 с, верный пароль в блокировке тоже `429`; секреты в логах не найдены |
|
||||
|
||||
Примечание: 4 «FAIL» в выводе скрипта изолированного стенда — ошибка форматирования самого скрипта (сравнивалось `yes` со строкой `yes (…)`);
|
||||
значения в скобках подтверждают успех (HTTP 200, 41 отказ, 3 IP, `Retry-After` 600). Реальных провалов нет.
|
||||
|
||||
## Замечания ревью
|
||||
|
||||
| № | Серьёзность | Замечание | Статус |
|
||||
|---|---|---|---|
|
||||
| 1 | средняя | **Страница входа: сломана вёрстка** — `.field` имеет `flex: 1 1 220px`, в колонке это давало 220px пустоты между полями; поле пароля не попадало в правило стилей `input[type=…]` и выглядело нестилизованным. Найдено по скриншоту на живом стенде | **исправлено** (`dashboard.css`: `input[type="password"]` в общее правило, `.login-card .field { flex: 0 0 auto }`), перепроверено скриншотом |
|
||||
| 2 | средняя | Счётчик перебора ключуется по `RemoteAddr` и **не доверяет** `X-Forwarded-For`. За reverse-proxy все клиенты разделят одну корзину: 5 неверных попыток заблокируют вход всем (DoS на админа). Сейчас не проявляется — дашборд открыт напрямую на `:8091`, Caddy перед ним нет | принято; при публикации через прокси нужен список доверенных прокси |
|
||||
| 3 | низкая | Сессия без состояния: «Выйти» стирает cookie в браузере, но украденная копия остаётся валидной до истечения (`session_ttl_minutes`); смена пароля сессии не отзывает. Инвалидация всех сессий — смена `ADMIN_DASHBOARD_SESSION_SECRET` и рестарт | принято, описано в документации |
|
||||
| 4 | низкая | На защищённых страницах нет `Cache-Control: no-store` (кнопка «Назад» после выхода может показать кэш) | не исправлено |
|
||||
| 5 | низкая | `register` и `heartbeat` открыты (решение пользователя): подделка heartbeat «оживляет» упавший валидатор. Перенос под токен агентов — одна строка в таблице маршрутов | принято по решению пользователя |
|
||||
| 6 | низкая | Токены и пароль передаются по HTTP открытым текстом, если TLS не терминируется перед сервисами | описано в документации (TLS на reverse-proxy) |
|
||||
| 7 | инфо | `withAuthInfo` заполняет `PageData` через `reflect` — работает, но хрупко при смене структур | не блокирует |
|
||||
| 8 | инфо | Не добавлен вариант `TestEndToEndHTTPFlow` с токенами — покрыто матрицей по таблице маршрутов и e2e-скриптом | принято |
|
||||
| 9 | инфо | CSRF-проверка сверяет `Origin` с `Host`: прокси, подменяющий `Host`, получит `403` на POST/PUT | описано в документации |
|
||||
|
||||
## Состояние живого стенда (`rxprod-compose`, проект `cloud-ip-validator`)
|
||||
|
||||
- **Включено:** токен администратора на control-api (`CONTROL_API_ADMIN_TOKEN`), тот же токен у дашборда (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`),
|
||||
вход в дашборд (`ADMIN_DASHBOARD_USERNAME` / `ADMIN_DASHBOARD_PASSWORD`), ключ сессии (`ADMIN_DASHBOARD_SESSION_SECRET`).
|
||||
Значения — в `rxprod-compose/.env` (gitignored; права ужесточены до `600`). Дашборд: `http://<хост>:8091/`, логин `admin`.
|
||||
- **Не включено намеренно:** `CONTROL_API_AGENT_TOKEN`. На стенде работают внешние компоненты со старыми бинарниками — валидаторы
|
||||
`validator-1`/`validator-2` (облачные ВМ) и внешний пробер `rxyc`: после включения токена агентов их результаты начнут получать `401`.
|
||||
Пока записывающие вызовы агентов открыты (в логе control-api — предупреждение `agent write API is open`).
|
||||
- **Раскатка токена агентов:** (1) обновить бинарники на валидаторах и внешних проберах; (2) задать им `CONTROL_API_AGENT_TOKEN`
|
||||
(`openssl rand -hex 32`) и перезапустить; (3) последним добавить тот же токен в `.env` стенда (control-api и prober) и выполнить `docker compose up -d`.
|
||||
- Образы `civ-capi`, `civ-adash`, `civ-prober`, `civ-agent` пересобраны; предыдущие сохранены под тегом `:pre-auth`
|
||||
(откат: `docker tag civ-capi:pre-auth civ-capi:latest` и `docker compose up -d`). Бэкап БД и прежнего `.env` — в каталоге scratchpad сессии (`/tmp`, временный).
|
||||
- `bin/` пересобран (`CGO_ENABLED=0`, `-trimpath -ldflags="-s -w"`), `bin/SHA256SUMS` обновлён.
|
||||
|
||||
## Что осталось
|
||||
|
||||
- Раскатка токена агентов на внешние компоненты (см. выше).
|
||||
- По желанию: `Cache-Control: no-store` (замечание 4), список доверенных прокси для счётчика перебора (замечание 2).
|
||||
@@ -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` (из ревью аутентификации).
|
||||
@@ -0,0 +1,110 @@
|
||||
# План: самопроверка через control-api (дополнительный способ сверки публичного IP)
|
||||
|
||||
> Дата: 2026-10-02 03:06 · Статус: **реализовано и проверено** (юнит-тесты, локальный e2e с `ip_echo` и с `control_api`) (решения пользователя — в конце)
|
||||
|
||||
## Зачем
|
||||
|
||||
Самопроверка агента подтверждает, что исходящий трафик валидатора идёт через выданный Floating IP: агент спрашивает свой
|
||||
публичный адрес у внешнего IP-echo сервиса и сверяет с назначенным адресом. Сейчас это единственный способ, и он хрупкий:
|
||||
1 октября таймауты `https://ifconfig.me/ip` дали волну провалов self-check (33 случая за вечер).
|
||||
|
||||
`control-api` в текущем развёртывании стоит **во внешнем окружении** (вне облака), валидаторы подключаются к нему **напрямую**.
|
||||
Значит, соединение валидатора с ним выходит наружу через Floating IP, и `control-api` сам видит публичный адрес источника.
|
||||
Это даёт второй способ сверки без сторонних сервисов: агент спрашивает у `control-api`, с какого адреса тот его видит.
|
||||
|
||||
Требование: **существующий способ (IP-echo) сохраняется**, новый добавляется как опция агента.
|
||||
|
||||
## Решение в двух строках
|
||||
|
||||
1. `control-api` получает ручку «с какого адреса ты меня видишь».
|
||||
2. Агент получает настройку `self_check.methods` — список способов в порядке приоритета; по умолчанию `[ip_echo]` (всё как сейчас).
|
||||
|
||||
## Конфигурация агента
|
||||
|
||||
```yaml
|
||||
self_check:
|
||||
timeout_seconds: 10
|
||||
methods: [control_api, ip_echo] # по умолчанию [ip_echo]
|
||||
ip_echo_urls: [...] # без изменений
|
||||
```
|
||||
|
||||
- Допустимые значения: `ip_echo` (текущий способ), `control_api` (новый). Неизвестное значение — ошибка при старте агента.
|
||||
- **Самопроверка успешна, если её подтвердил любой из способов.** Способы пробуются по порядку приоритета (первый —
|
||||
главный); остановка на первом успешном. К следующему способу переходим и при отсутствии ответа (ошибка, таймаут,
|
||||
404/5xx), и при несовпадении адреса. Провал — только если не подтвердил ни один способ; в `detail` попадает причина по
|
||||
каждому способу.
|
||||
- Внутри способа `ip_echo` поведение прежнее: URL перебираются по порядку, переход к следующему URL только при ошибке.
|
||||
- Пустой список или отсутствие ключа → `[ip_echo]`. Агент без новой настройки ведёт себя ровно как раньше.
|
||||
- Для внешнего размещения `control-api` в примерах и рекомендациях стоит `[control_api, ip_echo]`: способ через API в приоритете.
|
||||
- Итог в `detail`: `detected_egress_ip=<ip> matched (control_api)`; видно в событиях адреса и в дашборде.
|
||||
|
||||
## Control API
|
||||
|
||||
**Новая ручка:** `GET /api/v1/agents/{id}/observed-ip` → `200 {"ip":"90.156.213.5","source":"remote_addr"}`.
|
||||
|
||||
- Уровень доступа — **открыто**, как `heartbeat` и `assignment`: ручка отдаёт только адрес самого вызывающего, секретов нет.
|
||||
- `{id}` должен быть известным валидатором, иначе `404` (чтобы ручка не превращалась в публичный «узнай свой IP»).
|
||||
- Адрес берётся только из `r.RemoteAddr`, приводится к каноничному виду (`::ffff:1.2.3.4` → `1.2.3.4`).
|
||||
- Заголовки `X-Forwarded-For`/`X-Real-IP` **не учитываются**: подключение прямое, а доверие к заголовку позволило бы
|
||||
валидатору подделать адрес и пройти проверку. Если появится обратный прокси, понадобится отдельная настройка
|
||||
доверенных прокси — сейчас она не нужна (решение 1).
|
||||
|
||||
## Агент (`internal/agentcore`)
|
||||
|
||||
- Новая функция `detectViaControlAPI`: `GET /api/v1/agents/{id}/observed-ip` на `control_api_url`.
|
||||
- **Новое TCP-соединение на каждый вызов** (отдельный `http.Transport` с `DisableKeepAlives`). Это ключевой момент:
|
||||
соединение, открытое до привязки Floating IP (heartbeat, assignment), остаётся в старом NAT-состоянии и покажет
|
||||
прежний адрес; общий клиент `apiclient` использовать нельзя.
|
||||
- Токен агентов в этот запрос не нужен (ручка открытая) и не отправляется.
|
||||
- Таймаут `self_check.timeout_seconds` (сейчас 10 с) действует **на каждый способ отдельно**: при общем дедлайне зависший
|
||||
первый способ (приоритетный `control_api`) съел бы всё время, и запасной не успел бы ответить. Общий предел — таймаут × число способов.
|
||||
- `detectPublicIP` заменяется перебором `methods` по приоритету; `fetchIPEcho` не меняется.
|
||||
- Диагностика: если `control-api` вернул **частный** адрес (RFC 1918 и т. п.), в `detail` пишется подсказка: «control-api
|
||||
доступен по внутренней сети, самопроверка через него невозможна; используйте ip_echo».
|
||||
|
||||
## Ограничение способа (важно для документации)
|
||||
|
||||
Способ `control_api` корректен только если соединение валидатора с `control-api` **выходит через внешнюю сеть**
|
||||
(SNAT Floating IP). Если `control-api` достижим из облака по внутренней сети, он увидит частный адрес валидатора, и
|
||||
этот способ всегда будет давать несовпадение (при `[control_api, ip_echo]` проверка пройдёт по `ip_echo`).
|
||||
Если порт `control-api` опубликован через Docker, при выкладке проверить, что ручка показывает внешний адрес клиента, а
|
||||
не адрес шлюза Docker.
|
||||
|
||||
## Откат и совместимость
|
||||
|
||||
- Новый агент + старый `control-api`: ручки нет (`404`), при `methods: [control_api, ip_echo]` агент переходит на `ip_echo`.
|
||||
- Старый агент + новый `control-api`: ничего не меняется, ручка просто не вызывается.
|
||||
- Откат: убрать `methods` из конфига агента (или вернуть `[ip_echo]`) и перезапустить агент.
|
||||
- Схема БД и протокол `self-check` (`POST /agents/{id}/self-check`) не меняются.
|
||||
|
||||
## Затрагиваемые файлы
|
||||
|
||||
| Файл | Изменение |
|
||||
|---|---|
|
||||
| `internal/config/config.go` | `SelfCheckCfg.Methods`, дефолт `[ip_echo]` и проверка значений |
|
||||
| `internal/httpapi/routes.go`, `handlers_agent.go` | маршрут и обработчик `observed-ip` |
|
||||
| `internal/agentcore/agentcore.go` | `detectViaControlAPI`, перебор `methods` по приоритету |
|
||||
| `configs/validator-agent.example.yaml` | пример и комментарии |
|
||||
| `docs/API.md`, `docs/SETUP.md`, `docs/USAGE.md`, `README.md` | описание ручки, опции, ограничения |
|
||||
| `bin/validator-agent`, `bin/control-api`, `SHA256SUMS` | пересборка |
|
||||
|
||||
## Тесты
|
||||
|
||||
- **config:** дефолт `[ip_echo]`; допустимые значения; ошибка на неизвестном способе.
|
||||
- **httpapi:** прямой адрес; IPv4-mapped IPv6; заголовок `X-Forwarded-For` игнорируется; неизвестный валидатор → `404`.
|
||||
- **agentcore:** порядок способов; успех второго способа после ошибки или несовпадения первого; провал, когда не
|
||||
подтвердил ни один; каждый вызов открывает новое соединение (тестовый сервер считает соединения); подсказка про частный адрес.
|
||||
- **e2e:** `scripts/run-local-e2e.sh` остаётся на `ip_echo` (проверка обратной совместимости).
|
||||
|
||||
## Выкладка
|
||||
|
||||
1. `control-api` с новой ручкой (поведение не меняется): пересборка образа, перезапуск.
|
||||
2. Агенты на ВМ-валидаторах: новый `bin/validator-agent` и `methods: [control_api, ip_echo]` в их конфиге. Один валидатор
|
||||
для начала, проверить `detail` в событиях (`matched (control_api)`), затем остальные.
|
||||
|
||||
## Решения пользователя (2026-10-02)
|
||||
|
||||
1. Подключение валидаторов к `control-api` — **напрямую** (без обратного прокси): `trusted_proxies` не нужен.
|
||||
2. Ручка `observed-ip` — **открытая**.
|
||||
3. Достаточно **одной успешной самопроверки любым из способов**; при внешнем размещении API способ через ручку API —
|
||||
**в приоритете** (первый в списке).
|
||||
@@ -0,0 +1,146 @@
|
||||
# План: исправление оркестратора (двойная выдача валидатору) и очистки очереди
|
||||
|
||||
> Дата: 2026-10-02 09:02 UTC · Статус: **реализовано и проверено** (юнит-тесты с `-race`, локальный e2e; выкладка и приёмка на стенде — ниже). Решения пользователя: предел очистки 10 минут и 8 параллельных отвязок приняты как базовые
|
||||
> Основание: [analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md](../../analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md)
|
||||
|
||||
## Context
|
||||
|
||||
Массовая проверка 2 октября остановилась на 1026 из 6440 адресов. 7 из 20 валидаторов «залипли»: v1, v12, v13 не взяли
|
||||
ни одного задания после 07:17 и 07:33; v7, v16, v17, v3 залипали временно. Итог: 198 сбросов лизинга, 41 ошибка привязки
|
||||
Floating IP (`409 fixed IP already has a floating IP`), **42 адреса в `fail` без единой выполненной проверки**, потеря
|
||||
~27% пропускной способности. Отдельно «Очистить всё» не уложилась в таймаут клиента (256 с вместо секунд) и сначала
|
||||
оборвалась с 600 ошибками `context canceled`.
|
||||
|
||||
Цель: валидатор в любой момент держит не больше одного адреса; потеря heartbeat не приводит к двойной выдаче;
|
||||
«Очистить всё» выполняется за секунды и не прерывается разрывом соединения.
|
||||
|
||||
## Причины (по коду, подтверждены логами и БД)
|
||||
|
||||
| № | Причина | Где |
|
||||
|---|---|---|
|
||||
| 1 | Heartbeat возвращает `unreachable` → `idle`, не глядя на `current_ip_id`: занятый валидатор снова считается свободным | `internal/db/queries_validators.go:41` (`Heartbeat`), `:31` (`RegisterValidator`, возврат из `unreachable`) |
|
||||
| 2 | Освобождение валидатора идёт **по имени**, а не по адресу, который он держит: завершение старого адреса освобождает валидатор, уже взявший новый. Так же `RequeueOrFail` (в т. ч. при сбросе лизинга) и `MarkFIPOccupied`, `FreeValidator`. Освобождённый ставится в `idle` даже если он `unreachable` — мёртвый валидатор получает новые адреса каждые 3 минуты | `internal/db/queries_ipqueue.go` (`ReleaseFIP`, `RequeueOrFail`, `MarkFIPOccupied`), `queries_validators.go:143` (`FreeValidator`), `internal/orchestrator/orchestrator.go:469` |
|
||||
| 3 | Моя регрессия: защита от дублей привязки ключуется по валидатору (`assign:<validator>`). Вторая выдача того же валидатора не запускает привязку и стоит в `assigning_fip` до конца лизинга | `internal/orchestrator/orchestrator.go:149` |
|
||||
| 4 | Агент шлёт heartbeat только между заданиями. Адрес с 3–4 таймаутами внешних проверок (~40 с) блокирует его дольше порога 30 с | `internal/agentcore/agentcore.go` (`Run`, `pollOnce`) |
|
||||
| 5 | «Очистить всё» отвязывает FIP у **всех** строк с непустым `fip_id`, а он остаётся у `done`/`failed`. Больше 1000 последовательных вызовов OpenStack | `internal/db/queries_ipqueue.go` (`ListFIPRefs`, `ListFIPRefsByAddresses`), `internal/orchestrator/orchestrator.go` (`ClearQueue`, `DeleteIPs`) |
|
||||
| 6 | Очистка работает на контексте HTTP-запроса: разрыв соединения клиентом обрывает её посреди дела (отвязано часть, БД не очищена) | `internal/httpapi/handlers_admin.go:322` |
|
||||
|
||||
## Инварианты, которые вводим
|
||||
|
||||
- **I1.** Валидатор держит не более одного адреса: `validators.current_ip_id = X` тогда и только тогда, когда у строки `X`
|
||||
`owner_validator_id` равен этому валидатору и состояние не терминальное (`done`, `failed`, `occupied`).
|
||||
- **I2.** `idle` означает `current_ip_id IS NULL`. Состояния `unreachable` и `unregistered` не затираются освобождением.
|
||||
- **I3.** Валидатор освобождает только тот адрес, который он сейчас держит. Освобождение и возврат по лизингу чужого или
|
||||
устаревшего адреса состояние валидатора не меняют.
|
||||
|
||||
## Изменения
|
||||
|
||||
### 1. База данных (без миграций, только запросы)
|
||||
|
||||
`internal/db/queries_validators.go`, `queries_ipqueue.go`:
|
||||
|
||||
- **`Heartbeat`:** `unreachable` → `assigned`, если `current_ip_id IS NOT NULL`, иначе `idle`.
|
||||
То же в `RegisterValidator` (возврат из `unreachable`/`unregistered`).
|
||||
- **Общая функция освобождения** `freeValidatorTx(tx, validatorID, ipID)`:
|
||||
`UPDATE validators SET current_ip_id=NULL, state = CASE WHEN state='unreachable' THEN state ELSE 'idle' END WHERE validator_id=? AND current_ip_id=?`.
|
||||
Используют `ReleaseFIP`, `RequeueOrFail`, `MarkFIPOccupied`, `FreeValidator` (получает второй аргумент — id адреса).
|
||||
`deleteIPTx` (уже по `current_ip_id`) и `ClearAllIPs` переводятся на тот же `CASE`, чтобы не затирать `unreachable`.
|
||||
- **`ClaimNextQueued`:** условие обновления валидатора дополняется `AND current_ip_id IS NULL`.
|
||||
- **`ReconcileValidators`** (новая, вызывается из `Orchestrator.Tick`): лечит нарушение инвариантов, если они всё же возникли
|
||||
(падение процесса, старые строки): валидатор с `current_ip_id`, чья строка не существует, терминальна или принадлежит другому
|
||||
валидатору, освобождается; строка в `assigning_fip`/`awaiting_self_check`/`checking`, чей владелец не ссылается на неё,
|
||||
не трогается (её вернёт сброс лизинга). Один короткий запрос на такт.
|
||||
- **`ListFIPRefs` и `ListFIPRefsByAddresses`:** только строки в нетерминальных состояниях (`state NOT IN done, failed, occupied`)
|
||||
с непустым `fip_id`: у терминальных FIP уже отвязан (агрегация, возврат по лизингу, отмена и `occupied` делают это до записи
|
||||
состояния). Значения `fip_id` в строках не меняются (дашборд их показывает).
|
||||
|
||||
### 2. Оркестратор (`internal/orchestrator/orchestrator.go`)
|
||||
|
||||
- Ключ защиты от дублей привязки: `assign:<id адреса>`, а не `assign:<validator>` (строка 149). Дублирующий запуск привязки
|
||||
одного и того же адреса по-прежнему исключён.
|
||||
- `ForceCancel`, `sweepExpiredLeases`, `aggregateAndRelease`, `SelfCheckResult`: передают id адреса в освобождение (I3).
|
||||
- **`ClearQueue`/`DeleteIPs`:** отвязка FIP из `ListFIPRefs` (теперь ≤ числа валидаторов) выполняется параллельно, не более
|
||||
8 одновременных вызовов; затем прямой опрос портов (`releaseValidatorPorts`) как сейчас.
|
||||
- `Tick`: вызывает `ReconcileValidators` первым шагом.
|
||||
|
||||
### 3. HTTP (`internal/httpapi/handlers_admin.go`)
|
||||
|
||||
- «Очистить всё», удаление списка и отмена: контекст отвязан от отмены запроса (`context.WithoutCancel`) с собственным пределом
|
||||
времени (10 минут). Разрыв соединения клиентом (в т. ч. таймаут дашборда) больше не обрывает операцию на середине.
|
||||
|
||||
### 4. Агент (`internal/agentcore/agentcore.go`)
|
||||
|
||||
- Heartbeat уходит из `pollOnce` в **отдельную горутину** со своим тикером (период `poll_interval_seconds`); останавливается по
|
||||
отмене контекста. Долгие внешние проверки больше не блокируют heartbeat. Ошибки heartbeat пишутся в лог (предупреждение).
|
||||
- Протокол и конфигурация агента не меняются (старый агент с новым сервером и наоборот работают).
|
||||
|
||||
### 5. Документация
|
||||
|
||||
`docs/USAGE.md`/`docs/DIAGRAMS.md` (состояния валидатора и правила освобождения), запись в истории изменений `README.md`.
|
||||
|
||||
## Тесты
|
||||
|
||||
Пишу сам (агенты тесты не делают); каждый тест должен падать без исправления.
|
||||
|
||||
- **db:**
|
||||
- `Heartbeat` из `unreachable` с адресом даёт `assigned`, без адреса `idle`; `RegisterValidator` аналогично;
|
||||
- `ReleaseFIP`/`RequeueOrFail`/`MarkFIPOccupied` старого адреса не освобождают валидатор, который уже держит другой адрес;
|
||||
- освобождение `unreachable`-валидатора оставляет `unreachable`;
|
||||
- `ClaimNextQueued` не выдаёт адрес валидатору с `current_ip_id`;
|
||||
- `ReconcileValidators` лечит битые строки и не трогает корректные;
|
||||
- `ListFIPRefs`/`ListFIPRefsByAddresses` не содержат `done`/`failed`/`occupied`.
|
||||
- **orchestrator:**
|
||||
- сценарий инцидента: валидатор помечен `unreachable`, держа адрес A с идущими проверками → heartbeat → такт оркестратора
|
||||
**не выдаёт** ему B; после завершения A валидатор получает B;
|
||||
- мёртвый валидатор (`unreachable`, лизинг истёк) не получает новых адресов;
|
||||
- `ClearQueue` при 1000 строк `done` с `fip_id` и 5 активных не вызывает `Disassociate` для `done` (счётчик вызовов в обёртке OpenStack);
|
||||
- защита привязки по адресу (два адреса одного валидатора, искусственно, оба привязываются);
|
||||
- **случайный сценарий под нагрузкой** (20 валидаторов, 300 адресов, случайная потеря heartbeat, долгие проверки, часть адресов
|
||||
с отказом привязки): после каждого такта проверяются I1–I3; в конце все адреса завершены, сбросов лизинга нет.
|
||||
- **httpapi:** «Очистить всё» доживает до конца при отмене контекста запроса посередине (БД очищена, FIP отвязаны).
|
||||
- **agentcore:** во время долгой проверки (цель отвечает 3 с) heartbeat уходит по расписанию; остановка по отмене контекста.
|
||||
- **Общий прогон:** `go vet`, `go test -race ./...`, `scripts/run-local-e2e.sh` (с `ip_echo` и `control_api`).
|
||||
|
||||
## Выкладка
|
||||
|
||||
1. Правки control-api: сборка `bin/control-api`, образ `civ-capi`, перезапуск (БД не затрагивается; миграций нет).
|
||||
Одного этого достаточно, чтобы остановить двойные выдачи и бесконечное «залипание».
|
||||
2. Агент: сборка `bin/validator-agent`, коммит, раскатка Ansible-сценарием `deploy/ansible` (запускает пользователь): heartbeat в
|
||||
отдельном потоке убирает ложные `unreachable`.
|
||||
3. Перепроверка 42 адресов: список выгружается из снимка анализа в файл `analysis/2026-10-02_08-56_failed-addresses.txt` (уже выгружен, 42 адреса); ставятся в очередь
|
||||
через `POST /api/v1/admin/ips` (или «Перепроверка» в дашборде) после выкладки.
|
||||
|
||||
## Приёмка на стенде
|
||||
|
||||
Контрольная группа из 20 адресов, затем 400 адресов (при тех же внешних целях с долгими таймаутами) с наблюдением 30 минут:
|
||||
|
||||
| Показатель | Критерий |
|
||||
|---|---|
|
||||
| `lease_expired` | 0 |
|
||||
| Ошибки привязки `409` | 0 |
|
||||
| Двойные выдачи (два `claimed ip` одному валидатору за <10 с) | 0 |
|
||||
| Завершено каждым валидатором | отклонение от среднего не больше 15% |
|
||||
| `unreachable` при долгих проверках | нет (после раскатки нового агента) |
|
||||
| «Очистить всё» при >1000 строк `done` | ответ не дольше 10 с |
|
||||
| Адреса `fail` | только по существу (не из-за лизинга) |
|
||||
|
||||
## Риски и откат
|
||||
|
||||
- Изменения только в запросах и логике, без миграций; схема БД и протокол агента не меняются. Откат — предыдущий образ `civ-capi`
|
||||
(`docker tag`/предыдущий коммит) и прежний бинарник агента.
|
||||
- Риск: условные `UPDATE` по `current_ip_id` могут оставить валидатор занятым, если строка адреса пропала. Страхует `ReconcileValidators`.
|
||||
- Валидатор `unreachable` теперь не получает адресов до первого heartbeat — это намеренно; если агент жив, но heartbeat по сети
|
||||
не проходит, он простаивает (раньше брал адреса и терял их).
|
||||
|
||||
## Не входит в эту правку
|
||||
|
||||
- Параллельное выполнение внешних проверок в агенте (3–4 таймаута сейчас идут подряд): сократило бы цикл с ~84 с и снизило бы
|
||||
нагрузку на heartbeat; отдельное решение.
|
||||
- Судьба цели `packages.ubuntu.com` (71% `partial`) и причины недоступности проберов — отдельные вопросы из анализа.
|
||||
- Сокращение `fip_settle_seconds` (30 с) ради темпа.
|
||||
|
||||
## Вопросы к согласованию
|
||||
|
||||
1. Предел времени «Очистить всё» (10 минут) и число параллельных отвязок (8) — подходят?
|
||||
2. Выкладывать control-api сразу после тестов (до раскатки агента) — да, как в разделе «Выкладка»?
|
||||
3. 42 адреса `fail` перепроверять сразу после выкладки или вместе со следующим большим прогоном?
|
||||
@@ -0,0 +1,82 @@
|
||||
# План: уровни Egress / Ingress в таблице «Реестр»
|
||||
|
||||
## Контекст
|
||||
|
||||
Колонка «Последний результат» в «Реестре» показывает одну пилюлю общего вердикта (pass / partial / fail / cancelled). Для аналитики этого мало: не видно, где именно сбой — на выходе (egress) или на входе (ingress) — и какой тип проверки упал.
|
||||
|
||||
Нужно разделить результат на два уровня и показать «успешно из всего» (например, «5 из 5») по каждому уровню, с разбивкой по типу проверки (icmp, ssh, tcp, https, tls и новые типы, когда появятся).
|
||||
|
||||
Решения пользователя:
|
||||
- Пилюля общего вердикта остаётся, уровни добавляются под ней. Фильтр по вердикту не меняется.
|
||||
- «Всего» = число записанных проверок последнего цикла (не ожидаемое по настройкам).
|
||||
|
||||
## Что уже есть (по графу и коду)
|
||||
|
||||
- Проверки лежат в `checks` (`internal/db/migrations/0007_ip_registry.sql:39-65`). Уровень задаёт `source`: `egress` или `inbound-site-N` (`internal/db/models.go:39,68-72`). Тип — в `check_type`: `https`, `icmp`, `ssh`, `tcp-<порт>`, `tls-<порт>`.
|
||||
- Индекс `idx_checks_registry_cycle(registry_id, cycle_id)` уже есть. Миграция не нужна.
|
||||
- Цепочка данных: `fillRegistrySummary` (`internal/db/queries_registry.go:199`) → `registrySummaryToDTO` (`internal/httpapi/handlers_registry.go:75`) → `registryDTO` (`internal/httpapi/dto_admin.go:87`) → клиент дашборда → `registryItem` (`internal/dashboard/dto.go:258`) → шаблон `internal/dashboard/templates/registry.html` (`registry_table`, строки 69–101).
|
||||
- Дашборд ходит в БД только через HTTP control-api. Менять нужно оба слоя.
|
||||
|
||||
## Правила расчёта
|
||||
|
||||
- Цикл: максимальный `cycle_id` в `checks` для адреса (так же, как сейчас считается `LastCheckedAt`).
|
||||
- Уровень: `source = 'egress'` → Egress; `source LIKE 'inbound-site-%'` → Ingress. Прочее игнорируется.
|
||||
- Тип: часть `check_type` до первого `-` (`tcp-22`, `tcp-443` → `tcp`; `tls-443` → `tls`; `ssh`, `icmp`, `https` без изменений). Новый тип попадает в разбивку сам, без правок кода.
|
||||
- Ingress: считаются проверки всех площадок вместе (площадок может быть любое число).
|
||||
- Правило разбора живёт в одном месте: две функции в `internal/db/models.go` рядом с `InboundSource`: `CheckLevel(source)` и `CheckFamily(checkType)`.
|
||||
- Группировка делается в Go, не в SQL: SQL отдаёт `GROUP BY registry_id, source, check_type`, и это не больше ~30 строк на адрес.
|
||||
|
||||
## Изменения
|
||||
|
||||
### 1. БД (`internal/db`)
|
||||
- `models.go`: типы `TypeStat{Type, Total, OK}` и `LevelResult{Total, OK, ByType []TypeStat}` (по типам — в алфавитном порядке). Функции `CheckLevel`, `CheckFamily`.
|
||||
- `queries_registry.go`: в `RegistrySummary` добавить `LastCycleID`, `Egress`, `Ingress`. Один пакетный запрос на страницу вместо запроса на каждый адрес:
|
||||
```sql
|
||||
SELECT c.registry_id, c.source, c.check_type, COUNT(*), SUM(c.success)
|
||||
FROM checks c
|
||||
JOIN (SELECT registry_id, MAX(cycle_id) AS cid FROM checks
|
||||
WHERE registry_id IN (…) GROUP BY registry_id) m
|
||||
ON m.registry_id = c.registry_id AND m.cid = c.cycle_id
|
||||
GROUP BY c.registry_id, c.source, c.check_type
|
||||
```
|
||||
Новая функция заполняет сводки списком адресов (порциями по 500). Её вызывают `ListRegistryPage`, `ListRegistry` и `GetRegistryByAddress`. Это заодно убирает N+1 для новых полей.
|
||||
- `lastResultCond` и `fillRegistrySummary` (вердикт) не меняются.
|
||||
|
||||
### 2. HTTP API (`internal/httpapi`)
|
||||
- `dto_admin.go`: в `registryDTO` добавить `last_cycle_id`, `egress`, `ingress` как `{total, ok, by_type: [{type, total, ok}]}`. Изменение только добавляет поля, старые клиенты не ломаются.
|
||||
- `handlers_registry.go`: `registrySummaryToDTO` заполняет новые поля.
|
||||
- `docs/API.md` (раздел «Реестр адресов», ~645–668): новые поля и пример JSON.
|
||||
|
||||
### 3. Дашборд (`internal/dashboard`)
|
||||
- `dto.go`: `registryItem` получает те же поля.
|
||||
- `templates/registry.html`: в ячейке «Последний результат» сверху пилюля вердикта, ниже две строки:
|
||||
`Egress 5 из 5` и `Ingress 3 из 4`, под каждой чипы по типам: `https 3 из 3`, `icmp 2 из 2`. Цвет: все успешны — зелёный, ни одной — красный, иначе жёлтый. Нет проверок — «—». В `title` ячейки: «по записанным проверкам цикла N; вердикт учитывает недостающие результаты». Сохранить `data-label` для мобильной раскладки.
|
||||
- `static/dashboard.css` (~417–423): стили строки уровня и чипа по образцу `pill-*`.
|
||||
- Страница деталей `registry_detail.html` не меняется (вне объёма).
|
||||
|
||||
### 4. Тесты
|
||||
- `internal/db/queries_registry_test.go`: новые тесты — разбивка egress/ingress по типам; `tcp-22` и `tcp-443` сливаются в `tcp`; `tls`, `ssh`; новый тип (например `dns`) появляется в разбивке; адрес без проверок даёт нули; берётся последний цикл; работает после удаления строки очереди; несколько адресов в одном пакетном запросе. Добавить хелпер с ingress-проверками рядом с `finishWithChecks` (`queries_scale_test.go:24`).
|
||||
- `internal/httpapi/handlers_scale_test.go` (`TestAdminRegistryPaginationFiltersAndCompat`): проверить новые поля в ответе.
|
||||
- `internal/dashboard/handlers_test.go` (`TestRegistryPageAndDetail`): фикстура с уровнями, проверка вывода «5 из 5» и имён типов.
|
||||
- `TestScaleSmoke6440` (`queries_scale_test.go:356`): добавить ingress-проверки в засев, лимит 10 с сохраняется.
|
||||
|
||||
## Порядок работы (по правилам проекта)
|
||||
|
||||
1. Скопировать этот план в `docs/changes/2026-10-03_<ЧЧ-ММ>_registry-egress-ingress-levels-plan.md`.
|
||||
2. Реализовать п.1 → п.2 → п.3 → п.4.
|
||||
3. Написать `docs/changes/…-summary.md` (что изменено).
|
||||
4. Обновить `README.md` и `docs/API.md`/`docs/USAGE.md`, где описан реестр.
|
||||
5. Обновить граф: `/graphify . --update`.
|
||||
|
||||
## Проверка
|
||||
|
||||
- `go build ./... && go vet ./... && go test ./...`
|
||||
- `EXPLAIN QUERY PLAN` пакетного запроса: должен идти по `idx_checks_registry_cycle`, без полного перебора `checks`.
|
||||
- Локальный стенд (`scripts/run-local-e2e.sh`, `docs/LOCAL_E2E.md`): прогнать цикл, открыть `/registry`, сверить «N из M» по уровням и типам с таблицей на странице деталей `/registry/<ip>`.
|
||||
- Страница на 6440 адресов отвечает за время порядка прежних 0,12 с (допустим рост в разы, но не секунды).
|
||||
|
||||
## Ограничения и риски
|
||||
|
||||
- «N из M» считается по записанным проверкам. Если результатов не хватает, вердикт может быть хуже, чем «N из M» (например, все записанные успешны, а вердикт `partial`). Это показано подсказкой.
|
||||
- Пока идёт цикл, счётчики отражают частично пришедшие проверки текущего цикла. Состояние цикла видно в колонке состояния.
|
||||
- Фильтра и сортировки по уровням нет: сейчас вне объёма. Если понадобятся для аналитики, это отдельное изменение.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Реестр: уровни Egress и Ingress — итог
|
||||
|
||||
План: [2026-10-03_16-24_registry-egress-ingress-levels-plan.md](2026-10-03_16-24_registry-egress-ingress-levels-plan.md).
|
||||
|
||||
## Что сделано
|
||||
|
||||
В колонке «Последний результат» таблицы «Реестр» под вердиктом показаны два уровня последнего цикла:
|
||||
**Egress** и **Ingress** в виде «успешно из всего» (например, «5 из 5»), а под каждым — то же по типам проверок
|
||||
(`https`, `icmp`, `ssh`, `tcp`, `tls`; новый тип появляется сам).
|
||||
|
||||
## Правила
|
||||
|
||||
- Цикл — наибольший `cycle_id` с записанными проверками адреса.
|
||||
- Уровень по `checks.source`: `egress` → Egress, `inbound-site-N` → Ingress (все площадки вместе). Прочие источники не считаются.
|
||||
- Тип — `check_type` до первого дефиса: `tcp-22` и `tcp-443` → `tcp`.
|
||||
- «Всего» — число записанных проверок. Вердикт (`last_result`) учитывает ещё и недостающие результаты, поэтому может быть хуже, чем «N из M». В таблице это отмечено подсказкой.
|
||||
|
||||
## Изменённые файлы
|
||||
|
||||
- `internal/db/models.go` — `CheckLevel`, `CheckFamily`, константы уровней.
|
||||
- `internal/db/queries_registry.go` — `TypeStat`, `LevelResult`, поля `LastCycleID`/`Egress`/`Ingress` в `RegistrySummary`; `fillRegistryLevels` — один сгруппированный запрос на порцию до 500 адресов вместо запроса на адрес (используют `ListRegistry`, `ListRegistryPage`, `GetRegistryByAddress`). Миграция не нужна, запрос идёт по `idx_checks_registry_cycle`.
|
||||
- `internal/httpapi/dto_admin.go`, `handlers_registry.go` — поля `last_cycle_id`, `egress`, `ingress` (только добавление, старые клиенты не ломаются).
|
||||
- `internal/dashboard/dto.go`, `render.go` (функция шаблона `dict`), `templates/registry.html`, `static/dashboard.css` — отображение.
|
||||
- Документация: `docs/API.md`, `docs/USAGE.md`, `docs/DASHBOARD.md`, `README.md`.
|
||||
|
||||
## Не менялось
|
||||
|
||||
Фильтр по вердикту (`lastResultCond`), расчёт `last_result`, страница деталей `/registry/{ip}`, схема БД.
|
||||
|
||||
## Тесты и проверка
|
||||
|
||||
- Новые: `internal/db/queries_registry_levels_test.go` (разбор уровней и типов, нулевые счётчики, последний цикл, несколько адресов, после удаления строки очереди), `TestRegistryLevelsQueryUsesIndex` (план запроса без полного перебора `checks`), `TestAdminRegistryLevels` (API), `TestRegistryPageShowsEgressIngressLevels` (дашборд).
|
||||
- `TestScaleSmoke6440` дополнен проверками (30 на адрес): страница из 100 адресов — около 0,18 с.
|
||||
- `go build ./... && go vet ./... && go test ./...` — без ошибок.
|
||||
- Не проверено вручную на локальном стенде (`scripts/run-local-e2e.sh`) и в браузере: вид колонки на узком экране проверен только по разметке.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Пока цикл идёт, счётчики отражают частично пришедшие проверки.
|
||||
- Фильтра и сортировки по уровням нет — отдельное изменение, если понадобится для аналитики.
|
||||
@@ -0,0 +1,149 @@
|
||||
# План: раздел «Аналитика»
|
||||
|
||||
Статус: план, код не менялся. Основа — прогон 6440 адресов (2026-10-02 13:47 – 22:29 UTC) и три отчёта в `analysis/`.
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
Сейчас выводы по прогону получены ручными запросами к БД: срезы по подсетям, провалы по целям, ingress по площадкам, расхождение вердикта и проверок. Нужна страница, которая показывает то же самое постоянно, для любого прогона, без SQL.
|
||||
|
||||
Главное требование: переключать **отметку времени** — завершённый запуск проверки, — чтобы данные разных запусков не смешивались.
|
||||
|
||||
## 2. Ключевая находка: «запуска» в данных нет
|
||||
|
||||
`cycle_id` считается **на адрес** (`ip_registry.next_cycle`), а не на весь прогон. В БД сейчас: цикл 1 у 6431 адреса, циклы 2–4 у 9 перепроверенных. По `cycle_id` нельзя выбрать «прогон от 2 октября»: он ничего не группирует.
|
||||
|
||||
Группировка по времени тоже ненадёжна: прогон 6440 адресов шёл 8 ч 42 мин, а перепроверки внутри него шли в то же время.
|
||||
|
||||
Поэтому нужна новая сущность — **запуск** (`run`). Это единственное изменение схемы, без него переключатель невозможен.
|
||||
|
||||
## 3. Модель данных (миграция `0010`)
|
||||
|
||||
Новые таблицы:
|
||||
|
||||
- `check_runs(id, kind, started_at, finalized_at, state)`. `kind`: `auto` (автоцикл), `manual` (добавление, скан, перепроверка). `state`: `open` или `finalized`.
|
||||
- `run_results(run_id, registry_id, ip_address, cycle_id, verdict, aggregated_at, expected_checks, recorded_at_aggregation)`. Одна строка на адрес в запуске. Нужна, потому что вердикт сейчас хранится только в `ip_queue.overall_result` и затирается при перепроверке.
|
||||
- `subnets(cidr PRIMARY KEY, label)`. Список подсетей заказчика (45 шт.) не лежит ни в БД, ни в репозитории, его задаёт администратор.
|
||||
|
||||
Новые колонки: `run_id` в `ip_queue` и `checks`. Индекс `idx_checks_run(run_id, registry_id)`.
|
||||
|
||||
### Правила запуска
|
||||
|
||||
1. Новый запуск открывается, когда в очередь попадает адрес, а открытого запуска нет. Автоцикл открывает запуск явно в начале фазы.
|
||||
2. Пока запуск открыт, все добавленные и перепроверяемые адреса входят в него.
|
||||
3. Запуск **завершается**, когда все его строки очереди в конечном состоянии (`done`, `failed`, `occupied`, отмена). Тогда `finalized_at` = последняя агрегация.
|
||||
4. Перепроверка после завершения открывает **новый** запуск. Поэтому старый запуск не меняется.
|
||||
5. Если адрес перепроверен внутри открытого запуска, в аналитике берётся его последний цикл этого запуска.
|
||||
6. Поздние результаты (пришли после агрегации, как у 50 адресов в анализе 6.5) остаются в своём запуске: `run_id` берётся из строки очереди при записи проверки. Их число показывается отдельно.
|
||||
|
||||
### Заполнение уже накопленных данных
|
||||
|
||||
Запуски выделяются по паузе: если между окончаниями соседних циклов больше 60 минут, начинается новый запуск. Максимальная пауза в текущей БД 0,8 минуты, поэтому получится **один запуск на 6440 адресов**, 9 перепроверок войдут в него. Вердикт берётся из `ip_queue.overall_result`, для прежних циклов без него считается по проверкам и помечается как расчётный.
|
||||
|
||||
## 4. API (control-api, только чтение)
|
||||
|
||||
- `GET /api/v1/admin/analytics/runs` — список запусков: id, тип, начало, конец, адресов, доли `pass/partial/fail`, состояние. Открытый запуск виден, но помечен как идущий.
|
||||
- `GET /api/v1/admin/analytics/runs/{id}` — все блоки страницы одним ответом (десятки КБ):
|
||||
- **summary**: адресов; вердикты; Egress OK и Ingress OK по фактическим проверкам; доли по типам проверок; индикаторы прикладного уровня: адреса, провалившие все egress-проверки https (на запуске 02.10: 307, из них по всем 5 целям 290), и адреса, провалившие все ingress-проверки ssh (7; хотя бы с одной площадки 222); длительность и скорость; число поздних результатов; число неполных наборов.
|
||||
- **partial_reasons**: причины `partial` (только egress; ingress и egress; только ingress; неполный набор; только неполный набор).
|
||||
- **subnets**: по каждой подсети — адресов, `pass`, Egress OK, Ingress OK.
|
||||
- **targets**: egress по цели и типу проверки (процент провалов); матрица «подсеть × цель» для выбранного типа.
|
||||
- **sites**: ingress по площадке и типу проверки.
|
||||
- **validators**: доля провалов egress по валидаторам.
|
||||
- **errors**: классы ошибок по уровню и типу (таймаут, нет маршрута, баннер SSH и др.), классификатор по `checks.detail`.
|
||||
- **data_quality**: поздние результаты, неполные наборы, адреса, где вердикт расходится с проверками.
|
||||
- `GET/PUT /api/v1/admin/config/subnets` — список подсетей (замена целиком, текстом по одной в строке).
|
||||
|
||||
Группировка по типу проверки везде одна и та же, правило `CheckFamily` из `internal/db/models.go` (tcp-22 и tcp-443 → `tcp`). Новый тип появляется в таблицах сам.
|
||||
|
||||
Адрес → подсеть определяется в Go по самому длинному совпавшему префиксу (SQLite не умеет работать с CIDR). Для 6440 адресов это миллисекунды.
|
||||
|
||||
Расчёт идёт одним проходом по `checks` запуска (~190 тыс. строк). Результат завершённого запуска кэшируется в памяти по `run_id` и `MAX(checks.id)` запуска, чтобы поздний результат сбросил кэш.
|
||||
|
||||
## 5. Макет страницы `/analytics?run=<id>`
|
||||
|
||||
```
|
||||
┌─ Аналитика ─────────────────────────────────────────────────────────────┐
|
||||
│ Запуск: ◀ [ 02.10 13:47 → 22:29 · авто · 6440 адр · 30% pass ▾ ] ▶ │
|
||||
│ (идущий запуск виден в списке, но недоступен: «идёт, 120 из 800») │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ [Адресов 6440] [pass 1962 · 30%] [partial 4478 · 70%] [fail 0] │
|
||||
│ [Egress OK 31%] [Ingress OK 97%] [Длительность 8 ч 42 м] [Поздних 246] │
|
||||
├──────────────────────────────────┬───────────────────────────────────────┤
|
||||
│ Почему partial │ Данные: качество │
|
||||
│ ▇▇▇▇▇▇▇▇▇▇▇▇ только egress 4146 │ Поздние результаты 246 (50 адр.)│
|
||||
│ ▇ ingress+egress 157 │ Неполный набор 167 адресов │
|
||||
│ ▇ egress+неполный набор 125 │ Вердикт ≠ проверки 50 адресов │
|
||||
│ ▏ только ingress 8 │ │
|
||||
├──────────────────────────────────┴───────────────────────────────────────┤
|
||||
│ Подсети [сорт: хуже всего ▾] [показать все 43] │
|
||||
│ подсеть адр. pass% Egress OK% Ingress OK% │
|
||||
│ 161.104.108.0/23 45 ░░░░░ 0% ░░░░░ 0% ▇▇▇▇▇ 100% │
|
||||
│ 83.166.248.0/21 659 ▇░░░░ 11% ▇░░░░ 12% ▇▇▇▇░ 91% →реестр│
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ Egress по целям тип: [https] [icmp] [все] │
|
||||
│ цель провал | подсеть × цель (тепловая карта) │
|
||||
│ packages.ubuntu.com 60% | 83.166.248.0/21 ■■□■■ │
|
||||
│ dl-cdn.alpinelinux.org 34% | 212.233.72.0/21 ■■□■■ │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ Ingress по площадкам площадка × тип (tcp/ssh/icmp) | классы ошибок │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ Валидаторы: полоса «доля провалов egress» по 20 валидаторам (≈33–38%) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Два индикатора прикладного уровня (Egress https и Ingress ssh: все провалены) кликабельны: клик открывает окно со списком адресов и кнопкой «Скачать CSV». В рабочей странице список берётся из `GET /admin/analytics/runs/{id}/addresses?list=egress_https_all_failed|ingress_ssh_all_failed` (CSV собирается на сервере, `Content-Disposition: attachment`, UTF-8 с BOM), чтобы не зависеть от размера страницы.
|
||||
- Каждая строка блока «Классы ошибок ingress» кликабельна: окно показывает распределение проваленных проверок класса по валидаторам, вердикт адреса, статус проверки («в вердикте» или «после вердикта») и список с кнопкой «Скачать CSV». Эндпоинт: `GET /admin/analytics/runs/{id}/errors/{class}` (JSON и `?format=csv`).
|
||||
- Выбор запуска хранится в адресной строке (`?run=`), ссылку можно переслать. По умолчанию — последний завершённый запуск.
|
||||
- Каждый блок рассчитан только на выбранный запуск. Данных других запусков на странице нет.
|
||||
- Строка подсети, ячейка матрицы и причина `partial` — ссылки в «Реестр» с фильтром, чтобы увидеть конкретные адреса. Для этого в `GET /admin/registry` и `/registry` добавляются параметры `run`, `subnet`, `target`, `reason`; страница «Реестр» тоже учитывает выбранный запуск.
|
||||
- Рисование: HTML и встроенный SVG на сервере, без библиотек графиков (в дашборде их нет). Цвета и тепловая карта — по руководству навыка `dataviz`, с тёмной темой, как в остальном дашборде.
|
||||
- Пункт «Аналитика» в боковом меню (`internal/dashboard/templates/layout.html`).
|
||||
|
||||
## 6. Файлы
|
||||
|
||||
- БД: `internal/db/migrations/0010_check_runs.sql`, `models.go`, `queries_runs.go`, правки `queries_ipqueue.go` (`SubmitIPs`, `FinishIP`, `CancelIP`, `ClearAllIPs`) и `queries_checks.go` (`UpsertCheck` ставит `run_id`).
|
||||
- Оркестратор: `internal/orchestrator/orchestrator.go` — запись `run_results` при агрегации, завершение запуска; автоцикл открывает запуск.
|
||||
- Аналитика: новый пакет `internal/analytics` (расчёт блоков, классификатор ошибок, подсети).
|
||||
- API: `internal/httpapi/handlers_analytics.go`, `routes.go`, `docs/API.md`.
|
||||
- Дашборд: `internal/dashboard/handlers_analytics.go`, `templates/analytics.html`, `static/dashboard.css`, пункт меню, форма списка подсетей на `/settings`.
|
||||
|
||||
## 7. Поставка тремя шагами
|
||||
|
||||
1. **Запуски**: миграция, правила, заполнение накопленных данных. Результат: в БД один запуск на 6440 адресов. Видимых изменений в UI нет.
|
||||
2. **API аналитики**: расчёт и эндпоинты.
|
||||
3. **Страница**: макет выше, фильтры в реестре.
|
||||
|
||||
Каждый шаг отдельно собирается, тестируется и выкатывается на стенд.
|
||||
|
||||
## 8. Проверка
|
||||
|
||||
- Контрольные числа: расчёт по запуску на копии боевой БД (`rxprod-compose/capi-db`) должен дать цифры отчётов `analysis/`: 6440 адресов, 1962 `pass` / 4478 `partial`; провал egress у 4437; провал HTTPS по `packages.ubuntu.com` у 3841; 290 адресов провалили все цели; ingress провален у 224; 50 `pass` с поздними провалами; 167 неполных наборов. Если цифры не сходятся, расчёт неверен.
|
||||
- Миграция на копии боевой БД: ровно один запуск, `run_id` заполнен у всех `checks` и `ip_queue`, повторный запуск миграции безопасен.
|
||||
- Тесты: изоляция запусков (два запуска с разными результатами у одного адреса не пересекаются); перепроверка после завершения открывает новый запуск, внутри открытого — нет; поздний результат попадает в свой запуск; идущий запуск не выбирается; новый тип проверки (`dns`) появляется в таблицах; подсети (самый длинный префикс, адрес вне списка → «прочие»).
|
||||
- Нагрузка: страница на запуске 6440 адресов, расчёт до ~1 с, повторный запрос из кэша быстрее; `EXPLAIN QUERY PLAN` по `idx_checks_run`.
|
||||
- Вручную на стенде: выбрать запуск, сверить ключевые числа с отчётами, пройти по ссылке из подсети в реестр.
|
||||
|
||||
## 9. Допущения и ограничения
|
||||
|
||||
- **Валидатор у ingress-проверок.** В `checks.validator_id` для проверок пробера пусто. Валидатор адреса в цикле берётся из события `fip_associated` (поле `validator_id` в payload) по `registry_id` и `cycle_id`. На запуске 02.10 все 950 проваленных ingress-проверок нашли валидатора. Надёжнее писать `validator_id` в ingress-проверку при приёме результата; это отдельное маленькое изменение шага 1.
|
||||
- **Классификатор ошибок** работает по `checks.detail` и типу проверки; на запуске 02.10 он даёт 7 классов с суммой 950 (SSH: таймаут 327, ICMP: нет ответа 276, TCP-22: таймаут 260, SSH: баннер «Not allowed» 47, SSH: нет маршрута 18, ICMP: time exceeded 15, TCP-22: нет маршрута 7). Новые тексты ошибок попадают в класс «прочее» и видны в списке.
|
||||
- **Новое наблюдение для блока «Качество данных».** 844 из 950 проваленных ingress-проверок (89%) записаны после агрегации вердикта (среди всех ingress-проверок после вердикта пришло 1430 из 76644, около 1,9%). То есть таймауты приходят позже вердикта систематически, а не случайно. В отчёте анализа 6.5 это видно только для 246 провалов у адресов `pass`.
|
||||
|
||||
- Список подсетей даёт администратор (форма на `/settings`). Пока он пуст, подсети группируются автоматически по `/24`.
|
||||
- Порог 60 минут для заполнения накопленных данных — только для старых записей, настройкой не делается.
|
||||
- Глубина истории (`history_retention_cycles` > 0) удаляет старые проверки, и аналитика старых запусков теряет детали. По умолчанию 0 (хранить всё). Вердикты в `run_results` остаются.
|
||||
- Перед выкладкой миграции обязательна копия `rxprod-compose/capi-db/control-api.db`.
|
||||
- Вне объёма (следующий этап): сравнение двух запусков, выгрузка в CSV, оповещения.
|
||||
- Вердикт системы и фактические проверки считаются отдельно и показаны рядом (по анализу 6.5 они расходятся у 50 адресов), чтобы поздние результаты не скрывались.
|
||||
|
||||
## 10. Порядок по правилам проекта
|
||||
|
||||
План (этот файл) → реализация по шагам из п.7 → `…-summary.md` на каждый шаг → обновление `README.md` и `docs/` → обновление графа.
|
||||
|
||||
## 11. Уточнения при реализации (макет утверждён)
|
||||
|
||||
- Реализуется страница, точно повторяющая макет `docs/mockups/analytics-mockup.html`; отличия только там, где макет был заглушкой (данные, ссылки) или где этого требует рабочее приложение: время в подписях запусков — локальное время дашборда (как везде в нём), матрица «подсеть × цель» строится для любого типа проверки (в макете для `icmp` её не было), надпись про перепроверки показывается, только если они были.
|
||||
- Решения по открытым вопросам плана приняты по умолчанию: перепроверка после завершения запуска открывает новый запуск; список подсетей хранится в БД (`subnets`) и задаётся на `/settings`, пока пуст — группы по /24.
|
||||
- Миграция `0011` (запуски, `run_results`, `subnets`, `run_id` у `ip_queue` и `checks`, заполнение накопленных данных, валидатор у ingress-проверок). Раньше плана «Аналитика» выполнена миграция `0010` (вердикт без опоздавших результатов).
|
||||
- Ссылки из подсетей ведут в «Реестр» с фильтрами `run` и `subnet` (добавлены в `GET /admin/registry` и `/registry`).
|
||||
- **Сайдбар** (новое требование): убраны три точки возле логотипа; индикатор связи с control-api, переключатель темы и кнопка выхода подняты наверх в блок сессии под логотипом; индикатор краснеет («нет связи»), когда control-api недоступен; разделы разбиты на группы «Мониторинг» (Обзор, Очередь IP, Реестр, Аналитика) и «Настройка» (Валидаторы, Площадки, Цели, Типы проверок, Настройки); нижний блок сайдбара убран.
|
||||
@@ -0,0 +1,83 @@
|
||||
# План: вердикт без опоздавших результатов
|
||||
|
||||
Статус: план, код не менялся. Основа: код `internal/orchestrator`, `internal/probercore`, `internal/httpapi` и БД стенда (запуск 2026-10-02, 6440 адресов).
|
||||
|
||||
## 1. Требование
|
||||
|
||||
Вердикт адреса и его проверки должны совпадать. После вердикта результат не может появиться или измениться. Вердикт можно пересчитать из сохранённых проверок и получить то же значение.
|
||||
|
||||
## 2. Причины (проверены по коду и данным)
|
||||
|
||||
1. **Prober проверяет один адрес много раз.** `GET /probers/{site}/assignments` отдаёт все адреса в состоянии `checking`. Prober (`probercore.pollOnce`) обходит их при каждом опросе (каждые 5 с) и ничего не помнит о том, что адрес уже проверен. Пока адрес ждёт остальные площадки и egress, он проверяется снова и снова.
|
||||
2. **Новый результат затирает старый.** Результаты пишутся через `UpsertCheck` с `ON CONFLICT DO UPDATE`. Строка живёт с первой записи, а значения берутся из последнего круга. В БД 61 406 из 76 644 ingress-строк (80%) перезаписаны позже первой записи более чем на 3 с. У egress перезаписей нет (агент проверяет один раз).
|
||||
3. **Записи принимаются после вердикта.** `Orchestrator.RecordCheck` вызывает `UpsertCheck` без проверки состояния адреса. Итог на запуске 02.10:
|
||||
- ingress: 1430 строк записаны после вердикта (844 проваленных и 586 успешных); из 844 проваленных 833 записаны уже после отвязки Floating IP, то есть проверяли адрес, который к валидатору уже не привязан;
|
||||
- egress: 351 строка после вердикта (32 проваленных).
|
||||
4. **Окно считается не с начала проверки.** `isReadyToAggregate` сравнивает `assigned_at` с порогом `checking_window_seconds` (120 с). `assigned_at` ставится при выдаче адреса валидатору, то есть окно включает привязку Floating IP, паузу `fip_settle_seconds` (30 с) и self-check (до 60 с). На сами проверки остаётся около 30 с. По окну агрегировано 509 адресов (8%), у них часть результатов не успела прийти.
|
||||
5. **Время сравнивается чужими часами.** `checks.checked_at` ставит prober или агент на своей машине, а `aggregated_at` — control-api. При расхождении часов «после вердикта» определяется неверно.
|
||||
|
||||
Следствие: часть проваленных ingress-проверок в БД — не провалы адреса, а результат проверки уже отвязанного Floating IP. Отчёты по провалам ingress (в том числе таймаутам) за запуск 02.10 нужно читать с этой поправкой.
|
||||
|
||||
## 3. Решение
|
||||
|
||||
### A. Одна проверка адреса на площадку и попытку
|
||||
`handleProberAssignments` не отдаёт адрес, для которого эта площадка уже отметила `complete` в `ip_site_checks` в текущей попытке. Новая попытка (повтор после сбоя) отдаёт адрес снова. Prober менять не нужно, пересобирается только `control-api`. Побочный эффект: нагрузка на prober и на сеть падает в разы.
|
||||
|
||||
### B. После вердикта запись закрыта
|
||||
`UpsertCheck` пишет строку только если адрес в состоянии `checking` и номер попытки совпадает. Проверка выполняется в одном SQL-запросе (условие в `INSERT … SELECT … WHERE EXISTS`), без гонок.
|
||||
- Отброшенный результат не пишется и не меняет существующую строку.
|
||||
- Ответ агенту и prober остаётся `200 {"ok": true, "ignored": true}`, чтобы они не повторяли запрос.
|
||||
- На каждую отброшенную пачку пишется одно событие `result_dropped` (источник, число проверок). Счётчик виден в аналитике; в норме он равен нулю или близок к нему.
|
||||
|
||||
### C. Окно проверки считается с начала проверки
|
||||
Новая колонка `ip_queue.checking_started_at`, ставится в `SetChecking`. `isReadyToAggregate` сравнивает порог с ней, а не с `assigned_at`. Значение `checking_window_seconds` не меняется (120 с), но теперь это 120 с именно на проверки. Для строк без значения (старые данные) используется `assigned_at`.
|
||||
|
||||
### D. Время записи ставит сервер
|
||||
Новая колонка `checks.recorded_at` — время последней записи по часам control-api; при перезаписи обновляется. Все сравнения «до/после вердикта» идут по ней, а не по `checked_at`. Для старых строк значение равно `created_at`.
|
||||
|
||||
### E. Вердикт воспроизводим
|
||||
При агрегации в событие `aggregated` дописываются `egress_total`, `ingress_total`. Тест-инвариант: вердикт, пересчитанный по сохранённым проверкам адреса с учётом недостающих, равен `overall_result`.
|
||||
|
||||
### F. Недостающие результаты
|
||||
Правило не меняется: недостающая проверка считается провалом, вердикт не выше `partial`. Благодаря C число таких адресов должно упасть. Остаток показывается отдельно («нет результата»), а не смешивается с провалами.
|
||||
|
||||
### G. Старые данные
|
||||
В миграции `0010` колонка `checks.after_verdict` (0/1) для существующих строк: `1`, если `checked_at` позже `aggregated_at` строки очереди. Пересчитать затёртые результаты нельзя, поэтому в аналитике они помечаются как недостоверные и показываются отдельно (на запуске 02.10: 1430 ingress и 351 egress). После внедрения для новых данных значение всегда `0`.
|
||||
|
||||
Отвергнутый вариант: пересчитывать вердикт при опоздавших результатах. К этому моменту Floating IP уже отвязан, и такие результаты ничего не доказывают.
|
||||
|
||||
## 4. Файлы
|
||||
|
||||
- `internal/db/migrations/0010_verdict_integrity.sql` — колонки `ip_queue.checking_started_at`, `checks.recorded_at`, `checks.after_verdict`.
|
||||
- `internal/db/queries_checks.go` — условная запись, признак «отброшено».
|
||||
- `internal/db/queries_ipqueue.go` — `SetChecking`, выборка для prober без адресов с готовой площадкой.
|
||||
- `internal/orchestrator/orchestrator.go` — `RecordCheck` возвращает признак, `isReadyToAggregate` по `checking_started_at`, поля в событии `aggregated`.
|
||||
- `internal/httpapi/handlers_agent.go`, `handlers_prober.go` — ответ `ignored`, событие `result_dropped`.
|
||||
- Документы: `docs/API.md` (ответы на запись результатов), `docs/USAGE.md`, `README.md`.
|
||||
- Нумерация миграций: этот план идёт раньше плана аналитики (`…_analytics-section-plan.md`); аналитика получает `0011`.
|
||||
|
||||
## 5. Тесты
|
||||
|
||||
- Запись после вердикта игнорируется: строка не создана и не изменена; запись в состоянии `checking` проходит; запись старой попытки игнорируется.
|
||||
- Повторная запись той же проверки внутри окна идемпотентна.
|
||||
- `assignments`: после `complete` площадки адрес не возвращается ей, но возвращается остальным; после повтора попытки возвращается снова.
|
||||
- Окно считается с `checking_started_at`: адрес, назначенный давно, но начавший проверку только что, не агрегируется по окну.
|
||||
- Инвариант вердикта: для набора сценариев (все успешны, часть провалена, часть недостающих, ничего) вердикт равен пересчёту.
|
||||
- Миграция на копии боевой БД: `after_verdict` = 1 для 1430 ingress и 351 egress строк запуска 02.10, остальное без изменений; повторный запуск безопасен.
|
||||
|
||||
## 6. Проверка на стенде
|
||||
|
||||
1. Копия `rxprod-compose/capi-db/control-api.db`, пересборка и перезапуск `control-api` (по процедуре из `docs/SETUP.md`). Prober и агенты не пересобираются.
|
||||
2. Контрольный прогон на небольшой партии (например, 300 адресов).
|
||||
3. Ожидаемый результат: ни одной строки `checks` с `recorded_at` позже `aggregated_at` (`after_verdict = 0`); нет перезаписанных ingress-строк (`recorded_at` равен первой записи); событий `result_dropped` нет или единицы; адресов с агрегацией по окну заметно меньше 8%.
|
||||
4. Сравнение с запуском 02.10: доля проваленных ingress-проверок (таймауты ssh, tcp-22, icmp) должна уменьшиться, если часть прежних провалов была отвязанным Floating IP. Если не уменьшится, версия про отвязку неверна, и нужно искать другую причину.
|
||||
|
||||
## 7. Риски
|
||||
|
||||
- Отброшенные результаты теряются. Это сознательно: после вердикта Floating IP отвязан. Счётчик `result_dropped` показывает, насколько часто это случается.
|
||||
- Уменьшение числа проверок prober меняет нагрузку на сеть площадок. Это ожидаемый эффект, а не риск, но стоит предупредить владельцев площадок.
|
||||
- Новое окно (с начала проверки) может чуть увеличить длительность прогона на адресах, у которых действительно не пришли результаты. Значение окна настраивается.
|
||||
|
||||
## 8. Порядок по правилам проекта
|
||||
|
||||
План (этот файл) → реализация → `…-summary.md` → обновление `README.md` и `docs/` → пересборка стенда → контрольный прогон → обновление графа. Раздел «Аналитика» (план `2026-10-03_16-39`) строится после этого изменения: «Поздние результаты» там становятся контрольным показателем, который должен быть равен нулю.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Вердикт без опоздавших результатов — итог
|
||||
|
||||
План: [2026-10-03_17-21_verdict-no-late-results-plan.md](2026-10-03_17-21_verdict-no-late-results-plan.md).
|
||||
|
||||
Статус: код и тесты готовы. На стенд не выкладывалось, контрольный прогон не проводился.
|
||||
|
||||
## Что сделано
|
||||
|
||||
1. **Один проход площадки на адрес.** `GET /probers/{site}/assignments` больше не отдаёт адрес, для которого эта площадка отметила `complete` в текущей попытке (`DB.ListCheckingForSite`). Другим площадкам адрес выдаётся, новая попытка выдаёт его снова. Prober не менялся.
|
||||
2. **После вердикта запись закрыта.** `DB.UpsertCheckIfOpen` пишет проверку только если строка очереди существует, попытка совпадает с текущей и состояние не `aggregating`, `done`, `failed`, `occupied`. Проверка состояния и запись — один SQL-запрос. Отброшенный результат не создаёт и не меняет строк. Ответ `200 {"ok": true, "ignored": N}`; на каждый адрес пишется событие `result_dropped`. `UpsertCheck` и `Orchestrator.RecordCheck` оставлены как обёртки для существующих вызовов.
|
||||
3. **Окно проверки с её начала.** Новая колонка `ip_queue.checking_started_at` (ставится в `SetChecking`, сбрасывается при повторе). `isReadyToAggregate` считает `checking_window_seconds` от неё, для старых строк — от `assigned_at`.
|
||||
4. **Время записи по часам сервера.** Колонка `checks.recorded_at`, обновляется при каждой принятой записи.
|
||||
5. **Вердикт воспроизводим.** Правило вынесено в чистую функцию `computeVerdict`; в событие `aggregated` добавлены `egress` и `ingress` (число проверок по уровням).
|
||||
6. **Старые данные.** Миграция `0010_verdict_integrity.sql`: `recorded_at` = `created_at`, колонка `after_verdict` = 1 у строк, записанных после вердикта (`julianday(checked_at) > julianday(aggregated_at)`).
|
||||
|
||||
## Проверка на копии боевой БД (запуск 02.10, 141 005 проверок)
|
||||
|
||||
Миграция применилась за секунды, `user_version` = 10, `recorded_at` заполнен у всех строк. Помечено `after_verdict`:
|
||||
|
||||
| | всего | провалов | успешных |
|
||||
|---|--:|--:|--:|
|
||||
| ingress | 1430 | 844 | 586 |
|
||||
| egress | 351 | 32 | 319 |
|
||||
|
||||
В плане было 1426 и 350: они считались сравнением строк времени, миграция сравнивает точнее.
|
||||
|
||||
## Тесты
|
||||
|
||||
- `internal/db/queries_verdict_integrity_test.go`: запись принимается, пока адрес проверяется, и идемпотентна; после начала агрегации и после вердикта не создаёт и не меняет строк; результат прежней попытки отбрасывается; `recorded_at` растёт, `created_at` нет; `ListCheckingForSite` (готовая площадка, другая площадка, новая попытка); `checking_started_at` ставится и сбрасывается; миграция 0010 на базе версии 9.
|
||||
- `internal/orchestrator/verdict_integrity_test.go`: таблица `computeVerdict` (7 случаев); окно от начала проверки и запасной вариант `assigned_at`; полный цикл: после вердикта результат не принят, сохранённые проверки не изменились, вердикт равен пересчёту.
|
||||
- `internal/httpapi/handlers_verdict_integrity_test.go`: площадка получает адрес до `complete`, после него нет, другая площадка получает; результаты prober и агента после вердикта дают `ignored`, строки не изменены, два события `result_dropped`.
|
||||
- `go build ./... && go vet ./... && go test ./...` проходят.
|
||||
|
||||
## Что не проверено
|
||||
|
||||
- Работа на живом стенде и контрольный прогон. Гипотеза «часть прежних провалов ingress — проверка отвязанного Floating IP» подтвердится или нет только им: после изменения доля провалов ssh, tcp-22 и icmp должна упасть. Если не упадёт, причину нужно искать в другом месте.
|
||||
- Нагрузка на сеть площадок изменится: prober теперь зондирует адрес один раз, а не при каждом опросе.
|
||||
|
||||
## Что дальше
|
||||
|
||||
Пересборка и перезапуск только `control-api` (prober и агенты без изменений), копия БД перед миграцией, контрольный прогон на небольшой партии (300 адресов). Раздел «Аналитика» строится после этого и получает миграцию `0011`.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Раздел «Аналитика» — итог
|
||||
|
||||
План: [2026-10-03_16-39_analytics-section-plan.md](2026-10-03_16-39_analytics-section-plan.md). Макет: [docs/mockups/analytics-mockup.html](../mockups/analytics-mockup.html).
|
||||
|
||||
Статус: код, тесты и документация готовы. На стенд не выложено (бинарники `bin/` не пересобирались), не закоммичено.
|
||||
|
||||
## Что сделано
|
||||
|
||||
**Запуски (миграция `0011`).** Новые таблицы `check_runs`, `run_results`, `subnets`; колонки `run_id` у `ip_queue` и `checks`. Запуск открывается, когда адрес попадает в пустую или полностью обработанную очередь (`SubmitIPsAs`, `SeedQueue`), принимает всё добавленное и перепроверенное, пока открыт, и завершается, когда у всех его адресов есть итог или они удалены (`finalizeRunsTx` в `FinishIPExpected`, `CancelIP`, `RequeueOrFail`, `MarkFIPOccupied`, удалении и очистке; плюс страховка в такте оркестратора). Скан автоцикла помечает запуск `auto`. Итог адреса (`run_results`) пишется при вердикте вместе с ожидаемым и записанным числом проверок. Ingress-проверка получает `validator_id` держателя адреса при записи. Накопленные данные размечены миграцией: запуски выделяются паузами больше часа, итоги берутся из очереди или считаются по проверкам (помечаются `verdict_derived`), валидатор ingress восстановлен из события `fip_associated`; живые строки очереди без запуска попадают в открытый запуск при открытии БД.
|
||||
|
||||
**Расчёт (`internal/analytics`).** Показатели считаются по фактам: все проверки последнего цикла каждого адреса запуска, в том числе пришедшие позже вердикта. Блоки: показатели, причины `partial`, качество данных, подсети, цели и матрица «подсеть × цель» по типам проверок, площадки, классы ошибок, валидаторы; списки адресов (4 индикатора и класс ошибки). Подсеть — самая узкая подходящая; без списка — /24.
|
||||
|
||||
**API.** `GET /admin/analytics/runs`, `/runs/{id}` (кэш по версии данных запуска и списку подсетей; открытый запуск — 409), `/runs/{id}/lists/{kind}` (JSON и `?format=csv`), `GET`/`PUT /admin/config/subnets`; фильтры `run` и `subnet` в `GET /admin/registry`.
|
||||
|
||||
**Страница `/analytics`.** Точно по макету: выбор запуска (идущий виден, недоступен), 12 карточек, причины `partial`, качество данных, подсети, egress по целям с вкладками по типу и тепловой картой, ingress по площадкам, классы ошибок, валидаторы; окна со списками адресов, подсказками и выгрузкой CSV (скачивание — с сервера, `Content-Disposition: attachment`). Стили `static/analytics.css` (классы с префиксом `an-`, не пересекаются со стилями дашборда), скрипт `static/analytics.js`. В `/settings` блок «Подсети». Из подсетей и матрицы — переход в «Реестр» с фильтром (`/registry?run=…&subnet=…`, плашка фильтра со сбросом).
|
||||
|
||||
**Сайдбар.** Убраны три точки у логотипа. Индикатор связи с control-api (краснеет «нет связи» при сбое), переключатель темы и кнопка «Выйти» подняты наверх, в блок сессии под логотипом. Разделы разбиты на «Мониторинг» (Обзор, Очередь IP, Реестр, Аналитика) и «Настройка». Нижний блок убран.
|
||||
|
||||
## Проверка
|
||||
|
||||
- `go build ./... && go vet ./... && go test ./...` проходят. Новые тесты: БД (жизненный цикл запуска, присоединение и новый запуск при перепроверке, очистка и удаление, отмена и провал по повторам, валидатор ingress, подсети, фильтры реестра, миграция `0011` на базе версии 10), `internal/analytics` (расчёт по синтетическим данным, подсети, классы ошибок, списки), API (отчёт, списки, CSV, кэш, подсети, фильтры, 409/404/400), дашборд (страница одного запуска и пустое состояние, прокси списков и CSV, сайдбар, индикатор связи, форма подсетей, drill-down в реестр).
|
||||
- **Контрольные числа** на копии боевой БД (запуск 02.10, 6440 адресов) после миграции `0011` совпали с отчётами `analysis/`: 1962 `pass` / 4478 `partial`; Egress OK 2003, Ingress OK 6215; причины `partial` 4146 / 157 / 125 / 9 / 33 / 8; https: есть провалы 4409, все провалены 307 (по всем 5 целям 290); ssh: есть провалы 222, все провалены 7; провалы по целям 3841 / 2193 / 1872 / 1805 / 1728; первая строка матрицы 83.166.248.0/21: 81 / 57 / 51 / 86 / 46; классы ошибок 327 / 276 / 260 / 47 / 18 / 15 / 7; поздние провалы 246 у 50 адресов, 844 из 950 ingress-провалов после вердикта, неполный набор 167, `pass` по фактам 1912. Миграция на копии — около 8 с, расчёт отчёта — около 2,5 с.
|
||||
- **В браузере** (headless Chrome, локальный control-api и дашборд на той же копии): страница на 1440 px и 390 px, без горизонтальной прокрутки на телефоне; окна списков (4 409 строк) и класса ошибок открываются, кнопки внутри окна видны, вкладки типов и матрица работают, CSV отдаётся файлом.
|
||||
|
||||
## Отличия от макета
|
||||
|
||||
- Время в подписях запусков — локальное время дашборда (в макете было UTC).
|
||||
- Матрица «подсеть × цель» строится и для `icmp` (в макете для него её не было).
|
||||
- Строка «Перепроверено внутри запуска» показывается, только если такие адреса есть (в БД стенда прежние циклы этих адресов уже удалены, поэтому 0).
|
||||
- Карточки не переносят значение на вторую строку: минимальная ширина карточки 168 px, на телефоне шрифт значения меньше.
|
||||
- Таблицы данных на телефоне прокручиваются вбок, а не превращаются в карточки, как остальные таблицы дашборда.
|
||||
|
||||
## Что не сделано и ограничения
|
||||
|
||||
- Выкладка на стенд: нужна пересборка `control-api` и `admin-dashboard` и миграция `0011` на боевой БД (копия БД перед ней обязательна; процедура — в памяти проекта и в `docs/SETUP.md`). После выкладки задать список подсетей клиента на `/settings` (45 подсетей из отчёта) — без него адреса группируются по /24.
|
||||
- Подсказки при наведении не работают на сенсорных экранах и с клавиатуры (как и в макете).
|
||||
- В сайдбаре на узком экране (меню-«бургер») новый блок сессии я не просматривал отдельно.
|
||||
- Фильтр реестра по подсети ограничен адресами, попавшими в список подсетей: строка «прочие» без ссылки.
|
||||
@@ -0,0 +1,100 @@
|
||||
# План: повтор после сбоя self-check — на другом валидаторе
|
||||
|
||||
Статус: реализовано, см. [итог](2026-10-04_08-01_self-check-exclude-validator-summary.md).
|
||||
|
||||
## 1. Проблема
|
||||
|
||||
В запуске 2 два адреса получили `fail`: `89.208.220.90` и `89.208.221.74`. Оба раза self-check провалился четыре раза подряд на одном валидаторе `vkiplab-v17` (он выходил в сеть с общего адреса облака `109.120.182.224`, а не с плавающего IP), с интервалом около 70 секунд.
|
||||
|
||||
Причина в логике повтора: после сбоя адрес возвращается в начало очереди, а освободившийся `v17` берёт следующий адрес с наименьшим `sequence`, то есть тот же. Один временно неисправный валидатор расходует все попытки адреса. Сам адрес исправен: `89.208.216.141`, попавший на `v17` дважды, после перехода на `v13` прошёл проверки.
|
||||
|
||||
## 2. Требования и принятые решения
|
||||
|
||||
1. Адрес, не прошедший self-check на валидаторе N, при повторе **не отдаётся валидатору N**. Правило действует только для этого адреса.
|
||||
2. Валидатор N остаётся в системе как был: не блокируется, не помечается неисправным, продолжает брать и проверять все остальные адреса.
|
||||
3. **Итог `fail`** ставится, когда число проваленных self-check у адреса достигло **потолка**. Потолок не зависит от числа валидаторов. Это **настройка** `self_check_max_attempts`: она меняется в разделе настроек `/settings` и через API; **значение по умолчанию для текущего окружения — 5**. Исключение валидаторов (п.1) гарантирует, что первые попытки идут на разных валидаторах: адрес получает `fail` после 5 провалов на 5 разных валидаторах (а не на одном).
|
||||
4. Исключение создаёт **только** проваленный self-check; на ошибки привязки плавающего IP, потерю валидатора и истечение аренды оно не распространяется.
|
||||
5. Сведения «Self-check не прошёл на: …» сохраняются и отдаются в API для последующего показа на странице аналитики.
|
||||
|
||||
## 3. Решение
|
||||
|
||||
### 3.1. Данные (миграция `0012_self_check_failures.sql`)
|
||||
|
||||
- Таблица `ip_self_check_failures(registry_id, run_id, cycle_id, attempt_number, validator_id, failed_at, detail)`. Постоянная история сбоев self-check по адресу; не очищается при перепроверках, живёт вместе с реестром (не удаляется вместе со строкой очереди) и служит источником для аналитики.
|
||||
- Колонки `ip_queue.sc_failures` (сколько self-check провалено в текущей серии попыток) и `ip_queue.sc_round_start_attempt` (номер попытки, с которой действуют исключения; «раунд»).
|
||||
- Настройка `self_check_max_attempts` в таблице `settings`: миграция записывает **5** (допустимо 1…50), дальше значение меняется в `/settings` или через API. Поле `orchestrator.max_self_check_retries` в YAML для self-check больше не используется (остаётся в файле для совместимости, в документации помечается устаревшим).
|
||||
|
||||
Исключение для валидатора N на адресе действует, если в `ip_self_check_failures` есть сбой этого валидатора на этом адресе с `attempt_number >= sc_round_start_attempt`.
|
||||
|
||||
### 3.2. Обработка сбоя (`Orchestrator.SelfCheckResult`, ветка «не прошёл»)
|
||||
|
||||
После проверки, что адрес принадлежит этому валидатору и ждёт self-check, и отвязки плавающего IP (как сейчас):
|
||||
|
||||
1. Записать сбой в `ip_self_check_failures`, увеличить `sc_failures`.
|
||||
2. Решение:
|
||||
- **`sc_failures >= self_check_max_attempts`** → `fail` (причина «self-check не прошёл N раз», в событии перечислены валидаторы);
|
||||
- иначе → вернуть адрес в очередь. Если среди рабочих валидаторов (`idle`, `assigned`, `checking`; `unreachable` и `unregistered` не считаются) не осталось ни одного без сбоя в текущем раунде, начинается новый раунд (`sc_round_start_attempt` = следующая попытка): исключения теряют силу, повтор идёт по обычным правилам. Так при числе валидаторов меньше потолка (в том числе при единственном) повторы продолжаются до потолка, а адрес не застревает в очереди.
|
||||
|
||||
Лимит `max_retries` и счётчик `retry_count` для self-check больше не используются: возврат в очередь после сбоя self-check не увеличивает `retry_count` и не может привести к `fail` по нему. Для остальных причин возврата (ошибка привязки, истечение аренды) всё остаётся как сейчас.
|
||||
|
||||
### 3.3. Выдача адресов (`DB.ClaimNextQueued`)
|
||||
|
||||
Валидатор получает ближайший по `sequence` адрес, **кроме** тех, где он исключён (по 3.1). Исключённый адрес остаётся в очереди для остальных валидаторов и очередь не блокирует. Запрос по-прежнему одной транзакцией.
|
||||
|
||||
### 3.4. Жизненный цикл
|
||||
|
||||
- Ручная перепроверка или повторная постановка адреса (`SubmitIPs`) начинает новую серию: `sc_failures = 0`, `sc_round_start_attempt` = текущая попытка.
|
||||
- История в `ip_self_check_failures` остаётся.
|
||||
- Удаление адреса и очистка очереди историю не трогают; `docs/ADMIN_CLEANUP.md` дополняется новой таблицей.
|
||||
|
||||
### 3.5. Данные для аналитики и видимость
|
||||
|
||||
- Событие `self_check_result` остаётся как сейчас; добавляется событие `validator_excluded` (`{"validator_id": …, "failures": N}`).
|
||||
- `GET /admin/ips/{ip}` и `GET /admin/registry/{ip}` получают поле `self_check_failed_on` (список валидаторов, у которых self-check на этом адресе не прошёл; для реестра — по всем запускам, для запуска — фильтр по `run_id`).
|
||||
- Строка «Self-check не прошёл на: v17, …» на странице адреса (`/ips/{ip}`, `/registry/{ip}`).
|
||||
- **Отображение на странице аналитики не входит в эту доработку:** данные и API будут готовы, показ (колонка в списках адресов, блок «Self-check по валидаторам») делается следующим шагом отдельным планом.
|
||||
|
||||
### 3.6. Настройка в интерфейсе (`/settings`)
|
||||
|
||||
Изменения отражаются в разделе настроек явно:
|
||||
- В форме с `fip_settle_seconds` и `history_retention_cycles` добавляется поле **«Потолок провалов self-check на адрес»** (`self_check_max_attempts`), целое число, по умолчанию 5, допустимо 1…50.
|
||||
- Под полем пояснение: «Сколько раз self-check может не пройти у одного адреса, прежде чем адрес получит итог `fail`. Повторы идут на других валидаторах: валидатор, на котором адрес не прошёл self-check, этому адресу больше не выдаётся (на остальные адреса это не влияет)».
|
||||
- Значение сохраняется той же кнопкой «Сохранить», действует на следующих повторах без перезапуска; неверное значение (не число, вне 1…50) показывается предупреждением в баннере, форма остаётся прежней.
|
||||
- В API: `GET/PUT /admin/config/orchestrator` получает поле `self_check_max_attempts` (ошибка 400 при неверном значении).
|
||||
- Описание поля добавляется в `docs/DASHBOARD.md` (строка `/settings`) и `docs/USAGE.md`.
|
||||
|
||||
## 4. Файлы
|
||||
|
||||
- БД: `internal/db/migrations/0012_self_check_failures.sql`, `db.go`, `queries_ipqueue.go` (`ClaimNextQueued`, запись сбоя, новый возврат в очередь без `retry_count`, принудительный `fail`, сброс серии в `SubmitIPsAs`), `queries_settings.go` (настройка), запрос истории для API.
|
||||
- Оркестратор: `orchestrator.go` (`SelfCheckResult`), `config.go` (начальное значение настройки).
|
||||
- API и дашборд: `handlers_admin.go`, `handlers_config.go`, DTO, `handlers_settings.go`, `settings.html`, `ip_detail.html`, `registry_detail.html`.
|
||||
- Документы: `USAGE.md` («Как читать итоговый результат», повторы self-check, настройка), `API.md`, `ADMIN_CLEANUP.md`, `README.md`, итог `docs/changes/…-summary.md`.
|
||||
|
||||
## 5. Тесты
|
||||
|
||||
- **БД:** валидатор, на котором адрес исключён, пропускает его и берёт следующий; другой валидатор берёт исключённый адрес; исключения действуют только для этого адреса; новый раунд снимает исключения; перепостановка сбрасывает серию, но не историю; настройка (значения по умолчанию, границы).
|
||||
- **Оркестратор (сценарий инцидента на трёх валидаторах):** v1 всегда проваливает self-check — адрес не возвращается на v1, проходит на v2, а v1 в это время берёт и проходит другие адреса; потолок: адрес, провалившийся `self_check_max_attempts` раз, получает `fail`, раньше — нет; при потолке меньше числа валидаторов `fail` наступает на потолке без перебора всех; при потолке больше числа валидаторов начинается новый раунд; единственный валидатор повторяет на себе до потолка; `unreachable` не считается валидатором; сбой привязки и истечение аренды исключений и счётчика self-check не создают и по-прежнему идут по `max_retries`; изменение настройки действует на следующем повторе.
|
||||
- **API и дашборд:** поле `self_check_failed_on`; поле «Потолок провалов self-check на адрес» отображается в `/settings` со значением 5, сохраняется, неверные значения показывают предупреждение; поле в `GET/PUT /admin/config/orchestrator`; строка «Self-check не прошёл на: …» на странице адреса.
|
||||
- Миграция `0012` на копии боевой БД (настройка равна 5), повторный запуск безопасен. Полный `go build ./... && go vet ./... && go test ./...`.
|
||||
|
||||
## 6. Выкладка и проверка
|
||||
|
||||
Пересборка и перезапуск `control-api` и `admin-dashboard` (меняется форма настроек и страница адреса); агенты и prober без изменений. Порядок: проверка пустой очереди, копия БД, тег отката образа, миграция `0012`. Сбой на стенде воспроизвести нельзя, поэтому проверка там — штатный небольшой прогон без зависших `queued`; логику повторов покрывает тест на трёх валидаторах.
|
||||
|
||||
## 7. Риски и последствия принятого решения
|
||||
|
||||
- **Время до `fail` у неисправного адреса** ограничено потолком: 5 попыток по ~70 секунд, около 6 минут вместо ~5 минут сейчас (4 попытки). Очередь и другие валидаторы это не блокирует.
|
||||
- **Потолок меньше числа валидаторов.** Адрес, которому не повезло с 5 неисправными валидаторами подряд, получит `fail`, хотя на остальных 15 прошёл бы. Это сознательный компромисс в пользу ограниченного времени; потолок меняется в настройках.
|
||||
- Адрес, исключённый на части валидаторов, может чуть дольше ждать подходящего валидатора.
|
||||
- Если сбой общий (проблема облака для всех валидаторов), адрес в итоге получит `fail`; это верное поведение.
|
||||
- Не решается: причина сбоя `v17` в облаке (трансляция плавающего IP) — её нужно смотреть в OpenStack.
|
||||
|
||||
## 8. Решения по согласованию (все приняты)
|
||||
|
||||
1. Потолок попыток задаётся независимо от числа валидаторов; значение меняется в меню настроек (`/settings`) и через API.
|
||||
2. Значение по умолчанию для текущего окружения — 5.
|
||||
3. Исключение действует только для проваленного self-check; на ошибки привязки не распространяется.
|
||||
4. «Self-check не прошёл на: …» сохраняется и отдаётся в API; показ на странице аналитики — следующим шагом.
|
||||
5. Изменения отражаются в разделе настроек UI (п.3.6).
|
||||
|
||||
Открытых вопросов нет. Жду команды начать реализацию.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Итог: повтор после сбоя self-check — на другом валидаторе
|
||||
|
||||
План: [2026-10-04_08-01_self-check-exclude-validator-plan.md](2026-10-04_08-01_self-check-exclude-validator-plan.md).
|
||||
Статус: код написан и проверен (gofmt, build, vet, test, миграция на копии боевой БД); стенд **не пересобирался**.
|
||||
|
||||
## Что изменено
|
||||
|
||||
- **Миграция `0012_self_check_failures.sql`** (версия схемы 12): таблица `ip_self_check_failures` (история сбоев по адресу, по `registry_id`, без внешних ключей); колонки `ip_queue.sc_failures` и `ip_queue.sc_round_start_cycle`; настройка `settings.self_check_max_attempts` (`DEFAULT 5`, допустимо 1…50).
|
||||
- **`db.ClaimNextQueued`** пропускает адреса, на которых этот валидатор провалил self-check в текущем раунде; адрес остаётся в очереди для других валидаторов.
|
||||
- **`db.FailSelfCheck`** (одна транзакция): записывает сбой, увеличивает `sc_failures`; при `sc_failures >= потолка` — `fail`; иначе возвращает адрес в очередь без изменения `retry_count`; если рабочих валидаторов без сбоя в раунде не осталось — новый раунд. Позднее сообщение (адрес не у этого валидатора) — `ErrInvalidState`.
|
||||
- **Оркестратор** (`SelfCheckResult` → `failSelfCheck`): потолок читается из настроек на каждом сбое. События: `retry_or_fail` (при `fail` — со списком валидаторов) и `validator_excluded`. `max_self_check_retries` не используется (поле в YAML осталось).
|
||||
- **`SubmitIPsAs`/`SeedQueue`**: перепостановка начинает новую серию (`sc_failures = 0`, новый раунд); история остаётся.
|
||||
- **API**: `GET/PUT /admin/config/orchestrator` — поле `self_check_max_attempts` (400 вне 1…50; в PUT необязательно); `self_check_failed_on` в `GET /admin/ips/{ip}` (фильтр по запуску) и `GET /admin/registry/{ip}` (все запуски).
|
||||
- **Дашборд**: поле «Потолок провалов self-check на адрес» на `/settings`; строка «Self-check не прошёл на: …» на `/ips/{ip}` и `/registry/{ip}`.
|
||||
- **Документы**: `USAGE.md` (новый раздел «Повтор после сбоя self-check»), `API.md`, `DASHBOARD.md`, `ADMIN_CLEANUP.md` (новая таблица в сценариях 3.1 и 3.4), `README.md`.
|
||||
|
||||
## Отступления от плана
|
||||
|
||||
1. Раунд хранится как `sc_round_start_cycle` (номер `cycle_id`), а не `sc_round_start_attempt`: `attempt_number` сбрасывается при удалении и повторном создании строки очереди, а история остаётся — новая строка получила бы чужие исключения. `cycle_id` по адресу не повторяется. Поведение для оператора то же.
|
||||
2. Значение 5 задано `DEFAULT 5` колонки (покрывает и существующую строку `settings`, и чистую установку), `config.go` не менялся.
|
||||
3. В `PUT` потолок необязателен — старые клиенты без поля не получают 400.
|
||||
4. `validator_excluded` не пишется, если начался новый раунд (исключение сразу теряет силу).
|
||||
|
||||
## Проверки
|
||||
|
||||
- `gofmt -l` — пусто; `go build ./...`, `go vet ./...` — без ошибок; `go test ./...` — все пакеты `ok`. Две проверки версии схемы в старых тестах миграций (`TestMigration0010…`, `TestMigration0011…`) обновлены с 11 на 12.
|
||||
- Новые тесты: БД (`queries_selfcheck_test.go`), оркестратор (сценарий на трёх валидаторах, потолок, один и два валидатора, смена настройки), API, дашборд.
|
||||
- Миграция `0012` на копии боевой БД: версия 11 → 12, `self_check_max_attempts = 5`, повторное открытие без ошибок, таблица сбоев пуста.
|
||||
|
||||
## Выкладка
|
||||
|
||||
Не выполнена. Нужны пересборка и перезапуск `control-api` и `admin-dashboard` по процедуре (проверка пустой очереди, копия БД, тег отката `pre-self-check-exclude`, миграция `0012`); на стенде в очереди сейчас 6489 адресов `done`, `queued` нет.
|
||||
@@ -0,0 +1,76 @@
|
||||
# План: кликабельные индикаторы PASS, PARTIAL, FAIL в разделе «Аналитика»
|
||||
|
||||
Статус: реализовано, см. [итог](2026-10-04_09-55_analytics-verdict-indicators-summary.md).
|
||||
|
||||
## 1. Что нужно
|
||||
|
||||
Вверху страницы `/analytics` есть ряд индикаторов. Сейчас кликабельны только четыре «прикладных»: «Egress https: есть провалы / все провалены» и «Ingress ssh: есть провалы / все провалены» (открывают окно со списком адресов, есть «Скачать CSV» и «Копировать»). Нужно сделать кликабельными ещё три: **pass**, **partial**, **fail**. По клику открывается тот же диалог со списком адресов с этим вердиктом.
|
||||
|
||||
Остальные индикаторы («Адресов», «Egress OK», «Ingress OK», «Длительность», «Поздние результаты») остаются как есть: в задаче их нет.
|
||||
|
||||
## 2. Решение
|
||||
|
||||
Новые виды списков строятся по тому же механизму, что и существующие: control-api отдаёт таблицу (JSON или CSV), дашборд проксирует, JS открывает диалог. Новых маршрутов, миграций и новых данных не нужно: вердикт, число проверок и причина `partial` уже вычисляются в `analytics.Compute`.
|
||||
|
||||
### 2.1. control-api (`internal/analytics/lists.go`)
|
||||
|
||||
Три новых вида списка (`kind`): `verdict_pass`, `verdict_partial`, `verdict_fail`. В список входят адреса запуска с этим вердиктом; отменённые (`cancelled`) не входят, как и в остальных списках и в числах `summary`. Порядок — по числовому адресу (`an.sorted()`).
|
||||
|
||||
Столбцы:
|
||||
|
||||
| Вид | Столбцы |
|
||||
|---|---|
|
||||
| `verdict_pass` | Адрес, Подсеть, Валидатор, Egress, Ingress, Проверок в цикле |
|
||||
| `verdict_partial` | Адрес, Подсеть, Валидатор, Egress, Ingress, Проверок в цикле, **Причина** |
|
||||
| `verdict_fail` | Адрес, Подсеть, Валидатор, Egress, Ingress, Проверок в цикле |
|
||||
|
||||
- «Валидатор» — тот же, что в списках https (`ShortValidator`); пусто → «—».
|
||||
- «Egress» и «Ingress» — «успешно из всех» по уровню (`8 из 10`); у уровня без проверок — «—».
|
||||
- «Проверок в цикле» — сохранённых из ожидаемых (`stored из ExpectedChecks`; если ожидаемое неизвестно, только сохранённое). Позволяет сразу увидеть «неполный набор».
|
||||
- «Причина» у `partial` — та же формулировка, что в блоке «Почему partial» (`reasonName`), поэтому строки списка совпадают со столбиками блока.
|
||||
|
||||
Название CSV: `verdict_pass_run<N>.csv` и т. д. (по общему правилу). Для остальных видов ничего не меняется. Неизвестный `kind` по-прежнему даёт `404`.
|
||||
|
||||
Инвариант: число строк списка равно числу на карточке (`summary.pass / partial / fail`).
|
||||
|
||||
### 2.2. Дашборд (`internal/dashboard/static/analytics.js`, `analytics.css`)
|
||||
|
||||
- В `renderKpis` у плиток `pass`, `partial`, `fail` в поле списка ставится соответствующий `kind`, плитка становится `<button class="an-kpi" data-list=…>` и получает «список →», как у прикладных. У `fail` при значении 0 плитка остаётся некликабельной (список пуст, сейчас там надпись «ни одной полностью проваленной»).
|
||||
- В `LISTS` добавляются три записи: заголовок («Адреса с вердиктом pass» и т. д.), пояснение («Вердикт адреса за последний цикл запуска, выставленный системой при агрегации…»), подсказки к столбцам (что значит Egress/Ingress, «Проверок в цикле», «Причина»).
|
||||
- Обработчик клика и диалог менять не нужно: `[data-list]` уже подхватывает любую плитку. Стили есть (`button.an-kpi`); `an-go` для плитки без тега «успех/частично/провал» ставится рядом с существующим тегом.
|
||||
- Серверной части дашборда (`handlers_analytics.go`, прокси списка и CSV) менять не нужно: `kind` в пути произвольный.
|
||||
|
||||
### 2.3. Объём списка pass
|
||||
|
||||
В запуске на ~6400 адресов `pass` может быть несколько тысяч строк. Диалог сейчас показывает все строки. Оставляем так (таблица на несколько тысяч строк в браузере открывается нормально) и проверяем на реальном объёме при выкладке; если окно тормозит — отдельным шагом добавим показ первых N строк с пометкой «полный список — в CSV». Выгрузка в CSV и копирование всегда полные.
|
||||
|
||||
## 3. Файлы
|
||||
|
||||
- `internal/analytics/lists.go` — константы, столбцы и строки новых видов.
|
||||
- `internal/analytics/analytics.go` — только если нужна вспомогательная функция для строки «Egress/Ingress» (иначе без изменений).
|
||||
- `internal/httpapi/handlers_analytics.go` — только комментарий с перечнем видов.
|
||||
- `internal/dashboard/static/analytics.js` — плитки и `LISTS`.
|
||||
- Документы: `docs/API.md` (перечень `kind`), `docs/USAGE.md` («Что можно открыть»), `docs/DASHBOARD.md` (строка `/analytics`), `README.md` (веха), итог `docs/changes/…-summary.md`.
|
||||
|
||||
## 4. Тесты
|
||||
|
||||
- `analytics`: для набора с pass, partial, fail и cancelled — число строк каждого списка равно `summary`; отменённые не входят; порядок по адресу; столбцы и значения «Egress/Ingress/Проверок в цикле»; «Причина» совпадает с `reasons`; у неполного набора в «Проверок в цикле» меньше ожидаемого.
|
||||
- `httpapi`: `GET …/lists/verdict_partial` отдаёт JSON, `?format=csv` — файл с именем `verdict_partial_run<N>.csv`; неизвестный вид — `404` (уже есть, расширить).
|
||||
- `dashboard`: прокси списка пропускает новые виды; в отданных данных страницы плитки pass/partial/fail имеют ссылки (если страница это проверяет без JS — иначе проверка вручную, п. 5).
|
||||
- Полный `gofmt -l`, `go build ./... && go vet ./... && go test ./...`.
|
||||
|
||||
## 5. Выкладка и проверка
|
||||
|
||||
Меняются `control-api` (список) и `admin-dashboard` (статика встроена в бинарник); агенты и prober без изменений, миграций нет. Порядок: проверка пустой очереди, тег отката образов, пересборка, перезапуск. Проверка на стенде: на последнем запуске открыть плитки pass, partial, fail, сверить число в заголовке диалога с числом на плитке, скачать CSV, проверить фильтрацию и подсказки; оценить скорость окна на списке pass.
|
||||
|
||||
## 6. Риски
|
||||
|
||||
- Большой список pass: см. 2.3.
|
||||
- Вердикт — оценка системы при агрегации, а не по фактическим проверкам (см. «Качество данных»): у адреса pass могут быть поздние провалы. В пояснении диалога это указывается; столбцы Egress/Ingress показывают фактические проверки.
|
||||
- Числа на плитках и в списках берутся из одного вычисления отчёта, поэтому расходиться не должны (инвариант в тесте).
|
||||
|
||||
## 7. Не входит в доработку
|
||||
|
||||
Кликабельность «Egress OK», «Ingress OK», «Адресов», «Поздних результатов»; фильтры и сортировка внутри диалога; ссылка из строки диалога в «Реестр».
|
||||
|
||||
Открытых вопросов нет. Жду команды начать реализацию.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Итог: кликабельные индикаторы pass, partial, fail в «Аналитике»
|
||||
|
||||
План: [2026-10-04_09-55_analytics-verdict-indicators-plan.md](2026-10-04_09-55_analytics-verdict-indicators-plan.md).
|
||||
Статус: код написан и проверен (gofmt, build, vet, test, синтаксис JS); стенд **не пересобирался**.
|
||||
|
||||
## Что изменено
|
||||
|
||||
- **control-api** (`internal/analytics/lists.go`): виды списков `verdict_pass`, `verdict_partial`, `verdict_fail`. Строки: адреса запуска с этим вердиктом, без `cancelled`, по числовому порядку. Столбцы: Адрес, Подсеть, Валидатор, Egress, Ingress, «Проверок в цикле»; у `partial` ещё «Причина» (та же формула, что в блоке «Почему partial»). Число строк равно `summary.pass/partial/fail`.
|
||||
- Метод `addr.incomplete()` (`analytics.go`): «неполный набор» считается одним кодом для списка и для отчёта; логика отчёта не менялась.
|
||||
- **Дашборд** (`analytics.js`): плитки `pass`, `partial`, `fail` стали кнопками со списком и «список →»; `fail` с нулём остаётся некликабельной. В `LISTS` три записи с пояснением и подсказками к столбцам. Диалог, CSS и прокси дашборда не менялись.
|
||||
- **Документы**: `API.md`, `USAGE.md`, `DASHBOARD.md`, `README.md`.
|
||||
|
||||
## Особенности
|
||||
|
||||
- «Валидатор» берётся по https-проверкам (как в списках https): у адреса без https-проверок он «—».
|
||||
- «Проверок в цикле» при неизвестном ожидаемом числе (`-1`) — только записанное.
|
||||
- Вердикт — оценка системы при агрегации; Egress/Ingress в списке показывают фактические проверки. Пояснение есть в диалоге.
|
||||
|
||||
## Проверки
|
||||
|
||||
- `gofmt -l` пусто (одно выравнивание в тесте поправлено), `go build ./...`, `go vet ./...`, `go test ./...` — все пакеты `ok`; синтаксис `analytics.js` проверен.
|
||||
- Новые тесты: `TestVerdictLists` (инвариант «строк = summary», отмена, порядок, столбцы, «—», неполный набор, причина совпадает с `reasons`); `TestAnalyticsEndpoints` расширен (JSON, CSV `verdict_partial_run<N>.csv`, `404` на неизвестный вид).
|
||||
- Не проверено: открытие диалога в браузере и скорость окна на списке pass (нужна выкладка).
|
||||
|
||||
## Выкладка
|
||||
|
||||
Не выполнена. Нужны пересборка и перезапуск `control-api` и `admin-dashboard` (статика встроена в бинарник); миграций нет.
|
||||
@@ -0,0 +1,89 @@
|
||||
# План: сравнение двух запусков в разделе «Аналитика»
|
||||
|
||||
Статус: реализовано, см. [итог](2026-10-04_10-08_analytics-run-compare-summary.md).
|
||||
|
||||
## 1. Что нужно
|
||||
|
||||
Администратор выбирает два завершённых запуска и видит динамику между ними по семи индикаторам страницы «Аналитика»: `pass`, `partial`, `fail`, «Egress https: есть провалы», «Egress https: все провалены», «Ingress ssh: есть провалы», «Ingress ssh: все провалены». Отдельно выделяются три группы адресов:
|
||||
|
||||
1. **Новые**: есть в новом запуске, в старом не было. Нужны, чтобы понять, какие адреса прибыли в проект и подключились к анализу.
|
||||
2. **Изменившиеся**: есть в обоих запусках, но состояние по проверкам разное. Для них показывается подробная сводка, что именно изменилось.
|
||||
3. **Выбывшие**: были в старом запуске, в новом их нет. Нужны, чтобы понять, какие адреса вышли из состава проекта.
|
||||
|
||||
Адреса, которые есть в обоих запусках и не изменились, только считаются (список доступен, но не выделяется).
|
||||
|
||||
## 2. Основные решения
|
||||
|
||||
- **Отдельная страница `/analytics/compare`**, а не режим текущей: страница одного запуска остаётся как есть («данные других запусков на странице не участвуют»). На `/analytics` добавляется кнопка «Сравнить с другим запуском», она ведёт на сравнение с текущим запуском в роли нового.
|
||||
- **Выбор запусков.** Два списка: «Запуск A (старый)» и «Запуск B (новый)» и кнопка «Поменять местами». По умолчанию B — последний завершённый запуск, A — предыдущий. Порядок не навязывается: «новые» всегда означает «есть в B, нет в A». Доступны только завершённые запуски с адресами (как в текущем выборе). Одинаковые A и B — ошибка `400`.
|
||||
- **Адрес — это IP.** В запуске он присутствует, если у него есть итог, отличный от `cancelled`. Отменённые адреса из сравнения исключаются (в обоих запусках), их число показывается примечанием, чтобы они не выглядели как «новые» или «выбывшие».
|
||||
- **Состояние адреса** — принадлежность к семи индикаторам по последнему циклу адреса в запуске (те же правила, что в отчёте запуска; один адрес может входить в несколько, вердикт — ровно в один из трёх). Адрес **изменился**, если принадлежность хотя бы к одному индикатору в A и B разная. Адрес с теми же индикаторами, но другим набором проваленных целей или площадок считается неизменившимся (см. риски).
|
||||
- **Расчёт на control-api** по двум уже существующим `Analysis` (кэш завершённых запусков переиспользуется). Новых таблиц, миграций и новых данных нет.
|
||||
|
||||
## 3. Решение
|
||||
|
||||
### 3.1. control-api (`internal/analytics/compare.go`, новый)
|
||||
|
||||
- Таблица семи индикаторов (ключ, название, предикат по адресу): ключи совпадают с видами списков `verdict_pass`, `verdict_partial`, `verdict_fail`, `egress_https_any`, `egress_https_all`, `ingress_ssh_any`, `ingress_ssh_all`. Предикаты повторяют условия `Compute`/`List`; тест проверяет, что число адресов по каждому индикатору в каждом запуске равно соответствующему полю `summary`.
|
||||
- `Compare(a, b *Analysis) *Comparison`:
|
||||
- группы: `new`, `left`, `common`, `changed`, `same` (числа; `common = changed + same`);
|
||||
- по каждому индикатору: `base`, `target`, `delta`, `new` (новые адреса в индикаторе), `left` (выбывшие, были в индикаторе), `entered` (общие адреса, вошедшие в индикатор), `exited` (общие адреса, вышедшие из индикатора). Инвариант: `delta = new − left + entered − exited` (проверяется тестом);
|
||||
- матрица переходов вердикта для общих адресов (3×3), плюс строка «нет в A» (новые) и столбец «нет в B» (выбывшие);
|
||||
- число отменённых адресов в каждом запуске.
|
||||
- `(*Comparison) List(group, filter)`: таблица адресов. Группы: `new`, `left`, `changed`, `same`, `entered`, `exited`. Фильтры: `indicator` (для `new`/`left` — адрес входит в индикатор в своём запуске; для `changed`/`same` — принадлежность этому индикатору; для `entered`/`exited` обязателен), `from`+`to` (вердикт в A и в B, для ячейки матрицы). Порядок — по числовому адресу. Неверная группа или индикатор — ошибка (`404`).
|
||||
- Столбцы списков:
|
||||
- `new`: Адрес, Подсеть, Вердикт, Egress, Ingress, Индикаторы (в B);
|
||||
- `left`: те же столбцы по запуску A;
|
||||
- `changed`, `entered`, `exited`, `same`: Адрес, Подсеть, Вердикт (A → B), Egress (A → B), Ingress (A → B), **Что изменилось**. «Что изменилось» — текст по шагам: «вердикт partial → pass», «вошёл в: Egress https: все провалены», «вышел из: Ingress ssh: есть провалы», «https: провалены цели +a.test −b.test», «ssh: площадки −rxmsk», «валидатор v3 → v12». У `same` — «без изменений».
|
||||
- Для текста нужны из `addr` уже имеющиеся поля (`https.failedTargets`, `ssh.sites`, `https.validator`, статистики egress/ingress); новых вычислений в `Compute` нет.
|
||||
|
||||
### 3.2. API (`internal/httpapi/handlers_analytics.go`)
|
||||
|
||||
- `GET /api/v1/admin/analytics/compare?base=A&target=B` → отчёт сравнения (`runs`: сведения об обоих запусках, `groups`, `indicators`, `transitions`, `cancelled`).
|
||||
- `GET /api/v1/admin/analytics/compare/lists/{group}?base=A&target=B[&indicator=…][&from=…&to=…][&format=csv]` → таблица или CSV (UTF-8 с BOM; имя `compare_<group>[_<indicator>]_run<A>-<B>.csv`).
|
||||
- Ошибки: `400` (нет или неверные id, `base = target`), `404` (запуска нет, неизвестная группа/индикатор), `409` (запуск ещё идёт).
|
||||
- Загрузка `Analysis` по id выносится из `analysisFor` в общую функцию, чтобы сравнение и страница одного запуска пользовались одним кэшем.
|
||||
|
||||
### 3.3. Дашборд
|
||||
|
||||
- Страница `/analytics/compare?base=A&target=B` (`handlers_analytics.go`, `templates/analytics_compare.html`, `static/analytics-compare.js`), прокси списков и CSV: `/analytics/compare/lists/{group}` и `/analytics/compare/csv/{group}`. Состояние страницы целиком в адресе (можно отправить ссылку).
|
||||
- Содержимое страницы сверху вниз:
|
||||
1. Выбор A и B, «Поменять местами», «Сравнить»; под ним примечание: даты, тип и размер обоих запусков, число отменённых.
|
||||
2. Карточки: «Новые», «Выбывшие», «Общие», «Изменились», «Без изменений». Кликабельны, открывают диалог со списком.
|
||||
3. «Динамика по индикаторам»: строка на индикатор, столбцы A, B, Δ (рост `pass` и падение остальных — зелёным, обратное — красным), Новые, Выбывшие, Вошли, Вышли. Ненулевые числа кликабельны (список с соответствующими `group` и `indicator`).
|
||||
4. «Переходы вердикта»: матрица 3×3 по общим адресам + «нет в A» / «нет в B»; ячейки кликабельны.
|
||||
5. Диалог со списком, «Скачать CSV», «Копировать» — тот же, что на странице запуска.
|
||||
- Код диалога (открытие, заполнение, CSV, копирование, подсказки) выносится из `analytics.js` в общий `analytics-dialog.js`, разметка диалога — в общий шаблон; страница одного запуска переходит на него без изменения поведения.
|
||||
- На `/analytics` добавляется кнопка «Сравнить с другим запуском».
|
||||
|
||||
## 4. Файлы
|
||||
|
||||
- `internal/analytics/compare.go` (новый), `internal/analytics/compare_test.go` (новый).
|
||||
- `internal/httpapi/handlers_analytics.go` (общий загрузчик, два новых обработчика), `routes`.
|
||||
- `internal/dashboard`: `handlers_analytics.go` (страница и прокси), `client.go`, `routes.go`, `templates/analytics_compare.html`, `templates/analytics.html` (кнопка, общий диалог), `static/analytics.js`, `static/analytics-dialog.js` (новый), `static/analytics-compare.js` (новый), `static/analytics.css`.
|
||||
- Документы: `docs/API.md`, `docs/USAGE.md` («Аналитика запусков»), `docs/DASHBOARD.md`, `README.md`, итог `docs/changes/…-summary.md`.
|
||||
|
||||
## 5. Тесты
|
||||
|
||||
- `analytics`: два запуска с новыми, выбывшими, изменившимися и неизменившимися адресами, с отменёнными; числа групп; инвариант `delta = new − left + entered − exited` по каждому индикатору; число адресов по индикатору равно `summary` каждого запуска; матрица переходов; списки каждой группы и фильтры (`indicator`, `from`/`to`); текст «Что изменилось» (вердикт, вход/выход из индикатора, цели, площадки, валидатор); порядок по адресу; пустые группы.
|
||||
- `httpapi`: отчёт и списки (JSON, CSV с именем файла), `400` на одинаковые и неверные id, `404` на неизвестные запуск/группу/индикатор, `409` на незавершённый запуск.
|
||||
- `dashboard`: страница с запуском по умолчанию, с явным `base`/`target`, предупреждение на неверные id, прокси списка и CSV; страница одного запуска после выноса диалога по-прежнему отдаёт данные и скрипты.
|
||||
- `gofmt -l`, `go build ./... && go vet ./... && go test ./...`, проверка синтаксиса JS.
|
||||
|
||||
## 6. Выкладка и проверка
|
||||
|
||||
Меняются `control-api` и `admin-dashboard` (статика встроена); агенты, prober, миграции без изменений. Порядок: проверка пустой очереди, тег отката образов, пересборка, перезапуск. Проверка на стенде: сравнение запусков 1 и 2 через API (сумма групп равна числу адресов, инвариант по индикаторам), затем страница в браузере: выбор запусков, обмен местами, открытие диалогов из карточек, таблицы и матрицы, CSV, а также что страница одного запуска работает как раньше.
|
||||
|
||||
## 7. Риски
|
||||
|
||||
- **Что считается изменением.** Только принадлежность к семи индикаторам. Адрес, который остался в «Egress https: есть провалы», но у которого поменялись проваленные цели, попадает в «без изменений»; в списке `same` его сводка «без изменений» это не показывает. Если такие случаи важны, следующим шагом можно считать изменением и смену наборов целей и площадок.
|
||||
- **Размер списков.** Группа новых или `same` может содержать тысячи строк (диалог показывает все, как и другие списки); CSV полный.
|
||||
- **Разный состав запусков.** Если запуск 1 был частичным (например, перепроверка), то в сравнении будет много «новых» и «выбывших»: это отражает состав запусков, а не динамику. Размеры обоих запусков показаны в примечании над таблицами.
|
||||
- Вердикт — оценка системы при агрегации, а не итог всех проверок (см. «Качество данных»); сравнение вердикта это не меняет, столбцы Egress и Ingress показывают фактические проверки.
|
||||
- Вынос кода диалога затрагивает работающую страницу; закрывается тестами и ручной проверкой п. 6.
|
||||
|
||||
## 8. Не входит в доработку
|
||||
|
||||
Сравнение более чем двух запусков, графики динамики по времени, разбивка новых и выбывших по подсетям, сравнение по другим показателям (классы ошибок, площадки, валидаторы), автоматический выбор «интересных» пар запусков.
|
||||
|
||||
Открытых вопросов нет. Жду команды начать реализацию.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Итог: сравнение двух запусков в разделе «Аналитика»
|
||||
|
||||
План: [2026-10-04_10-08_analytics-run-compare-plan.md](2026-10-04_10-08_analytics-run-compare-plan.md).
|
||||
Статус: код написан и проверен (gofmt, build, vet, test, синтаксис JS); стенд **не пересобирался**.
|
||||
|
||||
## Что изменено
|
||||
|
||||
- **control-api** (`internal/analytics/compare.go`): таблица семи индикаторов (ключи совпадают с видами списков), `Compare(base, target)` — группы `new`, `left`, `common`, `changed`, `same`, разложение каждого индикатора (`delta = new − left + entered − exited`), матрица переходов вердикта, число отменённых. `List(group, filter)` с фильтрами `indicator`, `from`/`to` и столбцом «Что изменилось».
|
||||
- **API**: `GET /admin/analytics/compare` и `GET /admin/analytics/compare/lists/{group}` (JSON и CSV); `400` — неверные или одинаковые id, `404` — нет запуска, группы, индикатора или фильтра, `409` — запуск идёт. Загрузка отчёта запуска вынесена в общую функцию, кэш общий.
|
||||
- **Дашборд**: страница `/analytics/compare` (выбор A и B, «Поменять местами», карточки групп, «Динамика по индикаторам», «Переходы вердикта», диалог со списком и CSV); прокси списков и CSV; кнопка «Сравнить с другим запуском» на `/analytics`.
|
||||
- **Общий диалог**: код списков вынесен в `static/analytics-dialog.js`, разметка — в шаблон `analytics_dialog.html`; страница одного запуска перешла на него.
|
||||
- **Документы**: `API.md`, `USAGE.md`, `DASHBOARD.md`, `README.md`.
|
||||
|
||||
## Отклонения от плана
|
||||
|
||||
1. Добавлена группа `common` (карточка «Общие» и ячейки матрицы открывают список через неё).
|
||||
2. Фильтр `indicator` для `changed`/`same`/`common` — «хотя бы в одном из запусков».
|
||||
3. Неверные `from`/`to` дают `404`, как другие неверные виды списков; к имени CSV добавляется суффикс `_<from>-<to>`.
|
||||
4. Разметка диалога вынесена в отдельный шаблон.
|
||||
|
||||
## Особенности
|
||||
|
||||
- Адрес, оставшийся в тех же индикаторах, но с другим набором проваленных целей или площадок, считается неизменившимся (`same`).
|
||||
- Дубли IP внутри запуска не обрабатываются (в реестре IP уникален).
|
||||
|
||||
## Проверки
|
||||
|
||||
- `gofmt -l` пусто; `go build ./...`, `go vet ./...`, `go test ./...` — все пакеты `ok`; `node --check` для трёх JS-файлов.
|
||||
- Исправлено при ревью: в тесте дашборда число с неразрывным пробелом (`6 440`); в `TestRouteTableIsClassified` число admin-маршрутов 39 → 41.
|
||||
- Новые тесты: инварианты сравнения (числа индикаторов равны `summary`, разложение `delta`, сумма групп и матрицы), все группы и фильтры, тексты «Что изменилось», API (отчёт, списки, CSV, коды ошибок), дашборд (пара по умолчанию, предупреждения, прокси, страница одного запуска после выноса диалога).
|
||||
- Не проверено: страницы в браузере (диалог на `/analytics`, «Поменять местами», клики по матрице) и сравнение на данных стенда.
|
||||
|
||||
## Выкладка
|
||||
|
||||
Не выполнена. Нужны пересборка и перезапуск `control-api` и `admin-dashboard`; миграций нет.
|
||||
@@ -0,0 +1,498 @@
|
||||
<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Аналитика запусков</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap">
|
||||
<style>
|
||||
/* Layout: sidebar shell like the real dashboard; one scrolling page, summary first, detail below.
|
||||
Tokens are the dashboard's own (internal/dashboard/static/dashboard.css); heat ramp = one blue hue, validated sequential steps. */
|
||||
:root {
|
||||
--bg: #EEF1F5; --surface: #FFFFFF; --surface-alt: #E4E9F0;
|
||||
--border: #CBD3DE; --border-soft: #DCE2EA;
|
||||
--text: #12161F; --text-muted: #566073; --text-faint: #8992A3;
|
||||
--accent: #2F5FE0; --accent-strong: #1F45B0; --accent-soft: #E1E9FC;
|
||||
--success: #157F45; --success-soft: #DFF3E7;
|
||||
--warning: #9A6400; --warning-soft: #F8EDD4;
|
||||
--danger: #B4291D; --danger-soft: #FADEDA;
|
||||
--neutral: #5B6576; --neutral-soft: #E4E8EE;
|
||||
--bar: #3987e5; --bar-track: #E4E9F0;
|
||||
--h1:#cde2fb; --h2:#9ec5f4; --h3:#6da7ec; --h4:#3987e5; --h5:#256abf; --h6:#184f95; --h7:#0d366b;
|
||||
--hi1:#12161F; --hi2:#12161F; --hi3:#12161F; --hi4:#FFFFFF; --hi5:#FFFFFF; --hi6:#FFFFFF; --hi7:#FFFFFF;
|
||||
--radius: 4px; --shadow: 0 1px 2px rgba(12,18,30,.06), 0 1px 1px rgba(12,18,30,.05);
|
||||
--font-ui: "IBM Plex Sans", -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, Consolas, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg:#0A0D13; --surface:#10141C; --surface-alt:#161B25; --border:#262E3B; --border-soft:#1D2430;
|
||||
--text:#E7ECF5; --text-muted:#8D97AC; --text-faint:#626D82;
|
||||
--accent:#5C8CFF; --accent-strong:#82A6FF; --accent-soft:#182140;
|
||||
--success:#3DDC8A; --success-soft:#10261C; --warning:#F0B429; --warning-soft:#2B2210;
|
||||
--danger:#FF6B5E; --danger-soft:#2C1613; --neutral:#A3ACBE; --neutral-soft:#1B212B;
|
||||
--bar:#5C8CFF; --bar-track:#1D2430;
|
||||
--h1:#0d366b; --h2:#104281; --h3:#184f95; --h4:#256abf; --h5:#3987e5; --h6:#6da7ec; --h7:#9ec5f4;
|
||||
--hi1:#B8C9E8; --hi2:#C3D6F5; --hi3:#E7ECF5; --hi4:#FFFFFF; --hi5:#0A0D13; --hi6:#0A0D13; --hi7:#0A0D13;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4); color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg:#0A0D13; --surface:#10141C; --surface-alt:#161B25; --border:#262E3B; --border-soft:#1D2430;
|
||||
--text:#E7ECF5; --text-muted:#8D97AC; --text-faint:#626D82;
|
||||
--accent:#5C8CFF; --accent-strong:#82A6FF; --accent-soft:#182140;
|
||||
--success:#3DDC8A; --success-soft:#10261C; --warning:#F0B429; --warning-soft:#2B2210;
|
||||
--danger:#FF6B5E; --danger-soft:#2C1613; --neutral:#A3ACBE; --neutral-soft:#1B212B;
|
||||
--bar:#5C8CFF; --bar-track:#1D2430;
|
||||
--h1:#0d366b; --h2:#104281; --h3:#184f95; --h4:#256abf; --h5:#3987e5; --h6:#6da7ec; --h7:#9ec5f4;
|
||||
--hi1:#B8C9E8; --hi2:#C3D6F5; --hi3:#E7ECF5; --hi4:#FFFFFF; --hi5:#0A0D13; --hi6:#0A0D13; --hi7:#0A0D13;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4); color-scheme: dark;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { background: var(--bg); color: var(--text); font: 14px/1.5 var(--font-ui); }
|
||||
.shell { display: grid; grid-template-columns: 208px minmax(0,1fr); min-height: 100vh; }
|
||||
.side { background: var(--surface); border-right: 1px solid var(--border); padding: 16px 12px; display: flex; flex-direction: column; gap: 4px; position: sticky; top: 0; height: 100vh; }
|
||||
.brand { font: 700 13px var(--font-mono); letter-spacing: .02em; padding: 4px 8px 14px; }
|
||||
.nav a { display: block; padding: 7px 10px; border-radius: var(--radius); color: var(--text-muted); font: 500 12.5px var(--font-mono); text-decoration: none; }
|
||||
.nav a.on { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
.nav a:focus-visible, button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
main { min-width: 0; padding: 20px 24px 40px; display: grid; gap: 16px; align-content: start; }
|
||||
.mock { background: var(--warning-soft); color: var(--warning); border: 1px solid var(--warning); border-radius: var(--radius); padding: 8px 12px; font-size: 12.5px; }
|
||||
h1 { font: 700 18px var(--font-mono); margin: 0; }
|
||||
h2 { font: 700 12px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin: 0; }
|
||||
.sub { color: var(--text-muted); font-size: 12.5px; margin: 0; }
|
||||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 14px 16px; display: grid; gap: 12px; min-width: 0; align-content: start; }
|
||||
.head { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; justify-content: space-between; }
|
||||
.runbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.runbar label { font: 700 12px var(--font-mono); color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||
select, .btn { font: 500 13px var(--font-mono); background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); padding: 7px 10px; }
|
||||
select { min-width: 0; max-width: 100%; flex: 1 1 340px; }
|
||||
.btn { cursor: pointer; } .btn:hover { background: var(--surface-alt); }
|
||||
.btn[disabled] { opacity: .4; cursor: default; }
|
||||
.tabs { display: inline-flex; gap: 0; }
|
||||
.tabs button { font: 500 12px var(--font-mono); background: var(--surface); color: var(--text-muted); border: 1px solid var(--border); padding: 5px 12px; cursor: pointer; }
|
||||
.tabs button + button { border-left: 0; }
|
||||
.tabs button:first-child { border-radius: var(--radius) 0 0 var(--radius); } .tabs button:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
||||
.tabs button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-strong); border-color: var(--accent); }
|
||||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
||||
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; display: grid; gap: 2px; box-shadow: var(--shadow); }
|
||||
.kpi .k { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
|
||||
.kpi .v { font: 700 26px var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.kpi .d { font-size: 12px; color: var(--text-muted); }
|
||||
.tag { display: inline-flex; align-items: center; gap: 5px; font: 700 10.5px var(--font-mono); text-transform: uppercase; letter-spacing: .03em; padding: 2px 7px; border-radius: 2px; }
|
||||
.tag::before { content: ""; width: 5px; height: 5px; background: currentColor; border-radius: 1px; }
|
||||
.t-ok { background: var(--success-soft); color: var(--success); } .t-warn { background: var(--warning-soft); color: var(--warning); } .t-bad { background: var(--danger-soft); color: var(--danger); } .t-n { background: var(--neutral-soft); color: var(--neutral); }
|
||||
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 16px; }
|
||||
.rows { display: grid; gap: 7px; }
|
||||
.bar-row { display: grid; grid-template-columns: minmax(110px, 38%) minmax(0,1fr) auto; gap: 10px; align-items: center; font-size: 13px; }
|
||||
.bar-row .n { overflow-wrap: anywhere; }
|
||||
.track { height: 12px; background: var(--bar-track); border-radius: 2px; position: relative; }
|
||||
.fill { height: 100%; background: var(--bar); border-radius: 0 4px 4px 0; }
|
||||
.num { font: 500 12.5px var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
||||
.scroll { overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th .hint { cursor: help; color: var(--accent-strong); margin-left: 3px; }
|
||||
th { font: 700 11px var(--font-mono); text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
th.r, td.r { text-align: right; }
|
||||
td { padding: 6px 8px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
|
||||
td.a { font-family: var(--font-mono); font-size: 12.5px; white-space: nowrap; }
|
||||
td.a a { color: var(--accent-strong); text-decoration: none; } td.a a:hover { text-decoration: underline; }
|
||||
.pb { display: grid; grid-template-columns: 70px 40px; gap: 6px; align-items: center; justify-content: end; }
|
||||
.pb .track { height: 8px; }
|
||||
.heat td.c { padding: 2px; }
|
||||
.cell { display: block; min-width: 76px; text-align: center; font: 500 12px var(--font-mono); font-variant-numeric: tabular-nums; padding: 7px 4px; border-radius: 2px; cursor: default; }
|
||||
.h1{background:var(--h1);color:var(--hi1)} .h2{background:var(--h2);color:var(--hi2)} .h3{background:var(--h3);color:var(--hi3)} .h4{background:var(--h4);color:var(--hi4)} .h5{background:var(--h5);color:var(--hi5)} .h6{background:var(--h6);color:var(--hi6)} .h7{background:var(--h7);color:var(--hi7)}
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12px; color: var(--text-muted); }
|
||||
.legend i { display: inline-block; width: 28px; height: 10px; border-radius: 2px; }
|
||||
.vals { display: flex; align-items: flex-end; gap: 4px; height: 120px; border-bottom: 1px solid var(--border); position: relative; }
|
||||
.vals .col { flex: 1 1 0; min-width: 0; background: var(--bar); border-radius: 3px 3px 0 0; }
|
||||
.vals .avg { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--text-muted); }
|
||||
.vlab { display: flex; justify-content: space-between; font: 500 11px var(--font-mono); color: var(--text-faint); }
|
||||
.dq { display: grid; gap: 8px; }
|
||||
.dq .row { display: flex; gap: 10px; justify-content: space-between; align-items: baseline; border-bottom: 1px solid var(--border-soft); padding-bottom: 6px; font-size: 13px; }
|
||||
.dq .row span:last-child { font: 700 13px var(--font-mono); white-space: nowrap; }
|
||||
.note { font-size: 12px; color: var(--text-muted); }
|
||||
button.kpi { font: inherit; color: inherit; text-align: left; cursor: pointer; width: 100%; }
|
||||
button.kpi:hover { border-color: var(--accent); }
|
||||
button.kpi:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.kpi .go { font: 700 11px var(--font-mono); color: var(--accent-strong); }
|
||||
dialog { width: min(760px, calc(100% - 32px)); max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text); box-shadow: 0 16px 40px rgba(0,0,0,.35); }
|
||||
dialog[open] { display: flex; flex-direction: column; }
|
||||
dialog::backdrop { background: rgba(10,14,20,.5); }
|
||||
.dlg { display: flex; flex-direction: column; gap: 12px; padding: 16px; min-height: 0; flex: 1 1 auto; max-height: calc(100vh - 34px); max-height: calc(100dvh - 34px); }
|
||||
.dlg h3 { font: 700 15px var(--font-mono); margin: 0; overflow-wrap: anywhere; flex: 0 0 auto; }
|
||||
.dlg .note { flex: 0 0 auto; max-height: 6.5em; overflow: auto; margin: 0; }
|
||||
.dlg .dist { flex: 0 0 auto; }
|
||||
.dlg .list { flex: 0 1 auto; min-height: 96px; overflow: auto; border: 1px solid var(--border-soft); border-radius: var(--radius); }
|
||||
.dlg table { font-size: 12.5px; } .dlg th { position: sticky; top: 0; background: var(--surface); }
|
||||
.dlg .foot { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; padding-top: 4px; border-top: 1px solid var(--border-soft); }
|
||||
.dlg .foot .acts { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
@media (max-width: 520px) { .dlg { padding: 12px; gap: 10px; } .dlg .foot .acts { width: 100%; } .dlg .foot .acts .btn { flex: 1 1 auto; } }
|
||||
@media (max-height: 540px) { .dlg .dist { display: none; } }
|
||||
@media (max-height: 640px) { .dist .cols20 { height: 44px; } .dlg .note { max-height: 3em; } .dlg .dist .chips { display: none; } }
|
||||
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.btn.primary:hover { background: var(--accent-strong); }
|
||||
button.bar-row { font: inherit; color: inherit; text-align: left; width: 100%; background: none; border: 0; border-radius: var(--radius); padding: 3px 4px; margin: -3px -4px; cursor: pointer; }
|
||||
button.bar-row:hover { background: var(--surface-alt); }
|
||||
button.bar-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.dist { display: grid; gap: 8px; }
|
||||
.dist .chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.dist .cols20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; align-items: end; height: 84px; border-bottom: 1px solid var(--border); }
|
||||
.dist .cols20 div { background: var(--bar); border-radius: 2px 2px 0 0; min-height: 1px; }
|
||||
.dist .lab20 { display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); gap: 3px; font: 500 9.5px var(--font-mono); color: var(--text-faint); text-align: center; }
|
||||
#tip { position: fixed; z-index: 10; pointer-events: none; background: var(--text); color: var(--bg); font: 500 12px/1.4 var(--font-mono); padding: 6px 9px; border-radius: 4px; max-width: min(360px, calc(100vw - 24px)); white-space: normal; opacity: 0; transition: opacity .08s; }
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: minmax(0,1fr); }
|
||||
.side { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--border); padding: 10px 16px; }
|
||||
.brand { padding: 0 10px 0 0; } .nav { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
main { padding: 16px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { #tip { transition: none; } }
|
||||
</style>
|
||||
|
||||
</head><body style="margin:0">
|
||||
<div class="shell">
|
||||
<aside class="side">
|
||||
<div class="brand">CLOUD IP VALIDATOR</div>
|
||||
<nav class="nav" aria-label="Разделы">
|
||||
<a href="#" onclick="return false">Обзор</a>
|
||||
<a href="#" onclick="return false">Очередь</a>
|
||||
<a href="#" onclick="return false">Реестр</a>
|
||||
<a href="#" class="on" aria-current="page" onclick="return false">Аналитика</a>
|
||||
<a href="#" onclick="return false">Валидаторы</a>
|
||||
<a href="#" onclick="return false">Настройки</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="mock" role="note"><b>Макет.</b> Запуск от 02.10 построен на реальных числах из отчётов <code>analysis/</code> (6440 адресов). Два других запуска и идущий — <b>демонстрационные</b>, нужны только для проверки переключателя.</div>
|
||||
|
||||
<div class="head">
|
||||
<h1>Аналитика</h1>
|
||||
<p class="sub">Один выбранный запуск. Данные других запусков на странице не участвуют.</p>
|
||||
</div>
|
||||
|
||||
<section class="panel" aria-label="Выбор запуска">
|
||||
<div class="runbar">
|
||||
<label for="run">Запуск</label>
|
||||
<button class="btn" id="prev" aria-label="Предыдущий запуск">◀</button>
|
||||
<select id="run"></select>
|
||||
<button class="btn" id="next" aria-label="Следующий запуск">▶</button>
|
||||
</div>
|
||||
<p class="note" id="runnote"></p>
|
||||
</section>
|
||||
|
||||
<div class="kpis" id="kpis"></div>
|
||||
|
||||
<div class="cols">
|
||||
<section class="panel" aria-labelledby="h-reasons">
|
||||
<h2 id="h-reasons">Почему partial</h2>
|
||||
<div class="rows" id="reasons"></div>
|
||||
<p class="note">Адрес считается один раз, по главной причине. Всё, где есть egress, учитывается как egress.</p>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="h-dq">
|
||||
<h2 id="h-dq">Качество данных</h2>
|
||||
<div class="dq" id="dq"></div>
|
||||
<p class="note">Вердикт ставится при агрегации. Результаты, пришедшие позже, остаются в этом же запуске, но в вердикт не входят.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel" aria-labelledby="h-sub">
|
||||
<div class="head">
|
||||
<h2 id="h-sub">Подсети</h2>
|
||||
<div class="runbar">
|
||||
<div class="tabs" role="group" aria-label="Сортировка подсетей">
|
||||
<button type="button" data-sort="worst" aria-pressed="true">хуже всего</button>
|
||||
<button type="button" data-sort="size" aria-pressed="false">больше адресов</button>
|
||||
</div>
|
||||
<button class="btn" id="allsub" type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scroll"><table id="subtbl"></table></div>
|
||||
<p class="note">Строка ведёт в «Реестр» с фильтром по запуску и подсети.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="h-eg">
|
||||
<div class="head">
|
||||
<h2 id="h-eg">Egress по целям</h2>
|
||||
<div class="tabs" role="group" aria-label="Тип проверки">
|
||||
<button type="button" data-type="https" aria-pressed="true">https</button>
|
||||
<button type="button" data-type="icmp" aria-pressed="false">icmp</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rows" id="targets"></div>
|
||||
<div id="matrixwrap">
|
||||
<div class="head"><h2 style="margin-top:6px">Подсеть × цель</h2>
|
||||
<div class="legend" id="hlegend"></div></div>
|
||||
<div class="scroll"><table class="heat" id="matrix"></table></div>
|
||||
</div>
|
||||
<p class="note" id="matrixnote"></p>
|
||||
</section>
|
||||
|
||||
<div class="cols">
|
||||
<section class="panel" aria-labelledby="h-in">
|
||||
<h2 id="h-in">Ingress по площадкам</h2>
|
||||
<div class="scroll"><table id="sites"></table></div>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="h-err">
|
||||
<h2 id="h-err">Классы ошибок ingress</h2>
|
||||
<div class="rows" id="errs"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel" aria-labelledby="h-val">
|
||||
<h2 id="h-val">Валидаторы: доля провалов egress https</h2>
|
||||
<div class="vals" id="vals" role="img" aria-label="Доля проваленных https-проверок по 20 валидаторам"></div>
|
||||
<div class="vlab"><span>validator 1</span><span id="vavg"></span><span>validator 20</span></div>
|
||||
<p class="note">Ровная полоса значит: проблема зависит от подсети адреса, а не от валидатора.</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<dialog id="dlg" aria-labelledby="dlg-title">
|
||||
<div class="dlg">
|
||||
<h3 id="dlg-title"></h3>
|
||||
<p class="note" id="dlg-note"></p>
|
||||
<div class="dist" id="dlg-dist" hidden></div>
|
||||
<div class="list"><table id="dlg-tbl"></table></div>
|
||||
<div class="foot">
|
||||
<span class="note" id="dlg-msg" role="status"></span>
|
||||
<div class="acts">
|
||||
<button class="btn" type="button" id="dlg-copy">Копировать</button>
|
||||
<button class="btn primary" type="button" id="dlg-csv">Скачать CSV</button>
|
||||
<button class="btn" type="button" id="dlg-close" autofocus>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<div id="tip" role="tooltip"></div>
|
||||
|
||||
<script>
|
||||
const REAL = {"subnets": [{"c": "37.139.32.0/22", "n": 327, "p": 240, "e": 240, "i": 327}, {"c": "185.130.112.0/22", "n": 360, "p": 169, "e": 169, "i": 360}, {"c": "161.104.104.0/22", "n": 452, "p": 124, "e": 124, "i": 452}, {"c": "87.239.104.0/21", "n": 369, "p": 115, "e": 115, "i": 365}, {"c": "212.233.72.0/21", "n": 471, "p": 105, "e": 105, "i": 460}, {"c": "212.111.84.0/22", "n": 218, "p": 102, "e": 102, "i": 215}, {"c": "146.185.240.0/22", "n": 197, "p": 81, "e": 81, "i": 190}, {"c": "83.166.248.0/21", "n": 659, "p": 71, "e": 82, "i": 598}, {"c": "89.208.196.0/22", "n": 165, "p": 60, "e": 60, "i": 164}, {"c": "89.208.84.0/22", "n": 151, "p": 58, "e": 60, "i": 140}, {"c": "213.219.212.0/22", "n": 84, "p": 53, "e": 53, "i": 84}, {"c": "91.219.226.0/23", "n": 106, "p": 52, "e": 53, "i": 93}, {"c": "89.208.208.0/22", "n": 157, "p": 50, "e": 50, "i": 157}, {"c": "37.139.40.0/22", "n": 108, "p": 50, "e": 50, "i": 108}, {"c": "90.156.216.0/22", "n": 110, "p": 46, "e": 46, "i": 110}, {"c": "79.137.174.0/23", "n": 117, "p": 43, "e": 43, "i": 117}, {"c": "185.86.144.0/22", "n": 105, "p": 43, "e": 43, "i": 105}, {"c": "212.233.88.0/21", "n": 237, "p": 42, "e": 45, "i": 230}, {"c": "95.163.208.0/22", "n": 210, "p": 41, "e": 41, "i": 210}, {"c": "217.16.24.0/22", "n": 79, "p": 39, "e": 53, "i": 70}, {"c": "95.163.212.0/22", "n": 118, "p": 35, "e": 35, "i": 118}, {"c": "90.156.212.0/22", "n": 236, "p": 32, "e": 32, "i": 235}, {"c": "84.23.52.0/22", "n": 154, "p": 32, "e": 32, "i": 154}, {"c": "85.192.32.0/22", "n": 75, "p": 31, "e": 31, "i": 74}, {"c": "212.233.120.0/22", "n": 131, "p": 30, "e": 30, "i": 126}, {"c": "83.166.232.0/21", "n": 263, "p": 29, "e": 30, "i": 227}, {"c": "185.241.192.0/22", "n": 90, "p": 25, "e": 25, "i": 85}, {"c": "94.139.244.0/22", "n": 69, "p": 25, "e": 26, "i": 69}, {"c": "217.16.16.0/21", "n": 32, "p": 23, "e": 27, "i": 20}, {"c": "5.188.140.0/22", "n": 22, "p": 22, "e": 22, "i": 22}, {"c": "217.16.28.0/22", "n": 28, "p": 18, "e": 18, "i": 28}, {"c": "89.208.216.0/24", "n": 34, "p": 17, "e": 17, "i": 34}, {"c": "109.120.188.0/22", "n": 14, "p": 11, "e": 11, "i": 14}, {"c": "89.208.220.0/22", "n": 144, "p": 10, "e": 10, "i": 144}, {"c": "95.163.182.0/23", "n": 132, "p": 10, "e": 11, "i": 117}, {"c": "212.233.96.0/22", "n": 13, "p": 6, "e": 6, "i": 13}, {"c": "89.208.228.0/22", "n": 12, "p": 6, "e": 6, "i": 12}, {"c": "109.120.180.0/22", "n": 91, "p": 5, "e": 6, "i": 80}, {"c": "90.156.150.0/23", "n": 10, "p": 4, "e": 4, "i": 10}, {"c": "95.163.180.0/23", "n": 36, "p": 3, "e": 5, "i": 24}, {"c": "146.185.208.0/22", "n": 8, "p": 3, "e": 3, "i": 8}, {"c": "95.163.248.0/22", "n": 1, "p": 1, "e": 1, "i": 1}, {"c": "161.104.108.0/23", "n": 45, "p": 0, "e": 0, "i": 45}], "matrix": [{"c": "83.166.248.0/21", "partial": 588, "v": [81, 57, 51, 86, 46]}, {"c": "212.233.72.0/21", "partial": 366, "v": [82, 65, 57, 0, 57]}, {"c": "161.104.104.0/22", "partial": 328, "v": [88, 53, 48, 0, 40]}, {"c": "87.239.104.0/21", "partial": 254, "v": [91, 37, 34, 56, 30]}, {"c": "83.166.232.0/21", "partial": 234, "v": [78, 58, 46, 89, 44]}, {"c": "90.156.212.0/22", "partial": 204, "v": [91, 48, 50, 69, 42]}, {"c": "212.233.88.0/21", "partial": 195, "v": [81, 70, 56, 0, 67]}, {"c": "185.130.112.0/22", "partial": 191, "v": [97, 19, 20, 0, 15]}, {"c": "95.163.208.0/22", "partial": 169, "v": [85, 51, 43, 42, 38]}, {"c": "89.208.220.0/22", "partial": 134, "v": [87, 54, 50, 78, 38]}, {"c": "95.163.182.0/23", "partial": 122, "v": [74, 69, 48, 73, 45]}, {"c": "84.23.52.0/22", "partial": 122, "v": [88, 63, 48, 80, 41]}, {"c": "212.111.84.0/22", "partial": 116, "v": [95, 23, 15, 0, 16]}, {"c": "146.185.240.0/22", "partial": 116, "v": [87, 38, 30, 7, 24]}, {"c": "89.208.208.0/22", "partial": 107, "v": [88, 43, 33, 57, 28]}, {"c": "89.208.196.0/22", "partial": 105, "v": [92, 36, 31, 52, 29]}, {"c": "212.233.120.0/22", "partial": 101, "v": [83, 62, 54, 0, 71]}, {"c": "89.208.84.0/22", "partial": 93, "v": [83, 33, 27, 47, 31]}, {"c": "37.139.32.0/22", "partial": 87, "v": [86, 33, 30, 57, 26]}, {"c": "109.120.180.0/22", "partial": 86, "v": [90, 67, 59, 28, 48]}, {"c": "95.163.212.0/22", "partial": 83, "v": [86, 39, 29, 14, 24]}, {"c": "79.137.174.0/23", "partial": 74, "v": [85, 41, 39, 59, 28]}, {"c": "185.241.192.0/22", "partial": 65, "v": [91, 48, 29, 0, 22]}, {"c": "90.156.216.0/22", "partial": 64, "v": [89, 38, 31, 50, 25]}, {"c": "185.86.144.0/22", "partial": 62, "v": [94, 37, 32, 0, 18]}, {"c": "37.139.40.0/22", "partial": 58, "v": [84, 29, 33, 52, 24]}, {"c": "91.219.226.0/23", "partial": 54, "v": [96, 33, 15, 35, 28]}, {"c": "161.104.108.0/23", "partial": 45, "v": [87, 82, 73, 0, 49]}, {"c": "94.139.244.0/22", "partial": 44, "v": [82, 27, 36, 41, 20]}, {"c": "85.192.32.0/22", "partial": 44, "v": [91, 27, 20, 36, 20]}, {"c": "217.16.24.0/22", "partial": 40, "v": [65, 2, 0, 0, 2]}, {"c": "95.163.180.0/23", "partial": 33, "v": [76, 61, 42, 82, 52]}, {"c": "213.219.212.0/22", "partial": 31, "v": [87, 61, 35, 0, 61]}, {"c": "Line truncated
|
||||
const TARGETS = ['packages.ubuntu.com','dl-cdn.alpinelinux.org','repo.almalinux.org','hub.docker.com','github.com'];
|
||||
const fmt = n => Math.round(n).toLocaleString('ru-RU');
|
||||
const pct = (a, b) => b ? Math.round(a / b * 100) : 0;
|
||||
const pct1 = (a, b) => b ? (a / b * 100).toLocaleString('ru-RU', {maximumFractionDigits: 1}) : '0';
|
||||
|
||||
/* Base run: real numbers. Demo runs: the same shape, scaled and shifted with a fixed pattern. */
|
||||
function baseRun() {
|
||||
const subs = REAL.subnets.map(s => ({c: s.c, n: s.n, pass: s.p, eg: s.e, ing: s.i}));
|
||||
return {
|
||||
id: 'r3', demo: false, label: '02.10 13:47 → 22:29 · ручной · 6 440 адр. · 30% pass', kind: 'ручной (скан Floating IP)',
|
||||
start: '02.10.2026 13:47', end: '02.10.2026 22:29', dur: '8 ч 42 мин', rechecked: 9,
|
||||
subs, matrix: REAL.matrix.map(m => ({c: m.c, partial: m.partial, v: m.v.slice()})),
|
||||
reasons: [['Только egress', 4146], ['Ingress и egress', 157], ['Egress и неполный набор', 125], ['Ingress, egress и неполный набор', 9], ['Только неполный набор', 33], ['Только ingress', 8]],
|
||||
late: 246, lateAddr: 50, incomplete: 167, mismatch: 50,
|
||||
errLate: 844, egHttpsAny: 4409, egHttpsAll: 307, egHttpsAllFull: 290, ingSshAll: 7, ingSshAny: 222,
|
||||
targets: {https: [3841, 2193, 1872, 1805, 1728], icmp: [79, 0, 0, 1, 0]},
|
||||
sites: [['rxmsk', 6319, 6165, 6319, 6128, 6319, 6179], ['rxyc', 6439, 6435, 6439, 6429, 6439, 6434], ['misha-v', 6364, 6257, 6364, 6207, 6364, 6266], ['vk-kaz', 6426, 6400, 6426, 6392, 6426, 6402]],
|
||||
errs: [['SSH: таймаут', 327], ['ICMP: нет ответа', 276], ['TCP-22: таймаут', 260], ['SSH: баннер «Not allowed»', 47], ['SSH: нет маршрута', 18], ['ICMP: time exceeded', 15], ['TCP-22: нет маршрута', 7]],
|
||||
vals: [[1595,999],[1630,1071],[1618,1046],[1613,1042],[1597,998],[1622,1093],[1615,1045],[1599,1012],[1594,991],[1602,1049],[1610,1043],[1595,1014],[1630,1069],[1576,974],[1634,1073],[1589,1006],[1620,1062],[1613,1021],[1610,1047],[1614,1082]]
|
||||
};
|
||||
}
|
||||
function variant(b, id, label, kind, start, end, dur, scale, phase, shift) {
|
||||
const r = JSON.parse(JSON.stringify(b));
|
||||
Object.assign(r, {id, demo: true, label, kind, start, end, dur, rechecked: 0});
|
||||
r.subs = b.subs.map((s, i) => {
|
||||
const k = 1 + 0.22 * Math.sin(i * 1.7 + phase);
|
||||
const n = Math.max(1, Math.round(s.n * scale * (1 + 0.1 * Math.cos(i + phase))));
|
||||
const pass = Math.min(n, Math.max(0, Math.round(n * (s.pass / s.n) * k + n * shift)));
|
||||
return {c: s.c, n, pass, eg: Math.min(n, Math.round(n * (s.eg / s.n) * k + n * shift)), ing: Math.min(n, Math.round(n * (s.ing / s.n)))};
|
||||
});
|
||||
const total = r.subs.reduce((a, s) => a + s.n, 0);
|
||||
const f = total / 6440;
|
||||
r.matrix = b.matrix.map((m, i) => ({c: m.c, partial: Math.max(1, Math.round(m.partial * scale)), v: m.v.map((x, j) => Math.max(0, Math.min(100, Math.round(x * (1 + 0.2 * Math.sin(i + j + phase)) + shift * 100))))}));
|
||||
r.reasons = b.reasons.map(([k, v]) => [k, Math.round(v * f)]);
|
||||
r.late = Math.round(b.late * f); r.lateAddr = Math.round(b.lateAddr * f); r.incomplete = Math.round(b.incomplete * f); r.mismatch = Math.round(b.mismatch * f);
|
||||
r.errLate = Math.round(b.errLate * f); r.egHttpsAny = Math.round(b.egHttpsAny * f); r.egHttpsAll = Math.round(b.egHttpsAll * f); r.egHttpsAllFull = Math.round(b.egHttpsAllFull * f); r.ingSshAll = Math.round(b.ingSshAll * f); r.ingSshAny = Math.round(b.ingSshAny * f);
|
||||
r.targets = {https: b.targets.https.map(x => Math.round(x * f * (1 - shift * 2))), icmp: b.targets.icmp.map(x => Math.round(x * f))};
|
||||
r.sites = b.sites.map(s => s.map((x, j) => j === 0 ? x : Math.round(x * f)));
|
||||
r.errs = b.errs.map(([k, v]) => [k, Math.round(v * f)]);
|
||||
r.vals = b.vals.map(([t, o]) => [Math.round(t * f), Math.round(o * f * (1 + shift * 0.5))]);
|
||||
return r;
|
||||
}
|
||||
const BASE = baseRun();
|
||||
const RUNS = [
|
||||
variant(BASE, 'r1', '01.10 07:59 → 08:41 · авто · демо', 'автоцикл (демо)', '01.10.2026 07:59', '01.10.2026 08:41', '42 мин', 0.04, 0.6, 0.12),
|
||||
variant(BASE, 'r2', '03.10 10:05 → 12:20 · ручной · демо', 'перепроверка (демо)', '03.10.2026 10:05', '03.10.2026 12:20', '2 ч 15 мин', 0.16, 2.1, 0.06),
|
||||
BASE
|
||||
];
|
||||
const OPEN = {label: '03.10 15:30 → идёт · ручной · 120 из 800 · недоступен', disabled: true};
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const state = {run: RUNS[RUNS.length - 1], sort: 'worst', all: false, type: 'https'};
|
||||
|
||||
function sum(run) {
|
||||
const n = run.subs.reduce((a, s) => a + s.n, 0), pass = run.subs.reduce((a, s) => a + s.pass, 0);
|
||||
const eg = run.subs.reduce((a, s) => a + s.eg, 0), ing = run.subs.reduce((a, s) => a + s.ing, 0);
|
||||
return {n, pass, partial: n - pass, eg, ing};
|
||||
}
|
||||
function heatClass(v) { return 'h' + (v >= 90 ? 7 : v >= 75 ? 6 : v >= 60 ? 5 : v >= 45 ? 4 : v >= 30 ? 3 : v >= 15 ? 2 : 1); }
|
||||
function bar(v, max) { return `<div class="track"><div class="fill" style="width:${max ? Math.max(1, v / max * 100) : 0}%"></div></div>`; }
|
||||
function tipAttr(t) { return `data-tip="${t.replace(/"/g, '"')}"`; }
|
||||
|
||||
function render() {
|
||||
const r = state.run, s = sum(r);
|
||||
$('run').value = r.id;
|
||||
$('prev').disabled = RUNS.indexOf(r) === 0; $('next').disabled = RUNS.indexOf(r) === RUNS.length - 1;
|
||||
$('runnote').innerHTML = `Тип: ${r.kind}. Начало ${r.start}, завершён ${r.end}, длительность ${r.dur}.` + (r.rechecked ? ` Перепроверено внутри запуска: ${r.rechecked} адр. (берётся последний цикл).` : '') + (r.demo ? ' <b>Демонстрационные данные.</b>' : '');
|
||||
$('kpis').innerHTML = [
|
||||
['Адресов', fmt(s.n), 'последний цикл каждого адреса', ''],
|
||||
['pass', fmt(s.pass), `${pct(s.pass, s.n)}% адресов`, '<span class="tag t-ok">успех</span>'],
|
||||
['partial', fmt(s.partial), `${pct(s.partial, s.n)}% адресов`, '<span class="tag t-warn">частично</span>'],
|
||||
['fail', '0', 'ни одной полностью проваленной', '<span class="tag t-n">нет</span>'],
|
||||
['Egress OK', pct1(s.eg, s.n) + '%', 'все egress-проверки адреса успешны', ''],
|
||||
['Ingress OK', pct1(s.ing, s.n) + '%', 'все ingress-проверки адреса успешны', ''],
|
||||
['Egress https: есть провалы', fmt(r.egHttpsAny), `${pct1(r.egHttpsAny, s.n)}% адресов, хотя бы одна цель недоступна`, '<span class="tag t-warn">прикладной</span> <span class="go">список →</span>', 'egany'],
|
||||
['Ingress ssh: есть провалы', fmt(r.ingSshAny), `${pct1(r.ingSshAny, s.n)}% адресов, хотя бы с одной площадки`, '<span class="tag t-warn">прикладной</span> <span class="go">список →</span>', 'sshany'],
|
||||
['Egress https: все провалены', fmt(r.egHttpsAll), `${pct1(r.egHttpsAll, s.n)}% адресов, по всем 5 целям: ${fmt(r.egHttpsAllFull)}`, '<span class="tag t-bad">прикладной</span> <span class="go">список →</span>', 'eg'],
|
||||
['Ingress ssh: все провалены', fmt(r.ingSshAll), `${pct1(r.ingSshAll, s.n)}% адресов, ssh провален со всех площадок`, '<span class="tag t-bad">прикладной</span> <span class="go">список →</span>', 'ssh'],
|
||||
['Длительность', r.dur, `${fmt(s.n / Math.max(1, parseDur(r.dur)))} адр./мин`, ''],
|
||||
['Поздние результаты', fmt(r.late), `у ${fmt(r.lateAddr)} адресов, после вердикта`, '<span class="tag t-warn">внимание</span>']
|
||||
].map(([k, v, d, t, key]) => { const inner = `<div class="k">${k}</div><div class="v">${v}</div><div class="d">${d} ${t}</div>`; return key ? `<button type="button" class="kpi" data-list="${key}" aria-haspopup="dialog">${inner}</button>` : `<div class="kpi">${inner}</div>`; }).join('');
|
||||
|
||||
const rmax = Math.max(...r.reasons.map(x => x[1]));
|
||||
$('reasons').innerHTML = r.reasons.map(([k, v]) => `<div class="bar-row" ${tipAttr(k + ': ' + fmt(v) + ' адр. (' + pct(v, s.partial) + '% от partial)')}><span class="n">${k}</span>${bar(v, rmax)}<span class="num">${fmt(v)}</span></div>`).join('');
|
||||
$('dq').innerHTML = [
|
||||
['Поздние провалы у адресов с вердиктом pass', `${fmt(r.late)} / ${fmt(r.lateAddr)} адр.`],
|
||||
['Провалы ingress, пришедшие после вердикта', `${fmt(r.errLate)} из ${fmt(r.errs.reduce((a, e) => a + e[1], 0))}`],
|
||||
['Неполный набор проверок', `${fmt(r.incomplete)} адр.`],
|
||||
['Вердикт pass, но есть проваленные проверки', `${fmt(r.mismatch)} адр.`],
|
||||
['pass по вердикту → по фактическим проверкам', `${fmt(s.pass)} → ${fmt(s.pass - r.mismatch)}`]
|
||||
].map(([k, v]) => `<div class="row"><span>${k}</span><span>${v}</span></div>`).join('');
|
||||
|
||||
renderSubnets(r); renderTargets(r); renderSites(r); renderErrs(r); renderVals(r);
|
||||
}
|
||||
function parseDur(d) { const h = /(\d+) ч/.exec(d), m = /(\d+) мин/.exec(d); return (h ? +h[1] * 60 : 0) + (m ? +m[1] : 0) || 1; }
|
||||
|
||||
function pbar(a, n) { const p = pct(a, n); return `<div class="pb"><div class="track">${'<div class="fill" style="width:' + p + '%"></div>'}</div><span class="num">${p}%</span></div>`; }
|
||||
function renderSubnets(r) {
|
||||
let list = r.subs.slice();
|
||||
list.sort(state.sort === 'worst' ? (a, b) => a.pass / a.n - b.pass / b.n || b.n - a.n : (a, b) => b.n - a.n);
|
||||
const shown = state.all ? list : list.slice(0, 10);
|
||||
$('allsub').textContent = state.all ? 'свернуть до 10' : `показать все ${list.length}`;
|
||||
$('subtbl').innerHTML = `<thead><tr><th>Подсеть</th><th class="r">Адресов</th><th class="r">pass</th><th class="r">Egress OK</th><th class="r">Ingress OK</th></tr></thead><tbody>` +
|
||||
shown.map(s => `<tr><td class="a"><a href="#" onclick="return false" title="Открыть в реестре: запуск ${r.id}, подсеть ${s.c}">${s.c}</a></td><td class="num r">${fmt(s.n)}</td><td class="r" ${tipAttr(s.c + ': pass ' + fmt(s.pass) + ' из ' + fmt(s.n))}>${pbar(s.pass, s.n)}</td><td class="r" ${tipAttr(s.c + ': egress OK ' + fmt(s.eg) + ' из ' + fmt(s.n))}>${pbar(s.eg, s.n)}</td><td class="r" ${tipAttr(s.c + ': ingress OK ' + fmt(s.ing) + ' из ' + fmt(s.n))}>${pbar(s.ing, s.n)}</td></tr>`).join('') + '</tbody>';
|
||||
}
|
||||
function renderTargets(r) {
|
||||
const t = state.type, vals = r.targets[t], s = sum(r), max = Math.max(...vals, 1);
|
||||
$('targets').innerHTML = TARGETS.map((n, i) => `<div class="bar-row" ${tipAttr(n + ' · ' + t + ': провал у ' + fmt(vals[i]) + ' адр. (' + pct(vals[i], s.n) + '% всех)')}><span class="n">${n}</span>${bar(vals[i], max)}<span class="num">${fmt(vals[i])} · ${pct(vals[i], s.n)}%</span></div>`).join('');
|
||||
if (t === 'https') {
|
||||
$('matrixwrap').hidden = false;
|
||||
$('hlegend').innerHTML = ['0–14', '15–29', '30–44', '45–59', '60–74', '75–89', '90–100'].map((l, i) => `<span><i style="background:var(--h${i + 1})"></i> ${l}%</span>`).join('');
|
||||
const list = r.matrix.slice().sort((a, b) => b.partial - a.partial).slice(0, state.all ? 99 : 10);
|
||||
$('matrix').innerHTML = `<thead><tr><th>Подсеть</th><th class="r">partial</th>${TARGETS.map(x => `<th class="r">${x.replace('.com', '').replace('.org', '')}</th>`).join('')}</tr></thead><tbody>` +
|
||||
list.map(m => `<tr><td class="a"><a href="#" onclick="return false" title="Открыть адреса в реестре">${m.c}</a></td><td class="num r">${fmt(m.partial)}</td>${m.v.map((v, j) => `<td class="c"><span class="cell ${heatClass(v)}" ${tipAttr(m.c + ' → ' + TARGETS[j] + ': ' + v + '% адресов partial провалили https')}>${v}%</span></td>`).join('')}</tr>`).join('') + '</tbody>';
|
||||
$('matrixnote').textContent = 'Доля адресов partial подсети, проваливших https к цели. Строки — подсети с наибольшим числом partial; полный список включается кнопкой «показать все» выше.';
|
||||
} else {
|
||||
$('matrixwrap').hidden = true;
|
||||
$('matrixnote').textContent = 'Тип icmp: провалов почти нет, почти все относятся к packages.ubuntu.com. Матрица по подсетям для icmp в макете не показана, в рабочей странице она строится для любого типа.';
|
||||
}
|
||||
}
|
||||
function renderSites(r) {
|
||||
$('sites').innerHTML = `<thead><tr><th>Площадка</th><th class="r">icmp</th><th class="r">ssh</th><th class="r">tcp</th></tr></thead><tbody>` +
|
||||
r.sites.map(s => `<tr><td class="a">${s[0]}</td>${[1, 3, 5].map(i => `<td class="r"><span class="cell h${heatClass(100 - pct(s[i + 1], s[i]) * 8 > 100 ? 100 : Math.min(100, (100 - pct(s[i + 1], s[i])) * 8)).slice(1)}" ${tipAttr(s[0] + ' · ' + ['icmp', 'ssh', 'tcp'][(i - 1) / 2] + ': ' + fmt(s[i + 1]) + ' из ' + fmt(s[i]) + ' успешно')}>${fmt(s[i + 1])} / ${fmt(s[i])}</span></td>`).join('')}</tr>`).join('') + '</tbody>';
|
||||
}
|
||||
function renderErrs(r) {
|
||||
const max = Math.max(...r.errs.map(e => e[1]), 1);
|
||||
$('errs').innerHTML = r.errs.map(([k, v]) => `<button type="button" class="bar-row" data-cls="${k}" aria-haspopup="dialog" ${tipAttr(k + ': ' + fmt(v) + ' проверок. Нажмите, чтобы открыть список')}><span class="n">${k}</span>${bar(v, max)}<span class="num">${fmt(v)}</span></button>`).join('');
|
||||
}
|
||||
function renderVals(r) {
|
||||
const f = r.vals.map(([t, o]) => t ? (1 - o / t) : 0), avg = f.reduce((a, b) => a + b, 0) / f.length;
|
||||
$('vals').innerHTML = f.map((x, i) => `<div class="col" style="height:${x * 100}%" ${tipAttr('validator ' + (i + 1) + ': провал ' + Math.round(x * 100) + '% из ' + fmt(r.vals[i][0]) + ' https-проверок')}></div>`).join('') + `<div class="avg" style="bottom:${avg * 100}%"></div>`;
|
||||
$('vavg').textContent = `среднее ${Math.round(avg * 100)}% (шкала 0–100%)`;
|
||||
}
|
||||
|
||||
/* controls */
|
||||
const sel = $('run');
|
||||
sel.innerHTML = RUNS.slice().reverse().map(r => `<option value="${r.id}">${r.label}</option>`).join('') + `<option disabled>${OPEN.label}</option>`;
|
||||
sel.addEventListener('change', () => { state.run = RUNS.find(r => r.id === sel.value); render(); });
|
||||
$('prev').addEventListener('click', () => { const i = RUNS.indexOf(state.run); if (i > 0) { state.run = RUNS[i - 1]; render(); } });
|
||||
$('next').addEventListener('click', () => { const i = RUNS.indexOf(state.run); if (i < RUNS.length - 1) { state.run = RUNS[i + 1]; render(); } });
|
||||
document.querySelectorAll('[data-sort]').forEach(b => b.addEventListener('click', () => { state.sort = b.dataset.sort; document.querySelectorAll('[data-sort]').forEach(x => x.setAttribute('aria-pressed', x === b)); render(); }));
|
||||
document.querySelectorAll('[data-type]').forEach(b => b.addEventListener('click', () => { state.type = b.dataset.type; document.querySelectorAll('[data-type]').forEach(x => x.setAttribute('aria-pressed', x === b)); render(); }));
|
||||
$('allsub').addEventListener('click', () => { state.all = !state.all; render(); });
|
||||
|
||||
|
||||
/* modal: address lists (two indicators) and failed checks of one ingress error class */
|
||||
const LISTS = {
|
||||
egany: {title: 'Egress https: адреса с проваленными проверками', file: 'egress-https-any-failed', cols: ['Адрес', 'Подсеть', 'Валидатор', 'Провалено https', 'Проваленные цели'], hints: {2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок адреса провалено из всех записанных в цикле.'}, rows: () => REAL.egAnyList, n: r => r.egHttpsAny,
|
||||
note: 'Хотя бы одна egress-проверка https адреса провалена в последнем цикле запуска.'},
|
||||
sshany: {title: 'Ingress ssh: адреса с проваленными проверками', file: 'ingress-ssh-any-failed', cols: ['Адрес', 'Подсеть', 'Провалено ssh', 'Площадки с провалом', 'Ошибка'], hints: {2: 'Сколько площадок не смогли выполнить ssh-проверку адреса из всех, где она выполнялась.', 3: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 4: 'Класс ошибки: таймаут, баннер «Not allowed», нет маршрута.'}, rows: () => REAL.sshAnyList, n: r => r.ingSshAny,
|
||||
note: 'ssh провален хотя бы с одной площадки в последнем цикле запуска.'},
|
||||
eg: {title: 'Egress https: адреса, провалившие все проверки', file: 'egress-https-all-failed', cols: ['Адрес', 'Подсеть', 'Валидатор', 'https-проверок', 'Проваленные цели'], hints: {2: 'Валидатор, с которого шли egress-проверки адреса.', 3: 'Сколько https-проверок записано у адреса в цикле. Все они провалены. Меньше 5 значит, что часть результатов не пришла.'}, rows: () => REAL.egList, n: r => r.egHttpsAll,
|
||||
note: 'Все записанные egress-проверки https адреса провалены в последнем цикле запуска.'},
|
||||
ssh: {title: 'Ingress ssh: адреса, провалившие все проверки', file: 'ingress-ssh-all-failed', cols: ['Адрес', 'Подсеть', 'Площадки с провалом ssh', 'Ошибка'], hints: {2: 'Площадки пробера, с которых ssh-проверка адреса провалена.', 3: 'Класс ошибки ssh-проверки: таймаут, баннер «Not allowed», нет маршрута.'}, rows: () => REAL.sshList, n: r => r.ingSshAll,
|
||||
note: 'ssh провален со всех площадок, на которых он проверялся, в последнем цикле запуска.'}
|
||||
};
|
||||
const VERDICT_HINT = 'Итог всего адреса за цикл, который система выставила при агрегации. pass: все проверки (egress и ingress), полученные к этому моменту, успешны. partial: часть проверок провалена или результатов не хватает. fail: все проверки провалены. Это оценка адреса, а не этой проверки.';
|
||||
const STATUS_HINT = 'Состояние именно этой проверки (она всегда проваленная). «в вердикте»: результат пришёл до того, как система выставила вердикт адресу, и повлиял на него. «после вердикта»: результат пришёл позже, вердикт уже был выставлен и не пересчитывался. Поэтому у адреса с вердиктом pass может быть проваленная проверка.';
|
||||
const VERDICT_VAL = {pass: 'pass: к моменту вердикта все полученные проверки адреса были успешны. Эта проверка провалилась позже и в вердикт не вошла.', partial: 'partial: часть проверок адреса провалена или результатов не хватило, вердикт не pass.', fail: 'fail: все проверки адреса провалены.'};
|
||||
const STATUS_VAL = v => String(v).includes('после') ? 'Результат пришёл после вердикта адреса и в него не вошёл.' : 'Результат пришёл до вердикта и учтён в нём.';
|
||||
const ERRCOLS = ['Адрес', 'Подсеть', 'Площадка', 'Валидатор', 'Вердикт адреса', 'Статус проверки'];
|
||||
let cur = null;
|
||||
function showModal(m) {
|
||||
cur = m;
|
||||
$('dlg-title').textContent = `${m.title} · ${fmt(m.rows.length)}`;
|
||||
$('dlg-note').textContent = `Запуск: ${m.r.label}. ${m.note}` + (m.r.demo ? ' Демонстрационный запуск: список — первые строки реального запуска от 02.10.' : '');
|
||||
$('dlg-dist').hidden = !m.dist; $('dlg-dist').innerHTML = m.dist || '';
|
||||
const hint = (m.hints || {});
|
||||
$('dlg-tbl').innerHTML = `<thead><tr>${m.cols.map((c, i) => hint[i] ? `<th ${tipAttr(hint[i])} tabindex="0">${c}<span class="hint" aria-hidden="true">ⓘ</span></th>` : `<th>${c}</th>`).join('')}</tr></thead><tbody>` + m.rows.map(x => `<tr>${x.map((v, i) => { const h = m.cellHints && m.cellHints[i] ? m.cellHints[i](v) : ''; return `<td class="${i < 2 ? 'a' : ''}"${h ? ' ' + tipAttr(h) : ''}>${v}</td>`; }).join('')}</tr>`).join('') + '</tbody>';
|
||||
$('dlg-msg').textContent = '';
|
||||
const d = $('dlg'); if (d.showModal) d.showModal(); else d.setAttribute('open', '');
|
||||
}
|
||||
function openList(key) {
|
||||
const L = LISTS[key], r = state.run, all = L.rows(), n = Math.min(all.length, L.n(r));
|
||||
showModal({title: L.title, note: L.note, file: L.file, cols: L.cols, hints: L.hints, rows: r.demo ? all.slice(0, n) : all, r});
|
||||
}
|
||||
function openErr(cls) {
|
||||
const r = state.run, n = (r.errs.find(e => e[0] === cls) || [0, 0])[1];
|
||||
const all = REAL.errRows.filter(x => x[0] === cls), rows = (r.demo ? all.slice(0, n) : all).map(x => x.slice(1));
|
||||
const byVal = new Array(20).fill(0); rows.forEach(x => { byVal[+x[3].slice(1) - 1]++; });
|
||||
const max = Math.max(...byVal, 1), late = rows.filter(x => x[5] === 1).length, pass = rows.filter(x => x[4] === 'pass').length;
|
||||
const dist = `<h2>Распределение по валидаторам</h2>
|
||||
<div class="cols20" role="img" aria-label="Число проваленных проверок по валидаторам">${byVal.map((v, i) => `<div style="height:${Math.max(1, v / max * 100)}%" ${tipAttr('валидатор ' + (i + 1) + ': ' + v + ' проверок')}></div>`).join('')}</div>
|
||||
<div class="lab20">${byVal.map((v, i) => `<span>${i + 1}</span>`).join('')}</div>
|
||||
<div class="chips"><span class="tag t-n">всего ${fmt(rows.length)}</span><span class="tag t-ok">вердикт pass: ${fmt(pass)}</span><span class="tag t-warn">вердикт partial: ${fmt(rows.length - pass)}</span><span class="tag t-bad">после вердикта: ${fmt(late)}</span><span class="tag t-n">в вердикте: ${fmt(rows.length - late)}</span></div>`;
|
||||
const view = rows.map(x => [x[0], x[1], x[2], x[3], x[4], x[5] ? 'провал, после вердикта' : 'провал, в вердикте']);
|
||||
showModal({title: 'Ingress: ' + cls, note: 'Проваленные проверки этого класса в последнем цикле каждого адреса. Валидатор — тот, к которому был привязан адрес в этом цикле. «После вердикта» — результат пришёл позже агрегации и в вердикт адреса не вошёл.',
|
||||
file: 'ingress-errors-' + cls.toLowerCase().replace(/[^a-z0-9а-я]+/g, '-').replace(/^-|-$/g, ''), cols: ERRCOLS, rows: view, r, dist,
|
||||
hints: {3: 'Валидатор, к которому был привязан адрес в этом цикле (берётся из события привязки Floating IP).', 4: VERDICT_HINT, 5: STATUS_HINT},
|
||||
cellHints: {4: v => VERDICT_VAL[v] || '', 5: STATUS_VAL}});
|
||||
}
|
||||
function csvText(cols, rows) {
|
||||
const q = v => '"' + String(v).replace(/"/g, '""') + '"';
|
||||
return '\ufeff' + [cols, ...rows].map(row => row.map(q).join(',')).join('\r\n') + '\r\n';
|
||||
}
|
||||
async function saveCsv() {
|
||||
const name = `${cur.file}_${cur.r.id}.csv`, text = csvText(cur.cols, cur.rows), msg = $('dlg-msg');
|
||||
let dl = null;
|
||||
try { dl = window.claude && window.claude.use ? await window.claude.use('downloads') : null; } catch (e) { dl = null; }
|
||||
if (dl) {
|
||||
try { await dl.save({filename: name, data: text}); msg.textContent = 'Файл ' + name + ' сохранён.'; }
|
||||
catch (e) { msg.textContent = e && e.code === 'declined' ? 'Сохранение отменено.' : 'Сохранить не удалось. Используйте «Копировать».'; }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], {type: 'text/csv;charset=utf-8'})); a.download = name;
|
||||
document.body.appendChild(a); a.click(); a.remove(); msg.textContent = 'Файл ' + name + ' отправлен на скачивание.';
|
||||
} catch (e) { msg.textContent = 'Сохранить не удалось. Используйте «Копировать».'; }
|
||||
}
|
||||
async function copyCsv() {
|
||||
const msg = $('dlg-msg');
|
||||
try { await navigator.clipboard.writeText(csvText(cur.cols, cur.rows).replace('\ufeff', '')); msg.textContent = 'CSV скопирован в буфер обмена.'; }
|
||||
catch (e) { msg.textContent = 'Буфер обмена недоступен в этом окне.'; }
|
||||
}
|
||||
$('kpis').addEventListener('click', e => { const b = e.target.closest('[data-list]'); if (b) openList(b.dataset.list); });
|
||||
$('errs').addEventListener('click', e => { const b = e.target.closest('[data-cls]'); if (b) openErr(b.dataset.cls); });
|
||||
$('dlg-csv').addEventListener('click', saveCsv);
|
||||
$('dlg-copy').addEventListener('click', copyCsv);
|
||||
$('dlg-close').addEventListener('click', () => $('dlg').close());
|
||||
$('dlg').addEventListener('click', e => { if (e.target === $('dlg')) $('dlg').close(); });
|
||||
|
||||
/* tooltip (moved into the open dialog, otherwise the modal's top layer would cover it) */
|
||||
const tip = $('tip');
|
||||
function tipHost() { const d = $('dlg'); return d && d.open ? d : document.body; }
|
||||
document.addEventListener('mouseover', e => { const t = e.target.closest('[data-tip]'); if (t) { const h = tipHost(); if (tip.parentNode !== h) h.appendChild(tip); tip.textContent = t.dataset.tip; tip.style.opacity = 1; } });
|
||||
document.addEventListener('mousemove', e => { tip.style.left = Math.max(8, Math.min(e.clientX + 14, innerWidth - 376)) + 'px'; tip.style.top = (e.clientY + 16) + 'px'; });
|
||||
document.addEventListener('mouseout', e => { if (e.target.closest('[data-tip]')) tip.style.opacity = 0; });
|
||||
render();
|
||||
</script>
|
||||
</body></html>
|
||||
+165
-15
@@ -10,13 +10,16 @@ package agentcore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/apiclient"
|
||||
@@ -31,6 +34,10 @@ type Agent struct {
|
||||
|
||||
lastHandledIPID int64
|
||||
|
||||
// busy is true while an assignment is being worked on; it is reported in
|
||||
// the heartbeat body (informational on the control-api side).
|
||||
busy atomic.Bool
|
||||
|
||||
// registerRetryInitial/Max govern the backoff used while waiting for a
|
||||
// successful registration (see registerWithRetry): control-api may not
|
||||
// be up yet at agent boot, or may come and go across a redeploy, and the
|
||||
@@ -53,6 +60,13 @@ func New(cfg *config.ValidatorAgent, log *slog.Logger) *Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// WithToken sets the bearer token sent to the Control API (and only to it:
|
||||
// the IP-echo lookup and all check traffic use separate clients).
|
||||
func (a *Agent) WithToken(token string) *Agent {
|
||||
a.client.Token = token
|
||||
return a
|
||||
}
|
||||
|
||||
// Run registers with the Control API and polls forever until ctx is
|
||||
// cancelled.
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
@@ -61,6 +75,15 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
interval := time.Duration(a.cfg.PollIntervalSeconds) * time.Second
|
||||
|
||||
// Heartbeats run on their own schedule. Sent from the poll loop they
|
||||
// stopped for as long as a slow assignment took (an address whose
|
||||
// outbound targets all time out keeps the loop busy for ~40 s), which
|
||||
// control-api reads as a lost validator after heartbeat_timeout_seconds.
|
||||
hbCtx, stopHeartbeat := context.WithCancel(ctx)
|
||||
defer stopHeartbeat()
|
||||
go a.heartbeatLoop(hbCtx, interval)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -139,12 +162,32 @@ type checkConfigDTO struct {
|
||||
Targets []string `json:"targets"`
|
||||
}
|
||||
|
||||
func (a *Agent) pollOnce(ctx context.Context) {
|
||||
if _, err := a.client.Do(ctx, "POST", "/api/v1/agents/"+a.cfg.ValidatorID+"/heartbeat", heartbeatReq{LocalState: "idle"}, nil); err != nil {
|
||||
a.log.Error("heartbeat", "err", err)
|
||||
return
|
||||
// heartbeatLoop sends a heartbeat now and then every interval until ctx is
|
||||
// cancelled.
|
||||
func (a *Agent) heartbeatLoop(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
a.sendHeartbeat(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) sendHeartbeat(ctx context.Context) {
|
||||
state := "idle"
|
||||
if a.busy.Load() {
|
||||
state = "checking"
|
||||
}
|
||||
if _, err := a.client.Do(ctx, "POST", "/api/v1/agents/"+a.cfg.ValidatorID+"/heartbeat", heartbeatReq{LocalState: state}, nil); err != nil && ctx.Err() == nil {
|
||||
a.log.Error("heartbeat", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) pollOnce(ctx context.Context) {
|
||||
var assignment assignmentResp
|
||||
ok, err := a.client.Do(ctx, "GET", "/api/v1/agents/"+a.cfg.ValidatorID+"/assignment", nil, &assignment)
|
||||
if err != nil {
|
||||
@@ -160,6 +203,8 @@ func (a *Agent) pollOnce(ctx context.Context) {
|
||||
return // already handled this IP's work this attempt
|
||||
}
|
||||
|
||||
a.busy.Store(true)
|
||||
defer a.busy.Store(false)
|
||||
switch assignment.Phase {
|
||||
case "awaiting_self_check":
|
||||
a.handleSelfCheckAndRun(ctx, assignment)
|
||||
@@ -174,18 +219,13 @@ func (a *Agent) pollOnce(ctx context.Context) {
|
||||
func (a *Agent) handleSelfCheckAndRun(ctx context.Context, assignment assignmentResp) {
|
||||
a.postEvent(ctx, assignment.IPID, "config_received", "")
|
||||
|
||||
timeout := time.Duration(a.cfg.SelfCheck.TimeoutSeconds) * time.Second
|
||||
// Each method gets the full timeout (see runSelfCheckMethods), so the
|
||||
// overall budget scales with the number of methods.
|
||||
timeout := time.Duration(a.cfg.SelfCheck.TimeoutSeconds) * time.Second * time.Duration(len(a.selfCheckMethods()))
|
||||
selfCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
detectedIP, err := a.detectPublicIP(selfCtx)
|
||||
success := err == nil && detectedIP == assignment.IPAddress
|
||||
detail := "matched"
|
||||
if err != nil {
|
||||
detail = "ip echo request failed: " + err.Error()
|
||||
} else if !success {
|
||||
detail = fmt.Sprintf("egress ip %q does not match assigned fip %q", detectedIP, assignment.IPAddress)
|
||||
}
|
||||
detectedIP, _, detail, success := a.runSelfCheckMethods(selfCtx, assignment.IPAddress)
|
||||
|
||||
a.postSelfCheck(ctx, assignment.IPID, detectedIP, success, detail)
|
||||
a.postEvent(ctx, assignment.IPID, "self_check_result", fmt.Sprintf(`{"success":%t}`, success))
|
||||
@@ -197,7 +237,117 @@ func (a *Agent) handleSelfCheckAndRun(ctx context.Context, assignment assignment
|
||||
a.runChecks(ctx, assignment)
|
||||
}
|
||||
|
||||
// detectPublicIP asks each configured IP-echo URL, in order, for the
|
||||
// selfCheckMethods returns the configured methods in priority order. Configs
|
||||
// built without the loader (tests) may leave the list empty; that means the
|
||||
// historical behaviour, ip_echo only.
|
||||
func (a *Agent) selfCheckMethods() []string {
|
||||
if len(a.cfg.SelfCheck.Methods) == 0 {
|
||||
return []string{config.SelfCheckIPEcho}
|
||||
}
|
||||
return a.cfg.SelfCheck.Methods
|
||||
}
|
||||
|
||||
// runSelfCheckMethods tries the configured methods in priority order and
|
||||
// stops at the first one that confirms assignedIP. A method that gives no
|
||||
// answer and one that reports a different address are treated alike: the
|
||||
// next method is tried, since either may be a limitation of that method
|
||||
// (e.g. control-api reached over the internal network sees a private
|
||||
// address) rather than proof the floating IP is not attached. The check
|
||||
// fails only when no method confirms, and detail then carries the reason
|
||||
// from every method. detectedIP is the matching address on success, else the
|
||||
// last address any method reported (may be empty).
|
||||
//
|
||||
// Every method runs under its own self_check.timeout_seconds: with one shared
|
||||
// deadline a hung first method (priority control_api) would use it all up and
|
||||
// the fallback would never get a chance to answer.
|
||||
func (a *Agent) runSelfCheckMethods(ctx context.Context, assignedIP string) (detectedIP, method, detail string, ok bool) {
|
||||
var reasons []string
|
||||
perMethod := time.Duration(a.cfg.SelfCheck.TimeoutSeconds) * time.Second
|
||||
for _, m := range a.selfCheckMethods() {
|
||||
mctx, cancel := ctx, context.CancelFunc(func() {})
|
||||
if perMethod > 0 {
|
||||
mctx, cancel = context.WithTimeout(ctx, perMethod)
|
||||
}
|
||||
var ip string
|
||||
var err error
|
||||
switch m {
|
||||
case config.SelfCheckControlAPI:
|
||||
ip, err = a.detectViaControlAPI(mctx)
|
||||
case config.SelfCheckIPEcho:
|
||||
ip, err = a.detectViaIPEcho(mctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("ip echo request failed: %w", err)
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unknown self-check method")
|
||||
}
|
||||
cancel()
|
||||
if err != nil {
|
||||
reasons = append(reasons, m+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if ip == assignedIP {
|
||||
return ip, m, fmt.Sprintf("matched (%s)", m), true
|
||||
}
|
||||
detectedIP = ip
|
||||
reason := fmt.Sprintf("%s: egress ip %q does not match assigned fip %q", m, ip, assignedIP)
|
||||
if m == config.SelfCheckControlAPI && isLocalAddr(ip) {
|
||||
reason += " (control-api sees a private address; it is reachable over the internal network, self-check via control_api is not possible, use ip_echo)"
|
||||
}
|
||||
reasons = append(reasons, reason)
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
reasons = append(reasons, "no self-check methods configured")
|
||||
}
|
||||
return detectedIP, "", strings.Join(reasons, "; "), false
|
||||
}
|
||||
|
||||
// isLocalAddr reports whether ip is a private, loopback or link-local
|
||||
// address, i.e. one that can never be a floating IP.
|
||||
func isLocalAddr(ip string) bool {
|
||||
parsed := net.ParseIP(ip)
|
||||
return parsed != nil && (parsed.IsPrivate() || parsed.IsLoopback() || parsed.IsLinkLocalUnicast())
|
||||
}
|
||||
|
||||
// detectViaControlAPI asks control-api which source address it sees for this
|
||||
// validator. It is only meaningful when control-api is reached over the
|
||||
// external network, where the floating IP is the visible source (see
|
||||
// config.SelfCheckCfg).
|
||||
//
|
||||
// Every call dials a brand-new TCP connection through a dedicated transport
|
||||
// with keep-alives off: a connection opened before the floating IP was
|
||||
// attached (heartbeat, assignment polling) keeps its old NAT state and would
|
||||
// keep reporting the previous address, so the shared apiclient must not be
|
||||
// used. The endpoint is open, so no agent token is sent.
|
||||
func (a *Agent) detectViaControlAPI(ctx context.Context) (string, error) {
|
||||
url := strings.TrimRight(a.cfg.ControlAPIURL, "/") + "/api/v1/agents/" + neturl.PathEscape(a.cfg.ValidatorID) + "/observed-ip"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
transport := &http.Transport{DisableKeepAlives: true}
|
||||
defer transport.CloseIdleConnections()
|
||||
resp, err := (&http.Client{Transport: transport}).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return "", fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&body); err != nil {
|
||||
return "", fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if net.ParseIP(body.IP) == nil {
|
||||
return "", fmt.Errorf("response is not a valid IP: %q", body.IP)
|
||||
}
|
||||
return body.IP, nil
|
||||
}
|
||||
|
||||
// detectViaIPEcho asks each configured IP-echo URL, in order, for the
|
||||
// address this validator is currently seen egressing from, returning the
|
||||
// first one that answers with a parseable IP. These must be resources
|
||||
// genuinely outside the cloud project (see config.SelfCheckCfg) — OpenStack
|
||||
@@ -205,7 +355,7 @@ func (a *Agent) handleSelfCheckAndRun(ctx context.Context, assignment assignment
|
||||
// network, so anything reachable over the project's internal network would
|
||||
// report the validator's private address instead, regardless of whether
|
||||
// the floating IP is correctly attached.
|
||||
func (a *Agent) detectPublicIP(ctx context.Context) (string, error) {
|
||||
func (a *Agent) detectViaIPEcho(ctx context.Context) (string, error) {
|
||||
var lastErr error
|
||||
for _, url := range a.cfg.SelfCheck.IPEchoURLs {
|
||||
ip, err := fetchIPEcho(ctx, url)
|
||||
|
||||
@@ -98,3 +98,34 @@ func TestRegisterWithRetryStopsOnCancel(t *testing.T) {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTokenGoesOnlyToControlAPI: the bearer token authenticates calls to
|
||||
// control-api, and must never leak to the external IP-echo service.
|
||||
func TestTokenGoesOnlyToControlAPI(t *testing.T) {
|
||||
var apiAuth, echoAuth atomic.Value
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
apiAuth.Store(r.Header.Get("Authorization"))
|
||||
w.Write([]byte(`{"ok":true,"poll_interval_seconds":5}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
echo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
echoAuth.Store(r.Header.Get("Authorization"))
|
||||
w.Write([]byte("203.0.113.7"))
|
||||
}))
|
||||
defer echo.Close()
|
||||
|
||||
a := New(&config.ValidatorAgent{ValidatorID: "val-1", ControlAPIURL: api.URL}, testLogger()).WithToken("agent-secret")
|
||||
ctx := context.Background()
|
||||
if err := a.registerWithRetry(ctx); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if _, err := fetchIPEcho(ctx, echo.URL); err != nil {
|
||||
t.Fatalf("fetchIPEcho: %v", err)
|
||||
}
|
||||
if got, _ := apiAuth.Load().(string); got != "Bearer agent-secret" {
|
||||
t.Fatalf("control-api Authorization = %q, want bearer token", got)
|
||||
}
|
||||
if got, _ := echoAuth.Load().(string); got != "" {
|
||||
t.Fatalf("IP-echo request carried Authorization %q, want none", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package agentcore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/config"
|
||||
)
|
||||
|
||||
// While the agent is busy with a slow assignment (an address whose outbound
|
||||
// targets time out keeps it occupied for tens of seconds) it must keep sending
|
||||
// heartbeats; control-api marks a validator that stays silent for
|
||||
// heartbeat_timeout_seconds as unreachable.
|
||||
func TestHeartbeatContinuesDuringSlowChecks(t *testing.T) {
|
||||
slowTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(3500 * time.Millisecond)
|
||||
}))
|
||||
defer slowTarget.Close()
|
||||
|
||||
var heartbeats, assignments int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/agents/register", func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"ok":true}`)
|
||||
})
|
||||
mux.HandleFunc("POST /api/v1/agents/val-1/heartbeat", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&heartbeats, 1)
|
||||
fmt.Fprint(w, `{"ok":true}`)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/agents/val-1/assignment", func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&assignments, 1) > 1 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `{"ip_id":1,"ip_address":"1.1.1.1","phase":"checking","check_config":[{"type":"https","targets":[%q]}]}`, slowTarget.URL)
|
||||
})
|
||||
mux.HandleFunc("POST /api/v1/agents/val-1/results", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"ok":true}`) })
|
||||
mux.HandleFunc("POST /api/v1/agents/val-1/complete", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"ok":true}`) })
|
||||
capi := httptest.NewServer(mux)
|
||||
defer capi.Close()
|
||||
|
||||
a := New(&config.ValidatorAgent{
|
||||
ValidatorID: "val-1", ControlAPIURL: capi.URL, PollIntervalSeconds: 1,
|
||||
Checks: config.AgentChecks{HTTPSTimeoutSeconds: 10, ICMPTimeoutSeconds: 1, ICMPCount: 1},
|
||||
}, testLogger())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { _ = a.Run(ctx); close(done) }()
|
||||
|
||||
time.Sleep(3 * time.Second) // the slow check (3.5 s) is still running
|
||||
during := atomic.LoadInt32(&heartbeats)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Run did not stop after the context was cancelled")
|
||||
}
|
||||
|
||||
// One per second plus the first: 3-4 in 3 s. With heartbeats in the poll
|
||||
// loop there is exactly one, sent before the slow assignment started.
|
||||
if during < 3 {
|
||||
t.Fatalf("%d heartbeats in 3 s while a check was running, want at least 3", during)
|
||||
}
|
||||
if atomic.LoadInt32(&assignments) < 1 {
|
||||
t.Fatal("the assignment was never fetched, the test did not exercise a busy agent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package agentcore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/config"
|
||||
)
|
||||
|
||||
// echoServer answers every request with body (an IP-echo stand-in).
|
||||
func echoServer(t *testing.T, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
// controlAPIServer plays control-api's observed-ip route: it answers with ip
|
||||
// (status 200) or with the given error status when ip is empty.
|
||||
func controlAPIServer(t *testing.T, ip string, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/agents/val-1/observed-ip" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if ip == "" {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `{"ip":%q,"source":"remote_addr"}`, ip)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func selfCheckAgent(controlAPIURL string, methods []string, echoURLs ...string) *Agent {
|
||||
return &Agent{
|
||||
cfg: &config.ValidatorAgent{
|
||||
ValidatorID: "val-1",
|
||||
ControlAPIURL: controlAPIURL,
|
||||
SelfCheck: config.SelfCheckCfg{TimeoutSeconds: 2, Methods: methods, IPEchoURLs: echoURLs},
|
||||
},
|
||||
log: testLogger(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfCheckControlAPIFirstWins(t *testing.T) {
|
||||
capi := controlAPIServer(t, "1.2.3.4", 0)
|
||||
var echoCalls int32
|
||||
echo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&echoCalls, 1)
|
||||
fmt.Fprint(w, "1.2.3.4")
|
||||
}))
|
||||
defer echo.Close()
|
||||
|
||||
a := selfCheckAgent(capi.URL, []string{config.SelfCheckControlAPI, config.SelfCheckIPEcho}, echo.URL)
|
||||
ip, method, detail, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
|
||||
if !ok || ip != "1.2.3.4" || method != config.SelfCheckControlAPI || detail != "matched (control_api)" {
|
||||
t.Fatalf("got ip=%q method=%q detail=%q ok=%v", ip, method, detail, ok)
|
||||
}
|
||||
if n := atomic.LoadInt32(&echoCalls); n != 0 {
|
||||
t.Fatalf("ip_echo was called %d times although control_api already confirmed", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Any one confirming method is enough: control-api answers with a different
|
||||
// address, ip_echo confirms.
|
||||
func TestSelfCheckFallsThroughOnMismatch(t *testing.T) {
|
||||
capi := controlAPIServer(t, "5.5.5.5", 0)
|
||||
echo := echoServer(t, "1.2.3.4")
|
||||
|
||||
a := selfCheckAgent(capi.URL, []string{config.SelfCheckControlAPI, config.SelfCheckIPEcho}, echo.URL)
|
||||
ip, method, _, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
|
||||
if !ok || ip != "1.2.3.4" || method != config.SelfCheckIPEcho {
|
||||
t.Fatalf("got ip=%q method=%q ok=%v, want a pass via ip_echo", ip, method, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// An old control-api without the route (404) or a failing one (5xx) must not
|
||||
// stop the self-check: the next method decides.
|
||||
func TestSelfCheckFallsThroughOnControlAPIError(t *testing.T) {
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
|
||||
capi := controlAPIServer(t, "", status)
|
||||
echo := echoServer(t, "1.2.3.4")
|
||||
a := selfCheckAgent(capi.URL, []string{config.SelfCheckControlAPI, config.SelfCheckIPEcho}, echo.URL)
|
||||
_, method, _, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
|
||||
if !ok || method != config.SelfCheckIPEcho {
|
||||
t.Fatalf("status %d: method=%q ok=%v, want a pass via ip_echo", status, method, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfCheckFailsWhenNoMethodConfirms(t *testing.T) {
|
||||
capi := controlAPIServer(t, "10.0.0.5", 0) // private address: internal-network case
|
||||
echo := echoServer(t, "6.6.6.6")
|
||||
|
||||
a := selfCheckAgent(capi.URL, []string{config.SelfCheckControlAPI, config.SelfCheckIPEcho}, echo.URL)
|
||||
ip, method, detail, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
|
||||
if ok || method != "" {
|
||||
t.Fatalf("expected a failure, got ok=%v method=%q", ok, method)
|
||||
}
|
||||
if ip != "6.6.6.6" {
|
||||
t.Fatalf("detected ip = %q, want the last reported address 6.6.6.6", ip)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`control_api: egress ip "10.0.0.5" does not match assigned fip "1.2.3.4"`,
|
||||
"private address", // the hint for the internal-network case
|
||||
`ip_echo: egress ip "6.6.6.6" does not match`,
|
||||
} {
|
||||
if !strings.Contains(detail, want) {
|
||||
t.Fatalf("detail %q does not contain %q", detail, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfCheckPrivateHintOnlyForControlAPI(t *testing.T) {
|
||||
echo := echoServer(t, "10.1.1.1")
|
||||
a := selfCheckAgent("http://unused", []string{config.SelfCheckIPEcho}, echo.URL)
|
||||
_, _, detail, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
|
||||
if ok || strings.Contains(detail, "private address") {
|
||||
t.Fatalf("ok=%v detail=%q: the control_api hint must not appear for ip_echo", ok, detail)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing configured (a config built without the loader) the agent
|
||||
// behaves as before: ip_echo only, control-api is never asked.
|
||||
func TestSelfCheckDefaultsToIPEcho(t *testing.T) {
|
||||
var capiCalls int32
|
||||
capi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&capiCalls, 1)
|
||||
}))
|
||||
defer capi.Close()
|
||||
echo := echoServer(t, "1.2.3.4")
|
||||
|
||||
a := selfCheckAgent(capi.URL, nil, echo.URL)
|
||||
_, method, _, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
|
||||
if !ok || method != config.SelfCheckIPEcho || atomic.LoadInt32(&capiCalls) != 0 {
|
||||
t.Fatalf("method=%q ok=%v control-api calls=%d", method, ok, atomic.LoadInt32(&capiCalls))
|
||||
}
|
||||
}
|
||||
|
||||
// A hung control-api must not use up the time of the fallback: each method
|
||||
// has its own timeout.
|
||||
func TestSelfCheckHungControlAPIDoesNotStarveFallback(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
capi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release
|
||||
}))
|
||||
defer capi.Close()
|
||||
defer close(release)
|
||||
echo := echoServer(t, "1.2.3.4")
|
||||
|
||||
a := selfCheckAgent(capi.URL, []string{config.SelfCheckControlAPI, config.SelfCheckIPEcho}, echo.URL)
|
||||
a.cfg.SelfCheck.TimeoutSeconds = 1
|
||||
|
||||
start := time.Now()
|
||||
// The outer context mirrors handleSelfCheckAndRun: timeout x methods.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_, method, detail, ok := a.runSelfCheckMethods(ctx, "1.2.3.4")
|
||||
if !ok || method != config.SelfCheckIPEcho {
|
||||
t.Fatalf("method=%q ok=%v detail=%q, want a pass via ip_echo after control_api timed out", method, ok, detail)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 1900*time.Millisecond {
|
||||
t.Fatalf("took %s: the hung method consumed the fallback's time", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Every control-api request must use a new TCP connection: a connection
|
||||
// opened before the floating IP was attached would report the old address.
|
||||
func TestDetectViaControlAPIDialsNewConnectionEachTime(t *testing.T) {
|
||||
var conns int32
|
||||
ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"ip":"1.2.3.4","source":"remote_addr"}`)
|
||||
}))
|
||||
ts.Config.ConnState = func(_ net.Conn, s http.ConnState) {
|
||||
if s == http.StateNew {
|
||||
atomic.AddInt32(&conns, 1)
|
||||
}
|
||||
}
|
||||
ts.Start()
|
||||
defer ts.Close()
|
||||
|
||||
a := selfCheckAgent(ts.URL, nil)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := a.detectViaControlAPI(context.Background()); err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if n := atomic.LoadInt32(&conns); n != 3 {
|
||||
t.Fatalf("3 calls opened %d connections, want 3 (no keep-alive reuse)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectViaControlAPIRejectsBadAnswers(t *testing.T) {
|
||||
for name, body := range map[string]string{"not json": "oops", "not an ip": `{"ip":"abc"}`, "empty": `{}`} {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) }))
|
||||
a := selfCheckAgent(ts.URL, nil)
|
||||
if ip, err := a.detectViaControlAPI(context.Background()); err == nil {
|
||||
t.Fatalf("%s: expected an error, got %q", name, ip)
|
||||
}
|
||||
ts.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// The agent token must never be sent on this request (the route is open and
|
||||
// the token is meant for control-api writes only).
|
||||
func TestDetectViaControlAPISendsNoToken(t *testing.T) {
|
||||
var auth string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth = r.Header.Get("Authorization")
|
||||
fmt.Fprint(w, `{"ip":"1.2.3.4"}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
a := selfCheckAgent(ts.URL, nil)
|
||||
if _, err := a.detectViaControlAPI(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auth != "" {
|
||||
t.Fatalf("Authorization header sent: %q", auth)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
// Package analytics turns the stored checks of one finished run into the
|
||||
// numbers behind the dashboard's analytics page. It works on facts: every
|
||||
// check stored for the latest cycle of each address in the run, whenever it
|
||||
// arrived. The verdict is shown next to those facts, never mixed into them.
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// Input is everything Compute needs, already read from the database.
|
||||
type Input struct {
|
||||
Run db.CheckRun
|
||||
Results []db.RunResult
|
||||
Subnets []db.Subnet
|
||||
SiteNames map[int]string // site index -> site id
|
||||
Rechecked int // addresses with more than one cycle in the run
|
||||
// Each feeds every check of the run's result cycles to fn.
|
||||
Each func(fn func(db.RunCheck)) error
|
||||
}
|
||||
|
||||
// Report is the data of the analytics page for one run.
|
||||
type Report struct {
|
||||
Run RunInfo `json:"run"`
|
||||
Summary Summary `json:"summary"`
|
||||
Reasons []Reason `json:"reasons"`
|
||||
Quality Quality `json:"quality"`
|
||||
Subnets []SubnetRow `json:"subnets"`
|
||||
Targets TargetsBlock `json:"targets"`
|
||||
Matrix map[string][]MatrixRow `json:"matrix"`
|
||||
Sites SitesBlock `json:"sites"`
|
||||
Errors []ErrorClass `json:"errors"`
|
||||
Validators []ValidatorRow `json:"validators"`
|
||||
}
|
||||
|
||||
type RunInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinalizedAt *time.Time `json:"finalized_at"`
|
||||
DurationSec int `json:"duration_seconds"`
|
||||
Rechecked int `json:"rechecked"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
Addresses int `json:"addresses"`
|
||||
Pass int `json:"pass"`
|
||||
Partial int `json:"partial"`
|
||||
Fail int `json:"fail"`
|
||||
Cancelled int `json:"cancelled"`
|
||||
EgressOK int `json:"egress_ok"`
|
||||
IngressOK int `json:"ingress_ok"`
|
||||
EgressHTTPSAny int `json:"egress_https_any_failed"`
|
||||
EgressHTTPSAll int `json:"egress_https_all_failed"`
|
||||
// EgressHTTPSAllTargets counts the addresses that failed https to every
|
||||
// target of the full set (as many checks as the best-covered address).
|
||||
EgressHTTPSAllTargets int `json:"egress_https_all_targets_failed"`
|
||||
IngressSSHAny int `json:"ingress_ssh_any_failed"`
|
||||
IngressSSHAll int `json:"ingress_ssh_all_failed"`
|
||||
PerMinute float64 `json:"addresses_per_minute"`
|
||||
}
|
||||
|
||||
type Reason struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Quality is the data-quality block: how the verdict relates to the checks.
|
||||
type Quality struct {
|
||||
LateFailedAtPass int `json:"late_failed_checks_at_pass"`
|
||||
LateFailedAtPassAddresses int `json:"late_failed_addresses_at_pass"`
|
||||
IngressFailed int `json:"ingress_failed_checks"`
|
||||
IngressFailedLate int `json:"ingress_failed_late"`
|
||||
Incomplete int `json:"incomplete_addresses"`
|
||||
PassWithFailed int `json:"pass_with_failed_addresses"`
|
||||
PassByFacts int `json:"pass_by_facts"`
|
||||
}
|
||||
|
||||
type SubnetRow struct {
|
||||
CIDR string `json:"cidr"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Addresses int `json:"addresses"`
|
||||
Pass int `json:"pass"`
|
||||
EgressOK int `json:"egress_ok"`
|
||||
IngressOK int `json:"ingress_ok"`
|
||||
}
|
||||
|
||||
type TargetsBlock struct {
|
||||
Types []string `json:"types"`
|
||||
Targets []string `json:"targets"`
|
||||
Failed map[string][]int `json:"failed"` // type -> failed addresses per target, in Targets order
|
||||
}
|
||||
|
||||
type MatrixRow struct {
|
||||
CIDR string `json:"cidr"`
|
||||
Partial int `json:"partial"`
|
||||
Percent []int `json:"percent"` // per target, in Targets order
|
||||
}
|
||||
|
||||
type SitesBlock struct {
|
||||
Types []string `json:"types"`
|
||||
Rows []SiteRow `json:"rows"`
|
||||
}
|
||||
|
||||
type SiteRow struct {
|
||||
Site string `json:"site"`
|
||||
Stats []SiteStat `json:"stats"` // per type, in Types order
|
||||
}
|
||||
|
||||
type SiteStat struct {
|
||||
Total int `json:"total"`
|
||||
OK int `json:"ok"`
|
||||
}
|
||||
|
||||
type ErrorClass struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type ValidatorRow struct {
|
||||
Validator string `json:"validator"`
|
||||
Total int `json:"total"`
|
||||
OK int `json:"ok"`
|
||||
}
|
||||
|
||||
type typeStat struct{ n, ok int }
|
||||
|
||||
type failedIngress struct {
|
||||
class, site, validator string
|
||||
late bool
|
||||
}
|
||||
|
||||
// addr is everything known about one address of the run.
|
||||
type addr struct {
|
||||
res db.RunResult
|
||||
subnet string
|
||||
egress typeStat
|
||||
ingress typeStat
|
||||
stored int
|
||||
https struct {
|
||||
typeStat
|
||||
validator string
|
||||
failedTargets []string
|
||||
}
|
||||
ssh struct {
|
||||
typeStat
|
||||
sites []string
|
||||
errs map[string]bool
|
||||
}
|
||||
failedTargets map[string]bool // family\x00target -> failed
|
||||
failedIngress []failedIngress
|
||||
lateFailed int
|
||||
}
|
||||
|
||||
// incomplete is true when fewer checks are stored than the cycle expected; an
|
||||
// unknown expectation (-1) is never incomplete.
|
||||
func (a *addr) incomplete() bool { return a.res.ExpectedChecks >= 0 && a.stored < a.res.ExpectedChecks }
|
||||
|
||||
// Analysis is a computed report plus the per-address data the lists are cut from.
|
||||
type Analysis struct {
|
||||
Report Report
|
||||
addrs []*addr
|
||||
siteNames map[int]string
|
||||
}
|
||||
|
||||
// Compute reads the checks of the run once and builds the report.
|
||||
func Compute(in Input) (*Analysis, error) {
|
||||
byReg := make(map[int64]*addr, len(in.Results))
|
||||
var addrs []*addr
|
||||
subnetOf := newSubnetMatcher(in.Subnets)
|
||||
for _, r := range in.Results {
|
||||
a := &addr{res: r, subnet: subnetOf(r.IPAddress), failedTargets: map[string]bool{}}
|
||||
a.ssh.errs = map[string]bool{}
|
||||
byReg[r.RegistryID] = a
|
||||
addrs = append(addrs, a)
|
||||
}
|
||||
siteName := func(source string) string {
|
||||
idx, _ := strconv.Atoi(strings.TrimPrefix(source, "inbound-site-"))
|
||||
if n := in.SiteNames[idx]; n != "" {
|
||||
return n
|
||||
}
|
||||
return "site-" + strconv.Itoa(idx)
|
||||
}
|
||||
|
||||
type key struct{ site, typ string }
|
||||
siteStats := map[key]*typeStat{}
|
||||
siteTypes := map[string]bool{}
|
||||
egressTypes := map[string]bool{}
|
||||
valHTTPS := map[string]*typeStat{}
|
||||
targetSet := map[string]bool{}
|
||||
errCount := map[string]int{}
|
||||
|
||||
err := in.Each(func(c db.RunCheck) {
|
||||
a := byReg[c.RegistryID]
|
||||
if a == nil || a.res.Verdict == db.ResultCancelled {
|
||||
return // a cancelled address was stopped, its checks say nothing
|
||||
}
|
||||
a.stored++
|
||||
late := c.AfterVerdict || c.RecordedAt.After(a.res.AggregatedAt)
|
||||
family := db.CheckFamily(c.CheckType)
|
||||
switch db.CheckLevel(c.Source) {
|
||||
case db.LevelEgress:
|
||||
a.egress.n++
|
||||
if c.Success {
|
||||
a.egress.ok++
|
||||
}
|
||||
egressTypes[family] = true
|
||||
target := normalizeTarget(c.Target)
|
||||
targetSet[target] = true
|
||||
if !c.Success {
|
||||
a.failedTargets[family+"\x00"+target] = true
|
||||
}
|
||||
if family == "https" {
|
||||
a.https.n++
|
||||
a.https.validator = c.ValidatorID
|
||||
if c.Success {
|
||||
a.https.ok++
|
||||
} else {
|
||||
a.https.failedTargets = append(a.https.failedTargets, target)
|
||||
}
|
||||
v := valHTTPS[c.ValidatorID]
|
||||
if v == nil {
|
||||
v = &typeStat{}
|
||||
valHTTPS[c.ValidatorID] = v
|
||||
}
|
||||
v.n++
|
||||
if c.Success {
|
||||
v.ok++
|
||||
}
|
||||
}
|
||||
case db.LevelIngress:
|
||||
a.ingress.n++
|
||||
if c.Success {
|
||||
a.ingress.ok++
|
||||
}
|
||||
site := siteName(c.Source)
|
||||
siteTypes[family] = true
|
||||
ss := siteStats[key{site, family}]
|
||||
if ss == nil {
|
||||
ss = &typeStat{}
|
||||
siteStats[key{site, family}] = ss
|
||||
}
|
||||
ss.n++
|
||||
if c.Success {
|
||||
ss.ok++
|
||||
}
|
||||
if family == "ssh" {
|
||||
a.ssh.n++
|
||||
if c.Success {
|
||||
a.ssh.ok++
|
||||
}
|
||||
}
|
||||
if !c.Success {
|
||||
class := ErrorClassOf(c.CheckType, c.Detail)
|
||||
errCount[class]++
|
||||
a.failedIngress = append(a.failedIngress, failedIngress{class: class, site: site, validator: c.ValidatorID, late: late})
|
||||
if family == "ssh" {
|
||||
a.ssh.sites = append(a.ssh.sites, site)
|
||||
a.ssh.errs[errorReason(c.CheckType, c.Detail)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if late && !c.Success {
|
||||
a.lateFailed++
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rep := Report{Matrix: map[string][]MatrixRow{}}
|
||||
rep.Run = RunInfo{ID: in.Run.ID, Kind: in.Run.Kind, State: in.Run.State, StartedAt: in.Run.StartedAt,
|
||||
FinalizedAt: in.Run.FinalizedAt, Rechecked: in.Rechecked}
|
||||
if in.Run.FinalizedAt != nil {
|
||||
rep.Run.DurationSec = int(in.Run.FinalizedAt.Sub(in.Run.StartedAt).Seconds())
|
||||
}
|
||||
|
||||
maxHTTPS := 0
|
||||
for _, a := range addrs {
|
||||
if a.https.n > maxHTTPS {
|
||||
maxHTTPS = a.https.n
|
||||
}
|
||||
}
|
||||
|
||||
reasonCount := map[string]int{}
|
||||
type subAgg struct {
|
||||
n, pass, eg, ing, partial int
|
||||
failed map[string]int
|
||||
}
|
||||
subs := map[string]*subAgg{}
|
||||
sum := &rep.Summary
|
||||
for _, a := range addrs {
|
||||
v := a.res.Verdict
|
||||
if v == db.ResultCancelled {
|
||||
sum.Cancelled++
|
||||
continue
|
||||
}
|
||||
sum.Addresses++
|
||||
switch v {
|
||||
case db.ResultPass:
|
||||
sum.Pass++
|
||||
case db.ResultPartial:
|
||||
sum.Partial++
|
||||
case db.ResultFail:
|
||||
sum.Fail++
|
||||
}
|
||||
egOK := a.egress.n > 0 && a.egress.ok == a.egress.n
|
||||
inOK := a.ingress.n > 0 && a.ingress.ok == a.ingress.n
|
||||
if egOK {
|
||||
sum.EgressOK++
|
||||
}
|
||||
if inOK {
|
||||
sum.IngressOK++
|
||||
}
|
||||
if a.https.n > 0 && a.https.ok < a.https.n {
|
||||
sum.EgressHTTPSAny++
|
||||
if a.https.ok == 0 {
|
||||
sum.EgressHTTPSAll++
|
||||
if a.https.n == maxHTTPS {
|
||||
sum.EgressHTTPSAllTargets++
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.ssh.n > 0 && a.ssh.ok < a.ssh.n {
|
||||
sum.IngressSSHAny++
|
||||
if a.ssh.ok == 0 {
|
||||
sum.IngressSSHAll++
|
||||
}
|
||||
}
|
||||
|
||||
egFail := a.egress.ok < a.egress.n
|
||||
inFail := a.ingress.ok < a.ingress.n
|
||||
incomplete := a.incomplete()
|
||||
if incomplete {
|
||||
rep.Quality.Incomplete++
|
||||
}
|
||||
if v == db.ResultPartial {
|
||||
reasonCount[reasonName(egFail, inFail, incomplete)]++
|
||||
}
|
||||
if v == db.ResultPass {
|
||||
if a.egress.ok < a.egress.n || a.ingress.ok < a.ingress.n {
|
||||
rep.Quality.PassWithFailed++
|
||||
rep.Quality.LateFailedAtPass += a.lateFailed
|
||||
rep.Quality.LateFailedAtPassAddresses++
|
||||
}
|
||||
}
|
||||
|
||||
sa := subs[a.subnet]
|
||||
if sa == nil {
|
||||
sa = &subAgg{failed: map[string]int{}}
|
||||
subs[a.subnet] = sa
|
||||
}
|
||||
sa.n++
|
||||
if v == db.ResultPass {
|
||||
sa.pass++
|
||||
}
|
||||
if egOK {
|
||||
sa.eg++
|
||||
}
|
||||
if inOK {
|
||||
sa.ing++
|
||||
}
|
||||
if v == db.ResultPartial {
|
||||
sa.partial++
|
||||
for k := range a.failedTargets {
|
||||
sa.failed[k]++
|
||||
}
|
||||
}
|
||||
}
|
||||
rep.Quality.PassByFacts = sum.Pass - rep.Quality.PassWithFailed
|
||||
if sum.Addresses > 0 && rep.Run.DurationSec > 0 {
|
||||
sum.PerMinute = float64(sum.Addresses) / (float64(rep.Run.DurationSec) / 60)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
for _, f := range a.failedIngress {
|
||||
rep.Quality.IngressFailed++
|
||||
if f.late {
|
||||
rep.Quality.IngressFailedLate++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range reasonOrder {
|
||||
if n := reasonCount[name]; n > 0 {
|
||||
rep.Reasons = append(rep.Reasons, Reason{Name: name, Count: n})
|
||||
}
|
||||
}
|
||||
|
||||
// Subnets: worst first is the page's job; the report lists them by size.
|
||||
labels := map[string]string{}
|
||||
for _, s := range in.Subnets {
|
||||
labels[s.CIDR] = s.Label
|
||||
}
|
||||
for cidr, sa := range subs {
|
||||
rep.Subnets = append(rep.Subnets, SubnetRow{CIDR: cidr, Label: labels[cidr], Addresses: sa.n, Pass: sa.pass, EgressOK: sa.eg, IngressOK: sa.ing})
|
||||
}
|
||||
sort.Slice(rep.Subnets, func(i, j int) bool {
|
||||
if rep.Subnets[i].Addresses != rep.Subnets[j].Addresses {
|
||||
return rep.Subnets[i].Addresses > rep.Subnets[j].Addresses
|
||||
}
|
||||
return rep.Subnets[i].CIDR < rep.Subnets[j].CIDR
|
||||
})
|
||||
|
||||
// Targets and the subnet x target matrix, per egress check family.
|
||||
types := sortedKeys(egressTypes)
|
||||
rep.Targets.Types = types
|
||||
failedAddrs := map[string]int{} // family\x00target -> addresses
|
||||
for _, a := range addrs {
|
||||
if a.res.Verdict == db.ResultCancelled {
|
||||
continue
|
||||
}
|
||||
for k := range a.failedTargets {
|
||||
failedAddrs[k]++
|
||||
}
|
||||
}
|
||||
targets := sortedKeys(targetSet)
|
||||
lead := ""
|
||||
if len(types) > 0 {
|
||||
lead = types[0]
|
||||
for _, t := range types {
|
||||
if t == "https" {
|
||||
lead = t
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(targets, func(i, j int) bool {
|
||||
fi, fj := failedAddrs[lead+"\x00"+targets[i]], failedAddrs[lead+"\x00"+targets[j]]
|
||||
if fi != fj {
|
||||
return fi > fj
|
||||
}
|
||||
return targets[i] < targets[j]
|
||||
})
|
||||
rep.Targets.Targets = targets
|
||||
rep.Targets.Failed = map[string][]int{}
|
||||
for _, t := range types {
|
||||
row := make([]int, len(targets))
|
||||
for i, tg := range targets {
|
||||
row[i] = failedAddrs[t+"\x00"+tg]
|
||||
}
|
||||
rep.Targets.Failed[t] = row
|
||||
}
|
||||
for _, t := range types {
|
||||
var rows []MatrixRow
|
||||
for cidr, sa := range subs {
|
||||
if sa.partial == 0 {
|
||||
continue
|
||||
}
|
||||
pc := make([]int, len(targets))
|
||||
for i, tg := range targets {
|
||||
pc[i] = int(float64(sa.failed[t+"\x00"+tg])/float64(sa.partial)*100 + 0.5)
|
||||
}
|
||||
rows = append(rows, MatrixRow{CIDR: cidr, Partial: sa.partial, Percent: pc})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Partial != rows[j].Partial {
|
||||
return rows[i].Partial > rows[j].Partial
|
||||
}
|
||||
return rows[i].CIDR < rows[j].CIDR
|
||||
})
|
||||
rep.Matrix[t] = rows
|
||||
}
|
||||
|
||||
// Sites.
|
||||
rep.Sites.Types = sortedKeys(siteTypes)
|
||||
names := map[string]bool{}
|
||||
for k := range siteStats {
|
||||
names[k.site] = true
|
||||
}
|
||||
siteList := sortedKeys(names)
|
||||
sort.Slice(siteList, func(i, j int) bool {
|
||||
return siteIndexOf(in.SiteNames, siteList[i]) < siteIndexOf(in.SiteNames, siteList[j])
|
||||
})
|
||||
for _, s := range siteList {
|
||||
row := SiteRow{Site: s}
|
||||
for _, t := range rep.Sites.Types {
|
||||
st := siteStats[key{s, t}]
|
||||
if st == nil {
|
||||
st = &typeStat{}
|
||||
}
|
||||
row.Stats = append(row.Stats, SiteStat{Total: st.n, OK: st.ok})
|
||||
}
|
||||
rep.Sites.Rows = append(rep.Sites.Rows, row)
|
||||
}
|
||||
|
||||
for name, n := range errCount {
|
||||
rep.Errors = append(rep.Errors, ErrorClass{Name: name, Count: n})
|
||||
}
|
||||
sort.Slice(rep.Errors, func(i, j int) bool {
|
||||
if rep.Errors[i].Count != rep.Errors[j].Count {
|
||||
return rep.Errors[i].Count > rep.Errors[j].Count
|
||||
}
|
||||
return rep.Errors[i].Name < rep.Errors[j].Name
|
||||
})
|
||||
|
||||
for id, st := range valHTTPS {
|
||||
rep.Validators = append(rep.Validators, ValidatorRow{Validator: id, Total: st.n, OK: st.ok})
|
||||
}
|
||||
sort.Slice(rep.Validators, func(i, j int) bool {
|
||||
a, b := validatorNumber(rep.Validators[i].Validator), validatorNumber(rep.Validators[j].Validator)
|
||||
if a != b {
|
||||
return a < b
|
||||
}
|
||||
return rep.Validators[i].Validator < rep.Validators[j].Validator
|
||||
})
|
||||
|
||||
return &Analysis{Report: rep, addrs: addrs, siteNames: in.SiteNames}, nil
|
||||
}
|
||||
|
||||
var reasonOrder = []string{
|
||||
"Только egress",
|
||||
"Ingress и egress",
|
||||
"Egress и неполный набор",
|
||||
"Ingress, egress и неполный набор",
|
||||
"Только неполный набор",
|
||||
"Только ingress",
|
||||
"Ingress и неполный набор",
|
||||
"Прочее",
|
||||
}
|
||||
|
||||
func reasonName(egress, ingress, incomplete bool) string {
|
||||
switch {
|
||||
case egress && ingress && incomplete:
|
||||
return "Ingress, egress и неполный набор"
|
||||
case egress && ingress:
|
||||
return "Ingress и egress"
|
||||
case egress && incomplete:
|
||||
return "Egress и неполный набор"
|
||||
case egress:
|
||||
return "Только egress"
|
||||
case ingress && incomplete:
|
||||
return "Ingress и неполный набор"
|
||||
case ingress:
|
||||
return "Только ingress"
|
||||
case incomplete:
|
||||
return "Только неполный набор"
|
||||
}
|
||||
return "Прочее"
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func siteIndexOf(names map[int]string, site string) int {
|
||||
for i, n := range names {
|
||||
if n == site {
|
||||
return i
|
||||
}
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimPrefix(site, "site-")); err == nil {
|
||||
return n
|
||||
}
|
||||
return 1 << 20
|
||||
}
|
||||
|
||||
// validatorNumber is the trailing number of a validator id ("vkiplab-v12" ->
|
||||
// 12), or 1<<20 when there is none, so numbered validators sort naturally.
|
||||
func validatorNumber(id string) int {
|
||||
i := len(id)
|
||||
for i > 0 && id[i-1] >= '0' && id[i-1] <= '9' {
|
||||
i--
|
||||
}
|
||||
if i == len(id) {
|
||||
return 1 << 20
|
||||
}
|
||||
n, _ := strconv.Atoi(id[i:])
|
||||
return n
|
||||
}
|
||||
|
||||
// ShortValidator is the validator id as the page shows it: "vkiplab-v12" ->
|
||||
// "v12"; ids without a number stay whole.
|
||||
func ShortValidator(id string) string {
|
||||
n := validatorNumber(id)
|
||||
if n == 1<<20 {
|
||||
return id
|
||||
}
|
||||
return "v" + strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// normalizeTarget is the host of an egress target: https://host/path -> host.
|
||||
func normalizeTarget(t string) string {
|
||||
if u, err := url.Parse(t); err == nil && u.Host != "" {
|
||||
return u.Hostname()
|
||||
}
|
||||
return strings.TrimSuffix(t, "/")
|
||||
}
|
||||
|
||||
// newSubnetMatcher returns a function that maps an address to the most
|
||||
// specific configured subnet. With no subnets configured addresses group by
|
||||
// /24 (/64 for IPv6). An address outside the list goes to "прочие".
|
||||
func newSubnetMatcher(subnets []db.Subnet) func(string) string {
|
||||
type entry struct {
|
||||
p netip.Prefix
|
||||
name string
|
||||
}
|
||||
var list []entry
|
||||
for _, s := range subnets {
|
||||
if p, err := netip.ParsePrefix(s.CIDR); err == nil {
|
||||
list = append(list, entry{p.Masked(), p.Masked().String()})
|
||||
}
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].p.Bits() > list[j].p.Bits() })
|
||||
return func(ip string) string {
|
||||
a, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return "прочие"
|
||||
}
|
||||
if len(list) == 0 {
|
||||
bits := 24
|
||||
if a.Is6() {
|
||||
bits = 64
|
||||
}
|
||||
p, _ := a.Prefix(bits)
|
||||
return p.String()
|
||||
}
|
||||
for _, e := range list {
|
||||
if e.p.Contains(a) {
|
||||
return e.name
|
||||
}
|
||||
}
|
||||
return "прочие"
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorClassOf names the class of a failed ingress check: the check type and
|
||||
// the reason, e.g. "SSH: таймаут", "ICMP: нет ответа".
|
||||
func ErrorClassOf(checkType, detail string) string {
|
||||
return strings.ToUpper(checkType) + ": " + errorReason(checkType, detail)
|
||||
}
|
||||
|
||||
func errorReason(checkType, detail string) string {
|
||||
d := strings.ToLower(detail)
|
||||
switch {
|
||||
case strings.Contains(d, "unexpected banner prefix"):
|
||||
if strings.Contains(d, "not allo") {
|
||||
return "баннер «Not allowed»"
|
||||
}
|
||||
return "неожиданный баннер"
|
||||
case strings.Contains(d, "no route to host"):
|
||||
return "нет маршрута"
|
||||
case strings.Contains(d, "time exceeded"):
|
||||
return "time exceeded"
|
||||
case strings.Contains(d, "connection refused"):
|
||||
return "отказ в соединении"
|
||||
case strings.Contains(d, "timeout") || strings.Contains(d, "deadline exceeded"):
|
||||
if strings.EqualFold(checkType, "icmp") {
|
||||
return "нет ответа"
|
||||
}
|
||||
return "таймаут"
|
||||
}
|
||||
if strings.EqualFold(checkType, "icmp") {
|
||||
return "нет ответа"
|
||||
}
|
||||
return "прочее"
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 10, 2, 13, 0, 0, 0, time.UTC)
|
||||
|
||||
type fixture struct {
|
||||
results []db.RunResult
|
||||
checks []db.RunCheck
|
||||
}
|
||||
|
||||
func (f *fixture) addr(reg int64, ip, verdict string, expected int) {
|
||||
f.results = append(f.results, db.RunResult{RegistryID: reg, IPAddress: ip, CycleID: 1, Verdict: verdict,
|
||||
AggregatedAt: t0.Add(time.Minute), ExpectedChecks: expected})
|
||||
}
|
||||
|
||||
func (f *fixture) check(reg int64, source, typ, target string, ok bool, validator, detail string, late bool) {
|
||||
rec := t0
|
||||
if late {
|
||||
rec = t0.Add(time.Hour)
|
||||
}
|
||||
f.checks = append(f.checks, db.RunCheck{RegistryID: reg, Source: source, CheckType: typ, Target: target, Success: ok,
|
||||
ValidatorID: validator, Detail: detail, RecordedAt: rec})
|
||||
}
|
||||
|
||||
func (f *fixture) compute(t *testing.T, subnets []db.Subnet) *Analysis {
|
||||
t.Helper()
|
||||
end := t0.Add(10 * time.Minute)
|
||||
an, err := Compute(Input{
|
||||
Run: db.CheckRun{ID: 7, Kind: db.RunManual, State: db.RunFinalized, StartedAt: t0, FinalizedAt: &end},
|
||||
Results: f.results,
|
||||
Subnets: subnets,
|
||||
SiteNames: map[int]string{1: "rxmsk", 2: "rxyc"},
|
||||
Each: func(fn func(db.RunCheck)) error {
|
||||
for _, c := range f.checks {
|
||||
fn(c)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return an
|
||||
}
|
||||
|
||||
const (
|
||||
eg = db.SourceEgress
|
||||
s1 = "inbound-site-1"
|
||||
s2 = "inbound-site-2"
|
||||
)
|
||||
|
||||
func TestComputeCountsFactsPerAddress(t *testing.T) {
|
||||
f := &fixture{}
|
||||
// 1: all fine
|
||||
f.addr(1, "10.0.0.1", db.ResultPass, 6)
|
||||
// 2: egress https fails on both targets, rest fine
|
||||
f.addr(2, "10.0.0.2", db.ResultPartial, 6)
|
||||
// 3: ingress ssh fails on one site, set incomplete (5 of 6 stored)
|
||||
f.addr(3, "10.0.1.1", db.ResultPartial, 6)
|
||||
// 4: pass at the verdict, but a failed ingress check arrived afterwards
|
||||
f.addr(4, "10.0.1.2", db.ResultPass, 6)
|
||||
// 5: cancelled, not counted
|
||||
f.addr(5, "10.0.1.3", db.ResultCancelled, 6)
|
||||
|
||||
good := func(reg int64) {
|
||||
f.check(reg, eg, "https", "https://a.test/x", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, eg, "https", "https://b.test", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s1, "icmp", "ip", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s1, "ssh", "ip", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s2, "icmp", "ip", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s2, "ssh", "ip", true, "vkiplab-v1", "", false)
|
||||
}
|
||||
good(1)
|
||||
f.check(2, eg, "https", "https://a.test/x", false, "vkiplab-v2", `Get "https://a.test/x": context deadline exceeded`, false)
|
||||
f.check(2, eg, "https", "https://b.test", false, "vkiplab-v2", "", false)
|
||||
for _, s := range []string{s1, s2} {
|
||||
f.check(2, s, "icmp", "ip", true, "vkiplab-v2", "", false)
|
||||
f.check(2, s, "ssh", "ip", true, "vkiplab-v2", "", false)
|
||||
}
|
||||
f.check(3, eg, "https", "https://a.test/x", true, "vkiplab-v3", "", false)
|
||||
f.check(3, eg, "https", "https://b.test", true, "vkiplab-v3", "", false)
|
||||
f.check(3, s1, "icmp", "ip", true, "vkiplab-v3", "", false)
|
||||
f.check(3, s1, "ssh", "ip", false, "vkiplab-v3", "dial tcp 1.2.3.4:22: i/o timeout", false)
|
||||
f.check(3, s2, "icmp", "ip", true, "vkiplab-v3", "", false) // the 6th check is missing
|
||||
// 4: the failed ssh arrived after the verdict
|
||||
f.check(4, eg, "https", "https://a.test/x", true, "vkiplab-v4", "", false)
|
||||
f.check(4, eg, "https", "https://b.test", true, "vkiplab-v4", "", false)
|
||||
f.check(4, s1, "icmp", "ip", true, "vkiplab-v4", "", false)
|
||||
f.check(4, s1, "ssh", "ip", false, "vkiplab-v4", `unexpected banner prefix "Not allo"`, true)
|
||||
f.check(4, s2, "icmp", "ip", true, "vkiplab-v4", "", false)
|
||||
f.check(4, s2, "ssh", "ip", true, "vkiplab-v4", "", false)
|
||||
good(5)
|
||||
|
||||
an := f.compute(t, []db.Subnet{{CIDR: "10.0.0.0/24"}, {CIDR: "10.0.1.0/24"}})
|
||||
r := an.Report
|
||||
|
||||
want := Summary{Addresses: 4, Pass: 2, Partial: 2, Cancelled: 1, EgressOK: 3, IngressOK: 2,
|
||||
EgressHTTPSAny: 1, EgressHTTPSAll: 1, EgressHTTPSAllTargets: 1, IngressSSHAny: 2, IngressSSHAll: 1}
|
||||
got := r.Summary
|
||||
got.PerMinute = 0
|
||||
if got != want {
|
||||
t.Errorf("summary = %+v\nwant %+v", got, want)
|
||||
}
|
||||
if r.Run.DurationSec != 600 || r.Run.ID != 7 {
|
||||
t.Errorf("run info: %+v", r.Run)
|
||||
}
|
||||
|
||||
if wantReasons := []Reason{{"Только egress", 1}, {"Ingress и неполный набор", 1}}; !reflect.DeepEqual(r.Reasons, wantReasons) {
|
||||
t.Errorf("reasons = %+v, want %+v", r.Reasons, wantReasons)
|
||||
}
|
||||
|
||||
q := r.Quality
|
||||
if q.Incomplete != 1 || q.PassWithFailed != 1 || q.PassByFacts != 1 || q.LateFailedAtPass != 1 || q.LateFailedAtPassAddresses != 1 ||
|
||||
q.IngressFailed != 2 || q.IngressFailedLate != 1 {
|
||||
t.Errorf("quality = %+v", q)
|
||||
}
|
||||
|
||||
// Errors are classed by check type and reason.
|
||||
wantErrs := []ErrorClass{{"SSH: баннер «Not allowed»", 1}, {"SSH: таймаут", 1}}
|
||||
if !reflect.DeepEqual(r.Errors, wantErrs) {
|
||||
t.Errorf("errors = %+v", r.Errors)
|
||||
}
|
||||
|
||||
// Targets: hosts, https is the lead type; address 2 failed both.
|
||||
if !reflect.DeepEqual(r.Targets.Targets, []string{"a.test", "b.test"}) || !reflect.DeepEqual(r.Targets.Failed["https"], []int{1, 1}) {
|
||||
t.Errorf("targets = %+v", r.Targets)
|
||||
}
|
||||
if len(r.Subnets) != 2 || r.Subnets[0].Addresses != 2 {
|
||||
t.Errorf("subnets = %+v", r.Subnets)
|
||||
}
|
||||
if rows := r.Matrix["https"]; len(rows) != 2 || rows[0].CIDR != "10.0.0.0/24" && rows[0].CIDR != "10.0.1.0/24" {
|
||||
t.Errorf("matrix = %+v", r.Matrix)
|
||||
}
|
||||
|
||||
// Sites in index order, types sorted.
|
||||
if !reflect.DeepEqual(r.Sites.Types, []string{"icmp", "ssh"}) || len(r.Sites.Rows) != 2 || r.Sites.Rows[0].Site != "rxmsk" {
|
||||
t.Errorf("sites = %+v", r.Sites)
|
||||
}
|
||||
// ssh at rxmsk: addresses 1, 2, 3, 4 (the cancelled one is not counted) -> 4 checks, 2 failed.
|
||||
if st := r.Sites.Rows[0].Stats[1]; st.Total != 4 || st.OK != 2 {
|
||||
t.Errorf("rxmsk ssh = %+v", st)
|
||||
}
|
||||
// Validators by number.
|
||||
if len(r.Validators) != 4 || r.Validators[0].Validator != "vkiplab-v1" || r.Validators[0].Total != 2 {
|
||||
t.Errorf("validators = %+v", r.Validators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubnetMatching(t *testing.T) {
|
||||
in := []db.Subnet{{CIDR: "10.0.0.0/8"}, {CIDR: "10.1.0.0/16"}}
|
||||
m := newSubnetMatcher(in)
|
||||
for ip, want := range map[string]string{"10.1.2.3": "10.1.0.0/16", "10.2.0.1": "10.0.0.0/8", "192.0.2.1": "прочие", "garbage": "прочие"} {
|
||||
if got := m(ip); got != want {
|
||||
t.Errorf("%s -> %s, want %s", ip, got, want)
|
||||
}
|
||||
}
|
||||
auto := newSubnetMatcher(nil)
|
||||
if got := auto("203.0.113.77"); got != "203.0.113.0/24" {
|
||||
t.Errorf("without a list addresses group by /24, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorClassOf(t *testing.T) {
|
||||
for _, c := range []struct{ typ, detail, want string }{
|
||||
{"ssh", `read banner: read tcp 1.2.3.4:5->6.7.8.9:22: i/o timeout`, "SSH: таймаут"},
|
||||
{"ssh", `unexpected banner prefix "Not allo"`, "SSH: баннер «Not allowed»"},
|
||||
{"ssh", `dial tcp 1.2.3.4:22: connect: no route to host`, "SSH: нет маршрута"},
|
||||
{"tcp-22", `dial tcp 1.2.3.4:22: i/o timeout`, "TCP-22: таймаут"},
|
||||
{"tcp-22", `connect: connection refused`, "TCP-22: отказ в соединении"},
|
||||
{"icmp", `read echo reply: read ip4 0.0.0.0: i/o timeout`, "ICMP: нет ответа"},
|
||||
{"icmp", `unexpected icmp type time exceeded`, "ICMP: time exceeded"},
|
||||
{"ssh", `something new`, "SSH: прочее"},
|
||||
} {
|
||||
if got := ErrorClassOf(c.typ, c.detail); got != c.want {
|
||||
t.Errorf("%s %q = %q, want %q", c.typ, c.detail, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListsAndShortValidator(t *testing.T) {
|
||||
f := &fixture{}
|
||||
f.addr(1, "10.0.0.9", db.ResultPartial, 4)
|
||||
f.addr(2, "10.0.0.10", db.ResultPass, 4)
|
||||
f.check(1, eg, "https", "https://a.test", false, "vkiplab-v12", "", false)
|
||||
f.check(1, eg, "https", "https://b.test", false, "vkiplab-v12", "", false)
|
||||
f.check(1, s2, "ssh", "ip", false, "vkiplab-v12", "dial tcp: i/o timeout", false)
|
||||
f.check(1, s1, "ssh", "ip", false, "vkiplab-v12", "dial tcp: i/o timeout", true)
|
||||
f.check(2, eg, "https", "https://a.test", true, "vkiplab-v3", "", false)
|
||||
f.check(2, eg, "https", "https://b.test", false, "vkiplab-v3", "", false)
|
||||
an := f.compute(t, nil)
|
||||
|
||||
l, err := an.List(ListEgressHTTPSAny, "")
|
||||
if err != nil || len(l.Rows) != 2 || l.Rows[0][0] != "10.0.0.9" || l.Rows[1][0] != "10.0.0.10" { // numeric order
|
||||
t.Fatalf("any: %+v %v", l, err)
|
||||
}
|
||||
if l.Rows[0][2] != "v12" || l.Rows[0][3] != "2 из 2" || l.Rows[1][3] != "1 из 2" || l.Rows[1][4] != "b.test" {
|
||||
t.Errorf("any rows: %+v", l.Rows)
|
||||
}
|
||||
l, _ = an.List(ListEgressHTTPSAll, "")
|
||||
if len(l.Rows) != 1 || l.Rows[0][3] != "2" || l.Rows[0][4] != "a.test, b.test" {
|
||||
t.Errorf("all: %+v", l.Rows)
|
||||
}
|
||||
l, _ = an.List(ListIngressSSHAll, "")
|
||||
if len(l.Rows) != 1 || l.Rows[0][2] != "rxmsk, rxyc" || l.Rows[0][3] != "таймаут" { // sites in index order
|
||||
t.Errorf("ssh all: %+v", l.Rows)
|
||||
}
|
||||
l, _ = an.List(ListError, "SSH: таймаут")
|
||||
if len(l.Rows) != 2 || l.Rows[0][2] != "rxmsk" || l.Rows[0][5] != "провал, после вердикта" || l.Rows[1][5] != "провал, в вердикте" {
|
||||
t.Errorf("error list: %+v", l.Rows)
|
||||
}
|
||||
if _, err := an.List(ListError, ""); err == nil {
|
||||
t.Error("an error list needs a class")
|
||||
}
|
||||
if _, err := an.List("nonsense", ""); err == nil {
|
||||
t.Error("unknown list must fail")
|
||||
}
|
||||
for in, want := range map[string]string{"vkiplab-v12": "v12", "validator": "validator", "": ""} {
|
||||
if got := ShortValidator(in); got != want {
|
||||
t.Errorf("ShortValidator(%q) = %q", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerdictLists(t *testing.T) {
|
||||
f := &fixture{}
|
||||
f.addr(1, "10.0.0.10", db.ResultPass, 4) // late failed ssh: pass by verdict, failed by facts
|
||||
f.addr(2, "10.0.0.2", db.ResultPass, 4)
|
||||
f.addr(3, "10.0.0.9", db.ResultPartial, 4) // egress failed, one check missing
|
||||
f.addr(4, "10.0.0.3", db.ResultPartial, -1) // expected number unknown
|
||||
f.addr(5, "10.0.0.5", db.ResultFail, 1) // no egress checks at all
|
||||
f.addr(6, "10.0.0.4", db.ResultFail, 2)
|
||||
f.addr(7, "10.0.0.1", db.ResultCancelled, 4)
|
||||
for _, reg := range []int64{1, 2} {
|
||||
f.check(reg, eg, "https", "https://a.test", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, eg, "https", "https://b.test", true, "vkiplab-v1", "", false)
|
||||
f.check(reg, s1, "icmp", "ip", true, "vkiplab-v1", "", false)
|
||||
}
|
||||
f.check(1, s1, "ssh", "ip", false, "vkiplab-v1", "dial tcp: i/o timeout", true)
|
||||
f.check(2, s1, "ssh", "ip", true, "vkiplab-v1", "", false)
|
||||
f.check(3, eg, "https", "https://a.test", false, "vkiplab-v12", "", false)
|
||||
f.check(3, eg, "https", "https://b.test", false, "vkiplab-v12", "", false)
|
||||
f.check(3, s1, "icmp", "ip", true, "vkiplab-v12", "", false)
|
||||
f.check(4, eg, "https", "https://a.test", true, "vkiplab-v3", "", false)
|
||||
f.check(4, s1, "ssh", "ip", false, "vkiplab-v3", "dial tcp: i/o timeout", false)
|
||||
f.check(5, s1, "icmp", "ip", false, "vkiplab-v5", "", false)
|
||||
f.check(6, eg, "https", "https://a.test", false, "vkiplab-v5", "", false)
|
||||
f.check(6, s1, "icmp", "ip", false, "vkiplab-v5", "", false)
|
||||
f.check(7, eg, "https", "https://a.test", true, "vkiplab-v1", "", false)
|
||||
an := f.compute(t, nil)
|
||||
|
||||
// Every list has as many rows as its number on the card; the cancelled address is in none.
|
||||
s := an.Report.Summary
|
||||
rows := map[string][][]string{}
|
||||
for kind, want := range map[string]int{ListVerdictPass: s.Pass, ListVerdictPartial: s.Partial, ListVerdictFail: s.Fail} {
|
||||
l, err := an.List(kind, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l.Kind != kind || len(l.Rows) != want || want != 2 {
|
||||
t.Fatalf("%s: %d rows, summary %d", kind, len(l.Rows), want)
|
||||
}
|
||||
for _, r := range l.Rows {
|
||||
if r[0] == "10.0.0.1" || len(r) != len(l.Columns) {
|
||||
t.Errorf("%s: bad row %v for columns %v", kind, r, l.Columns)
|
||||
}
|
||||
}
|
||||
rows[kind] = l.Rows
|
||||
}
|
||||
|
||||
// Numeric address order, not text order.
|
||||
if p := rows[ListVerdictPass]; p[0][0] != "10.0.0.2" || p[1][0] != "10.0.0.10" {
|
||||
t.Errorf("pass order: %v", p)
|
||||
}
|
||||
l, _ := an.List(ListVerdictPass, "")
|
||||
if want := []string{"Адрес", "Подсеть", "Валидатор", "Egress", "Ingress", "Проверок в цикле"}; !reflect.DeepEqual(l.Columns, want) {
|
||||
t.Errorf("pass columns: %v", l.Columns)
|
||||
}
|
||||
// Levels count successful of all; the failed late ssh shows in ingress.
|
||||
if want := []string{"10.0.0.2", "10.0.0.0/24", "v1", "2 из 2", "2 из 2", "4 из 4"}; !reflect.DeepEqual(rows[ListVerdictPass][0], want) {
|
||||
t.Errorf("pass row: %v", rows[ListVerdictPass][0])
|
||||
}
|
||||
if r := rows[ListVerdictPass][1]; r[3] != "2 из 2" || r[4] != "1 из 2" || r[5] != "4 из 4" {
|
||||
t.Errorf("pass with a late failure: %v", r)
|
||||
}
|
||||
|
||||
// Partial has the reason of the "why partial" block; a short set shows stored of expected,
|
||||
// an unknown expectation only the stored number.
|
||||
l, _ = an.List(ListVerdictPartial, "")
|
||||
if len(l.Columns) != 7 || l.Columns[6] != "Причина" {
|
||||
t.Errorf("partial columns: %v", l.Columns)
|
||||
}
|
||||
if want := []string{"10.0.0.3", "10.0.0.0/24", "v3", "1 из 1", "0 из 1", "2", "Только ingress"}; !reflect.DeepEqual(l.Rows[0], want) {
|
||||
t.Errorf("partial unknown expected: %v", l.Rows[0])
|
||||
}
|
||||
if want := []string{"10.0.0.9", "10.0.0.0/24", "v12", "0 из 2", "1 из 1", "3 из 4", "Egress и неполный набор"}; !reflect.DeepEqual(l.Rows[1], want) {
|
||||
t.Errorf("partial incomplete: %v", l.Rows[1])
|
||||
}
|
||||
byReason := map[string]int{}
|
||||
for _, r := range l.Rows {
|
||||
byReason[r[6]]++
|
||||
}
|
||||
for _, x := range an.Report.Reasons {
|
||||
if byReason[x.Name] != x.Count {
|
||||
t.Errorf("reason %q: %d rows, %d in the report", x.Name, byReason[x.Name], x.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// A level without checks shows a dash, so does a missing validator.
|
||||
l, _ = an.List(ListVerdictFail, "")
|
||||
if len(l.Columns) != 6 {
|
||||
t.Errorf("fail columns: %v", l.Columns)
|
||||
}
|
||||
if want := []string{"10.0.0.4", "10.0.0.0/24", "v5", "0 из 1", "0 из 1", "2 из 2"}; !reflect.DeepEqual(l.Rows[0], want) {
|
||||
t.Errorf("fail row: %v", l.Rows[0])
|
||||
}
|
||||
if want := []string{"10.0.0.5", "10.0.0.0/24", "—", "—", "0 из 1", "1 из 1"}; !reflect.DeepEqual(l.Rows[1], want) {
|
||||
t.Errorf("fail row without egress: %v", l.Rows[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// indicator is one of the seven counters of the analytics page. Its key is the
|
||||
// name of the list behind the counter; has repeats the condition Compute counts
|
||||
// it by (an address with a cancelled result is never asked).
|
||||
type indicator struct {
|
||||
key, name string
|
||||
has func(a *addr) bool
|
||||
}
|
||||
|
||||
var indicators = []indicator{
|
||||
{ListVerdictPass, "pass", func(a *addr) bool { return a.res.Verdict == db.ResultPass }},
|
||||
{ListVerdictPartial, "partial", func(a *addr) bool { return a.res.Verdict == db.ResultPartial }},
|
||||
{ListVerdictFail, "fail", func(a *addr) bool { return a.res.Verdict == db.ResultFail }},
|
||||
{ListEgressHTTPSAny, "Egress https: есть провалы", func(a *addr) bool { return a.https.n > 0 && a.https.ok < a.https.n }},
|
||||
{ListEgressHTTPSAll, "Egress https: все провалены", func(a *addr) bool { return a.https.n > 0 && a.https.ok == 0 }},
|
||||
{ListIngressSSHAny, "Ingress ssh: есть провалы", func(a *addr) bool { return a.ssh.n > 0 && a.ssh.ok < a.ssh.n }},
|
||||
{ListIngressSSHAll, "Ingress ssh: все провалены", func(a *addr) bool { return a.ssh.n > 0 && a.ssh.ok == 0 }},
|
||||
}
|
||||
|
||||
// indicatorIndex is the position of an indicator key in the table, -1 if unknown.
|
||||
func indicatorIndex(key string) int {
|
||||
for i, ind := range indicators {
|
||||
if ind.key == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// verdicts are the rows and columns of the transition matrix.
|
||||
var verdicts = []string{db.ResultPass, db.ResultPartial, db.ResultFail}
|
||||
|
||||
func verdictIndex(v string) int {
|
||||
for i, x := range verdicts {
|
||||
if x == v {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// membership is the set of indicators an address belongs to, one bit per
|
||||
// entry of the indicators table.
|
||||
func membership(a *addr) uint8 {
|
||||
var m uint8
|
||||
for i, ind := range indicators {
|
||||
if ind.has(a) {
|
||||
m |= 1 << i
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Comparison is the difference between two finished runs: the base (older) and
|
||||
// the target (newer) one. An address is its IP; one with a cancelled result is
|
||||
// not in its run.
|
||||
type Comparison struct {
|
||||
Runs CompareRuns `json:"runs"`
|
||||
Groups CompareGroups `json:"groups"`
|
||||
Indicators []IndicatorDiff `json:"indicators"`
|
||||
Transitions Transitions `json:"transitions"`
|
||||
Cancelled CompareCancel `json:"cancelled"`
|
||||
|
||||
rows []*cmpRow // numeric address order
|
||||
target *Analysis
|
||||
}
|
||||
|
||||
type CompareRuns struct {
|
||||
Base CompareRun `json:"base"`
|
||||
Target CompareRun `json:"target"`
|
||||
}
|
||||
|
||||
type CompareRun struct {
|
||||
RunInfo
|
||||
Addresses int `json:"addresses"`
|
||||
}
|
||||
|
||||
// CompareGroups counts the addresses by how they relate to the two runs:
|
||||
// New are only in the target, Left only in the base, Common in both, and
|
||||
// Common = Changed + Same.
|
||||
type CompareGroups struct {
|
||||
New int `json:"new"`
|
||||
Left int `json:"left"`
|
||||
Common int `json:"common"`
|
||||
Changed int `json:"changed"`
|
||||
Same int `json:"same"`
|
||||
}
|
||||
|
||||
// IndicatorDiff is one indicator in both runs. Delta = Target - Base =
|
||||
// New - Left + Entered - Exited.
|
||||
type IndicatorDiff struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Base int `json:"base"`
|
||||
Target int `json:"target"`
|
||||
Delta int `json:"delta"`
|
||||
New int `json:"new"` // new addresses that are in the indicator
|
||||
Left int `json:"left"` // left addresses that were in it
|
||||
Entered int `json:"entered"` // common addresses that entered it
|
||||
Exited int `json:"exited"` // common addresses that left it
|
||||
}
|
||||
|
||||
// Transitions is the verdict of the common addresses: Matrix[from][to] with
|
||||
// the verdicts of the base on the rows and of the target on the columns. New
|
||||
// holds the new addresses by their verdict in the target, Left the addresses
|
||||
// that left by their verdict in the base.
|
||||
type Transitions struct {
|
||||
Verdicts []string `json:"verdicts"`
|
||||
Matrix [][]int `json:"matrix"`
|
||||
New []int `json:"new"`
|
||||
Left []int `json:"left"`
|
||||
}
|
||||
|
||||
type CompareCancel struct {
|
||||
Base int `json:"base"`
|
||||
Target int `json:"target"`
|
||||
}
|
||||
|
||||
// cmpRow is one address with its state in each run (nil when absent) and the
|
||||
// indicators it belongs to there.
|
||||
type cmpRow struct {
|
||||
ip string
|
||||
a, b *addr
|
||||
ma, mb uint8
|
||||
}
|
||||
|
||||
func (r *cmpRow) common() bool { return r.a != nil && r.b != nil }
|
||||
func (r *cmpRow) changed() bool { return r.common() && r.ma != r.mb }
|
||||
|
||||
// Compare puts two analyses side by side; base is the older run, target the newer.
|
||||
func Compare(base, target *Analysis) *Comparison {
|
||||
byIP := map[string]*cmpRow{}
|
||||
var rows []*cmpRow
|
||||
add := func(list []*addr, isBase bool) {
|
||||
for _, x := range list {
|
||||
r := byIP[x.res.IPAddress]
|
||||
if r == nil {
|
||||
r = &cmpRow{ip: x.res.IPAddress}
|
||||
byIP[r.ip] = r
|
||||
rows = append(rows, r)
|
||||
}
|
||||
if isBase {
|
||||
r.a, r.ma = x, membership(x)
|
||||
} else {
|
||||
r.b, r.mb = x, membership(x)
|
||||
}
|
||||
}
|
||||
}
|
||||
add(base.sorted(), true)
|
||||
add(target.sorted(), false)
|
||||
sort.Slice(rows, func(i, j int) bool { return lessIP(rows[i].ip, rows[j].ip) })
|
||||
|
||||
c := &Comparison{rows: rows, target: target}
|
||||
c.Runs = CompareRuns{
|
||||
Base: CompareRun{RunInfo: base.Report.Run, Addresses: base.Report.Summary.Addresses},
|
||||
Target: CompareRun{RunInfo: target.Report.Run, Addresses: target.Report.Summary.Addresses},
|
||||
}
|
||||
c.Cancelled = CompareCancel{Base: base.Report.Summary.Cancelled, Target: target.Report.Summary.Cancelled}
|
||||
c.Indicators = make([]IndicatorDiff, len(indicators))
|
||||
for i, ind := range indicators {
|
||||
c.Indicators[i] = IndicatorDiff{Key: ind.key, Name: ind.name}
|
||||
}
|
||||
tr := &c.Transitions
|
||||
tr.Verdicts = verdicts
|
||||
tr.New, tr.Left = make([]int, len(verdicts)), make([]int, len(verdicts))
|
||||
tr.Matrix = make([][]int, len(verdicts))
|
||||
for i := range tr.Matrix {
|
||||
tr.Matrix[i] = make([]int, len(verdicts))
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
switch {
|
||||
case r.a == nil:
|
||||
c.Groups.New++
|
||||
if v := verdictIndex(r.b.res.Verdict); v >= 0 {
|
||||
tr.New[v]++
|
||||
}
|
||||
case r.b == nil:
|
||||
c.Groups.Left++
|
||||
if v := verdictIndex(r.a.res.Verdict); v >= 0 {
|
||||
tr.Left[v]++
|
||||
}
|
||||
default:
|
||||
c.Groups.Common++
|
||||
if r.changed() {
|
||||
c.Groups.Changed++
|
||||
} else {
|
||||
c.Groups.Same++
|
||||
}
|
||||
if from, to := verdictIndex(r.a.res.Verdict), verdictIndex(r.b.res.Verdict); from >= 0 && to >= 0 {
|
||||
tr.Matrix[from][to]++
|
||||
}
|
||||
}
|
||||
for i := range indicators {
|
||||
bit := uint8(1) << i
|
||||
inA, inB := r.ma&bit != 0, r.mb&bit != 0
|
||||
d := &c.Indicators[i]
|
||||
if inA {
|
||||
d.Base++
|
||||
}
|
||||
if inB {
|
||||
d.Target++
|
||||
}
|
||||
switch {
|
||||
case r.a == nil && inB:
|
||||
d.New++
|
||||
case r.b == nil && inA:
|
||||
d.Left++
|
||||
case r.common() && !inA && inB:
|
||||
d.Entered++
|
||||
case r.common() && inA && !inB:
|
||||
d.Exited++
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range c.Indicators {
|
||||
c.Indicators[i].Delta = c.Indicators[i].Target - c.Indicators[i].Base
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Groups served by Comparison.List.
|
||||
const (
|
||||
GroupNew = "new"
|
||||
GroupLeft = "left"
|
||||
GroupCommon = "common"
|
||||
GroupChanged = "changed"
|
||||
GroupSame = "same"
|
||||
GroupEntered = "entered"
|
||||
GroupExited = "exited"
|
||||
)
|
||||
|
||||
// CompareFilter narrows a group. Indicator is a list key of the indicator
|
||||
// table: for new and left the address is in it in its own run, for common,
|
||||
// changed and same in either run, for entered and exited it is required.
|
||||
// From and To, only together, keep the common addresses whose verdict was From
|
||||
// in the base and is To in the target.
|
||||
type CompareFilter struct {
|
||||
Indicator string
|
||||
From, To string
|
||||
}
|
||||
|
||||
// CompareList is a table of addresses of one group.
|
||||
type CompareList struct {
|
||||
Group string `json:"group"`
|
||||
Indicator string `json:"indicator,omitempty"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows [][]string `json:"rows"`
|
||||
}
|
||||
|
||||
// List builds the table of a group; an unknown group, indicator or verdict, or
|
||||
// a filter that does not fit the group, is an ErrUnknownList.
|
||||
func (c *Comparison) List(group string, f CompareFilter) (*CompareList, error) {
|
||||
switch group {
|
||||
case GroupNew, GroupLeft, GroupCommon, GroupChanged, GroupSame, GroupEntered, GroupExited:
|
||||
default:
|
||||
return nil, ErrUnknownList(group)
|
||||
}
|
||||
var bit uint8
|
||||
if f.Indicator != "" {
|
||||
i := indicatorIndex(f.Indicator)
|
||||
if i < 0 {
|
||||
return nil, ErrUnknownList("indicator " + f.Indicator)
|
||||
}
|
||||
bit = 1 << i
|
||||
}
|
||||
if bit == 0 && (group == GroupEntered || group == GroupExited) {
|
||||
return nil, ErrUnknownList(group + " without indicator")
|
||||
}
|
||||
if (f.From != "") != (f.To != "") {
|
||||
return nil, ErrUnknownList("from without to")
|
||||
}
|
||||
if f.From != "" {
|
||||
if verdictIndex(f.From) < 0 || verdictIndex(f.To) < 0 {
|
||||
return nil, ErrUnknownList("verdict " + f.From + " → " + f.To)
|
||||
}
|
||||
if group == GroupNew || group == GroupLeft {
|
||||
return nil, ErrUnknownList("from and to for " + group)
|
||||
}
|
||||
}
|
||||
|
||||
l := &CompareList{Group: group, Indicator: f.Indicator, Rows: [][]string{}}
|
||||
if group == GroupNew || group == GroupLeft {
|
||||
l.Columns = []string{"Адрес", "Подсеть", "Вердикт", "Egress", "Ingress", "Индикаторы"}
|
||||
} else {
|
||||
l.Columns = []string{"Адрес", "Подсеть", "Вердикт (A → B)", "Egress (A → B)", "Ingress (A → B)", "Что изменилось"}
|
||||
}
|
||||
for _, r := range c.rows {
|
||||
var ok bool
|
||||
switch group {
|
||||
case GroupNew:
|
||||
ok = r.a == nil && (bit == 0 || r.mb&bit != 0)
|
||||
case GroupLeft:
|
||||
ok = r.b == nil && (bit == 0 || r.ma&bit != 0)
|
||||
case GroupCommon:
|
||||
ok = r.common()
|
||||
case GroupChanged:
|
||||
ok = r.changed()
|
||||
case GroupSame:
|
||||
ok = r.common() && !r.changed()
|
||||
case GroupEntered:
|
||||
ok = r.common() && r.ma&bit == 0 && r.mb&bit != 0
|
||||
case GroupExited:
|
||||
ok = r.common() && r.ma&bit != 0 && r.mb&bit == 0
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if r.common() && bit != 0 && group != GroupEntered && group != GroupExited && (r.ma|r.mb)&bit == 0 {
|
||||
continue
|
||||
}
|
||||
if f.From != "" && (r.a.res.Verdict != f.From || r.b.res.Verdict != f.To) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case r.a == nil:
|
||||
l.Rows = append(l.Rows, []string{r.ip, r.b.subnet, r.b.res.Verdict, okOf(r.b.egress), okOf(r.b.ingress), indicatorsCell(r.mb)})
|
||||
case r.b == nil:
|
||||
l.Rows = append(l.Rows, []string{r.ip, r.a.subnet, r.a.res.Verdict, okOf(r.a.egress), okOf(r.a.ingress), indicatorsCell(r.ma)})
|
||||
default:
|
||||
l.Rows = append(l.Rows, []string{r.ip, r.b.subnet, arrow(r.a.res.Verdict, r.b.res.Verdict),
|
||||
arrow(okOf(r.a.egress), okOf(r.b.egress)), arrow(okOf(r.a.ingress), okOf(r.b.ingress)), c.changeText(r)})
|
||||
}
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func arrow(from, to string) string { return from + " → " + to }
|
||||
|
||||
// indicatorsCell names the indicators of a membership set, "—" for none.
|
||||
func indicatorsCell(m uint8) string {
|
||||
var names []string
|
||||
for i, ind := range indicators {
|
||||
if m&(1<<i) != 0 {
|
||||
names = append(names, ind.name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "—"
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// changeText says step by step what differs between the two runs for a common
|
||||
// address. An address with the same indicators is "без изменений" even if its
|
||||
// failed targets or sites differ.
|
||||
func (c *Comparison) changeText(r *cmpRow) string {
|
||||
if r.ma == r.mb {
|
||||
return "без изменений"
|
||||
}
|
||||
var steps []string
|
||||
if r.a.res.Verdict != r.b.res.Verdict {
|
||||
steps = append(steps, "вердикт "+arrow(r.a.res.Verdict, r.b.res.Verdict))
|
||||
}
|
||||
var in, out []string
|
||||
for i, ind := range indicators {
|
||||
if strings.HasPrefix(ind.key, "verdict_") {
|
||||
continue // the verdict step says it
|
||||
}
|
||||
bit := uint8(1) << i
|
||||
switch {
|
||||
case r.ma&bit == 0 && r.mb&bit != 0:
|
||||
in = append(in, ind.name)
|
||||
case r.ma&bit != 0 && r.mb&bit == 0:
|
||||
out = append(out, ind.name)
|
||||
}
|
||||
}
|
||||
if len(in) > 0 {
|
||||
steps = append(steps, "вошёл в: "+strings.Join(in, ", "))
|
||||
}
|
||||
if len(out) > 0 {
|
||||
steps = append(steps, "вышел из: "+strings.Join(out, ", "))
|
||||
}
|
||||
if added, removed := setDiff(r.a.https.failedTargets, r.b.https.failedTargets, sortedStrings); len(added)+len(removed) > 0 {
|
||||
steps = append(steps, "https: провалены цели "+signed(added, removed))
|
||||
}
|
||||
if added, removed := setDiff(r.a.ssh.sites, r.b.ssh.sites, c.target.sortSites); len(added)+len(removed) > 0 {
|
||||
steps = append(steps, "ssh: площадки "+signed(added, removed))
|
||||
}
|
||||
if was, now := r.a.https.validator, r.b.https.validator; was != "" && now != "" && was != now {
|
||||
steps = append(steps, "валидатор "+arrow(ShortValidator(was), ShortValidator(now)))
|
||||
}
|
||||
return strings.Join(steps, "; ")
|
||||
}
|
||||
|
||||
// setDiff is what is in now and not in was, and the reverse, each ordered by order.
|
||||
func setDiff(was, now []string, order func([]string) []string) (added, removed []string) {
|
||||
in := func(list []string) map[string]bool {
|
||||
m := make(map[string]bool, len(list))
|
||||
for _, s := range list {
|
||||
m[s] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
w, n := in(was), in(now)
|
||||
for s := range n {
|
||||
if !w[s] {
|
||||
added = append(added, s)
|
||||
}
|
||||
}
|
||||
for s := range w {
|
||||
if !n[s] {
|
||||
removed = append(removed, s)
|
||||
}
|
||||
}
|
||||
return order(added), order(removed)
|
||||
}
|
||||
|
||||
func sortedStrings(s []string) []string {
|
||||
sort.Strings(s)
|
||||
return s
|
||||
}
|
||||
|
||||
// signed writes a set difference as "+new1 +new2 −gone1".
|
||||
func signed(added, removed []string) string {
|
||||
parts := make([]string, 0, len(added)+len(removed))
|
||||
for _, s := range added {
|
||||
parts = append(parts, "+"+s)
|
||||
}
|
||||
for _, s := range removed {
|
||||
parts = append(parts, "−"+s)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// set stores the four checks of an address: https to a.test and b.test, ssh
|
||||
// from the two sites.
|
||||
func (f *fixture) set(reg int64, validator string, a, b, ssh1, ssh2 bool) {
|
||||
f.check(reg, eg, "https", "https://a.test", a, validator, "", false)
|
||||
f.check(reg, eg, "https", "https://b.test", b, validator, "", false)
|
||||
f.check(reg, s1, "ssh", "ip", ssh1, validator, "dial tcp: i/o timeout", false)
|
||||
f.check(reg, s2, "ssh", "ip", ssh2, validator, "dial tcp: i/o timeout", false)
|
||||
}
|
||||
|
||||
// comparedRuns builds the older and the newer run:
|
||||
//
|
||||
// 10.0.0.1 pass -> pass, nothing changed
|
||||
// 10.0.0.2 partial -> pass: https recovered on both targets, another validator
|
||||
// 10.0.0.3 pass -> partial: ssh fails from rxmsk
|
||||
// 10.0.0.20 partial -> partial: another https target fails, same indicators
|
||||
// 10.0.0.10 only in the old run (fail)
|
||||
// 10.0.0.14 only in the old run (pass), cancelled in the new one
|
||||
// 10.0.0.11 only in the new run (partial, https fails everywhere)
|
||||
// 10.0.0.12 only in the new run (pass)
|
||||
// 10.0.0.13 only in the new run (pass), cancelled in the old one
|
||||
func comparedRuns(t *testing.T) (base, target *Analysis) {
|
||||
t.Helper()
|
||||
fa, fb := &fixture{}, &fixture{}
|
||||
fa.addr(1, "10.0.0.1", db.ResultPass, 4)
|
||||
fa.set(1, "vkiplab-v1", true, true, true, true)
|
||||
fa.addr(2, "10.0.0.2", db.ResultPartial, 4)
|
||||
fa.set(2, "vkiplab-v2", false, false, true, true)
|
||||
fa.addr(3, "10.0.0.3", db.ResultPass, 4)
|
||||
fa.set(3, "vkiplab-v3", true, true, true, true)
|
||||
fa.addr(4, "10.0.0.20", db.ResultPartial, 4)
|
||||
fa.set(4, "vkiplab-v4", false, true, true, true)
|
||||
fa.addr(5, "10.0.0.10", db.ResultFail, 4)
|
||||
fa.set(5, "vkiplab-v5", false, false, false, false)
|
||||
fa.addr(6, "10.0.0.13", db.ResultCancelled, 4)
|
||||
fa.set(6, "vkiplab-v6", true, true, true, true)
|
||||
fa.addr(7, "10.0.0.14", db.ResultPass, 4)
|
||||
fa.set(7, "vkiplab-v7", true, true, true, true)
|
||||
|
||||
fb.addr(1, "10.0.0.1", db.ResultPass, 4)
|
||||
fb.set(1, "vkiplab-v1", true, true, true, true)
|
||||
fb.addr(2, "10.0.0.2", db.ResultPass, 4)
|
||||
fb.set(2, "vkiplab-v5", true, true, true, true)
|
||||
fb.addr(3, "10.0.0.3", db.ResultPartial, 4)
|
||||
fb.set(3, "vkiplab-v3", true, true, false, true)
|
||||
fb.addr(4, "10.0.0.20", db.ResultPartial, 4)
|
||||
fb.set(4, "vkiplab-v4", true, false, true, true)
|
||||
fb.addr(8, "10.0.0.11", db.ResultPartial, 4)
|
||||
fb.set(8, "vkiplab-v6", false, false, true, true)
|
||||
fb.addr(9, "10.0.0.12", db.ResultPass, 4)
|
||||
fb.set(9, "vkiplab-v6", true, true, true, true)
|
||||
fb.addr(10, "10.0.0.13", db.ResultPass, 4)
|
||||
fb.set(10, "vkiplab-v6", true, true, true, true)
|
||||
fb.addr(11, "10.0.0.14", db.ResultCancelled, 4)
|
||||
fb.set(11, "vkiplab-v7", true, true, true, true)
|
||||
return fa.compute(t, nil), fb.compute(t, nil)
|
||||
}
|
||||
|
||||
func ips(l *CompareList) []string {
|
||||
out := []string{}
|
||||
for _, r := range l.Rows {
|
||||
out = append(out, r[0])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mustList(t *testing.T, c *Comparison, group string, f CompareFilter) *CompareList {
|
||||
t.Helper()
|
||||
l, err := c.List(group, f)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %+v: %v", group, f, err)
|
||||
}
|
||||
for _, r := range l.Rows {
|
||||
if len(r) != len(l.Columns) {
|
||||
t.Errorf("%s: row %v for columns %v", group, r, l.Columns)
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func TestCompareGroupsAndIndicators(t *testing.T) {
|
||||
base, target := comparedRuns(t)
|
||||
c := Compare(base, target)
|
||||
|
||||
if want := (CompareGroups{New: 3, Left: 2, Common: 4, Changed: 2, Same: 2}); c.Groups != want {
|
||||
t.Errorf("groups = %+v, want %+v", c.Groups, want)
|
||||
}
|
||||
// Every address is in exactly one of new, left, common; common = changed + same.
|
||||
if c.Groups.New+c.Groups.Common != target.Report.Summary.Addresses || c.Groups.Left+c.Groups.Common != base.Report.Summary.Addresses ||
|
||||
c.Groups.Changed+c.Groups.Same != c.Groups.Common {
|
||||
t.Errorf("groups do not add up: %+v, runs %d and %d addresses", c.Groups, base.Report.Summary.Addresses, target.Report.Summary.Addresses)
|
||||
}
|
||||
if c.Cancelled != (CompareCancel{Base: 1, Target: 1}) {
|
||||
t.Errorf("cancelled = %+v", c.Cancelled)
|
||||
}
|
||||
if c.Runs.Base.Addresses != 6 || c.Runs.Target.Addresses != 7 || c.Runs.Base.ID != 7 {
|
||||
t.Errorf("runs = %+v", c.Runs)
|
||||
}
|
||||
|
||||
// The count of each indicator in each run is the number of its card.
|
||||
sa, sb := base.Report.Summary, target.Report.Summary
|
||||
cards := map[string][2]int{
|
||||
ListVerdictPass: {sa.Pass, sb.Pass}, ListVerdictPartial: {sa.Partial, sb.Partial}, ListVerdictFail: {sa.Fail, sb.Fail},
|
||||
ListEgressHTTPSAny: {sa.EgressHTTPSAny, sb.EgressHTTPSAny}, ListEgressHTTPSAll: {sa.EgressHTTPSAll, sb.EgressHTTPSAll},
|
||||
ListIngressSSHAny: {sa.IngressSSHAny, sb.IngressSSHAny}, ListIngressSSHAll: {sa.IngressSSHAll, sb.IngressSSHAll},
|
||||
}
|
||||
if len(c.Indicators) != len(cards) {
|
||||
t.Fatalf("%d indicators", len(c.Indicators))
|
||||
}
|
||||
for _, d := range c.Indicators {
|
||||
if want := cards[d.Key]; d.Base != want[0] || d.Target != want[1] {
|
||||
t.Errorf("%s: %d -> %d, summaries say %v", d.Key, d.Base, d.Target, want)
|
||||
}
|
||||
if d.Delta != d.Target-d.Base || d.Delta != d.New-d.Left+d.Entered-d.Exited {
|
||||
t.Errorf("%s: delta %d, new %d left %d entered %d exited %d", d.Key, d.Delta, d.New, d.Left, d.Entered, d.Exited)
|
||||
}
|
||||
}
|
||||
|
||||
want := map[string]IndicatorDiff{
|
||||
ListVerdictPass: {Base: 3, Target: 4, Delta: 1, New: 2, Left: 1, Entered: 1, Exited: 1},
|
||||
ListVerdictPartial: {Base: 2, Target: 3, Delta: 1, New: 1, Entered: 1, Exited: 1},
|
||||
ListVerdictFail: {Base: 1, Target: 0, Delta: -1, Left: 1},
|
||||
ListEgressHTTPSAny: {Base: 3, Target: 2, Delta: -1, New: 1, Left: 1, Exited: 1},
|
||||
ListEgressHTTPSAll: {Base: 2, Target: 1, Delta: -1, New: 1, Left: 1, Exited: 1},
|
||||
ListIngressSSHAny: {Base: 1, Target: 1, Left: 1, Entered: 1},
|
||||
ListIngressSSHAll: {Base: 1, Target: 0, Delta: -1, Left: 1},
|
||||
}
|
||||
for _, d := range c.Indicators {
|
||||
w := want[d.Key]
|
||||
w.Key, w.Name = d.Key, d.Name
|
||||
if d != w || d.Name == "" {
|
||||
t.Errorf("%s = %+v, want %+v", d.Key, d, w)
|
||||
}
|
||||
}
|
||||
|
||||
// Verdicts of the common addresses, and of the new and of the left ones.
|
||||
tr := c.Transitions
|
||||
if !reflect.DeepEqual(tr.Verdicts, []string{"pass", "partial", "fail"}) ||
|
||||
!reflect.DeepEqual(tr.Matrix, [][]int{{1, 1, 0}, {1, 1, 0}, {0, 0, 0}}) ||
|
||||
!reflect.DeepEqual(tr.New, []int{2, 1, 0}) || !reflect.DeepEqual(tr.Left, []int{1, 0, 1}) {
|
||||
t.Errorf("transitions = %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareLists(t *testing.T) {
|
||||
base, target := comparedRuns(t)
|
||||
c := Compare(base, target)
|
||||
|
||||
// New and left: the state in the run the address is in; numeric order.
|
||||
l := mustList(t, c, GroupNew, CompareFilter{})
|
||||
if want := []string{"Адрес", "Подсеть", "Вердикт", "Egress", "Ingress", "Индикаторы"}; !reflect.DeepEqual(l.Columns, want) {
|
||||
t.Errorf("new columns: %v", l.Columns)
|
||||
}
|
||||
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.11", "10.0.0.12", "10.0.0.13"}) {
|
||||
t.Errorf("new: %v", got)
|
||||
}
|
||||
if want := []string{"10.0.0.11", "10.0.0.0/24", "partial", "0 из 2", "2 из 2", "partial, Egress https: есть провалы, Egress https: все провалены"}; !reflect.DeepEqual(l.Rows[0], want) {
|
||||
t.Errorf("new row: %v", l.Rows[0])
|
||||
}
|
||||
if got := ips(mustList(t, c, GroupNew, CompareFilter{Indicator: ListVerdictPass})); !reflect.DeepEqual(got, []string{"10.0.0.12", "10.0.0.13"}) {
|
||||
t.Errorf("new, pass: %v", got)
|
||||
}
|
||||
if got := ips(mustList(t, c, GroupNew, CompareFilter{Indicator: ListEgressHTTPSAll})); !reflect.DeepEqual(got, []string{"10.0.0.11"}) {
|
||||
t.Errorf("new, https all: %v", got)
|
||||
}
|
||||
l = mustList(t, c, GroupLeft, CompareFilter{})
|
||||
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.10", "10.0.0.14"}) {
|
||||
t.Errorf("left: %v", got)
|
||||
}
|
||||
if want := "fail"; l.Rows[0][2] != want || l.Rows[0][5] != "fail, Egress https: есть провалы, Egress https: все провалены, Ingress ssh: есть провалы, Ingress ssh: все провалены" {
|
||||
t.Errorf("left row: %v", l.Rows[0])
|
||||
}
|
||||
if got := ips(mustList(t, c, GroupLeft, CompareFilter{Indicator: ListVerdictPass})); !reflect.DeepEqual(got, []string{"10.0.0.14"}) {
|
||||
t.Errorf("left, pass: %v", got)
|
||||
}
|
||||
|
||||
// Changed: what changed, step by step.
|
||||
l = mustList(t, c, GroupChanged, CompareFilter{})
|
||||
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.2", "10.0.0.3"}) {
|
||||
t.Fatalf("changed: %v", got)
|
||||
}
|
||||
if l.Columns[2] != "Вердикт (A → B)" || l.Columns[5] != "Что изменилось" {
|
||||
t.Errorf("changed columns: %v", l.Columns)
|
||||
}
|
||||
if want := []string{"10.0.0.2", "10.0.0.0/24", "partial → pass", "0 из 2 → 2 из 2", "2 из 2 → 2 из 2",
|
||||
"вердикт partial → pass; вышел из: Egress https: есть провалы, Egress https: все провалены; https: провалены цели −a.test −b.test; валидатор v2 → v5"}; !reflect.DeepEqual(l.Rows[0], want) {
|
||||
t.Errorf("changed row 1: %v", l.Rows[0])
|
||||
}
|
||||
if want := []string{"10.0.0.3", "10.0.0.0/24", "pass → partial", "2 из 2 → 2 из 2", "2 из 2 → 1 из 2",
|
||||
"вердикт pass → partial; вошёл в: Ingress ssh: есть провалы; ssh: площадки +rxmsk"}; !reflect.DeepEqual(l.Rows[1], want) {
|
||||
t.Errorf("changed row 2: %v", l.Rows[1])
|
||||
}
|
||||
// A filter by indicator keeps the addresses that are in it in either run.
|
||||
if got := ips(mustList(t, c, GroupChanged, CompareFilter{Indicator: ListIngressSSHAny})); !reflect.DeepEqual(got, []string{"10.0.0.3"}) {
|
||||
t.Errorf("changed, ssh any: %v", got)
|
||||
}
|
||||
if got := ips(mustList(t, c, GroupChanged, CompareFilter{Indicator: ListEgressHTTPSAll})); !reflect.DeepEqual(got, []string{"10.0.0.2"}) {
|
||||
t.Errorf("changed, https all: %v", got)
|
||||
}
|
||||
|
||||
// Same: another failed target does not change the indicators; numeric order (20 after 1).
|
||||
l = mustList(t, c, GroupSame, CompareFilter{})
|
||||
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.1", "10.0.0.20"}) {
|
||||
t.Fatalf("same: %v", got)
|
||||
}
|
||||
if want := []string{"10.0.0.20", "10.0.0.0/24", "partial → partial", "1 из 2 → 1 из 2", "2 из 2 → 2 из 2", "без изменений"}; !reflect.DeepEqual(l.Rows[1], want) {
|
||||
t.Errorf("same row: %v", l.Rows[1])
|
||||
}
|
||||
if got := ips(mustList(t, c, GroupSame, CompareFilter{Indicator: ListEgressHTTPSAny})); !reflect.DeepEqual(got, []string{"10.0.0.20"}) {
|
||||
t.Errorf("same, https any: %v", got)
|
||||
}
|
||||
|
||||
// Common is changed + same, in address order.
|
||||
l = mustList(t, c, GroupCommon, CompareFilter{})
|
||||
if got := ips(l); !reflect.DeepEqual(got, []string{"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.20"}) || l.Rows[3][5] != "без изменений" {
|
||||
t.Errorf("common: %v", l.Rows)
|
||||
}
|
||||
|
||||
// Entered and exited need an indicator.
|
||||
for _, x := range []struct {
|
||||
group, ind string
|
||||
want []string
|
||||
}{
|
||||
{GroupEntered, ListVerdictPass, []string{"10.0.0.2"}},
|
||||
{GroupExited, ListVerdictPass, []string{"10.0.0.3"}},
|
||||
{GroupEntered, ListVerdictPartial, []string{"10.0.0.3"}},
|
||||
{GroupEntered, ListIngressSSHAny, []string{"10.0.0.3"}},
|
||||
{GroupExited, ListEgressHTTPSAll, []string{"10.0.0.2"}},
|
||||
{GroupExited, ListEgressHTTPSAny, []string{"10.0.0.2"}},
|
||||
{GroupEntered, ListEgressHTTPSAny, []string{}},
|
||||
{GroupEntered, ListVerdictFail, []string{}},
|
||||
} {
|
||||
got := ips(mustList(t, c, x.group, CompareFilter{Indicator: x.ind}))
|
||||
if !reflect.DeepEqual(got, x.want) {
|
||||
t.Errorf("%s %s: %v, want %v", x.group, x.ind, got, x.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A cell of the transition matrix: the verdict in the old and in the new run.
|
||||
for _, x := range []struct {
|
||||
group, from, to string
|
||||
want []string
|
||||
}{
|
||||
{GroupCommon, "partial", "pass", []string{"10.0.0.2"}},
|
||||
{GroupCommon, "pass", "partial", []string{"10.0.0.3"}},
|
||||
{GroupCommon, "pass", "pass", []string{"10.0.0.1"}},
|
||||
{GroupCommon, "partial", "partial", []string{"10.0.0.20"}},
|
||||
{GroupSame, "partial", "partial", []string{"10.0.0.20"}},
|
||||
{GroupChanged, "pass", "pass", []string{}},
|
||||
{GroupCommon, "fail", "pass", []string{}},
|
||||
} {
|
||||
got := ips(mustList(t, c, x.group, CompareFilter{From: x.from, To: x.to}))
|
||||
if !reflect.DeepEqual(got, x.want) {
|
||||
t.Errorf("%s %s -> %s: %v, want %v", x.group, x.from, x.to, got, x.want)
|
||||
}
|
||||
}
|
||||
// The cells of the matrix and the lists add up.
|
||||
n := 0
|
||||
for _, from := range verdicts {
|
||||
for _, to := range verdicts {
|
||||
n += len(mustList(t, c, GroupCommon, CompareFilter{From: from, To: to}).Rows)
|
||||
}
|
||||
}
|
||||
if n != c.Groups.Common {
|
||||
t.Errorf("matrix cells hold %d addresses, %d are common", n, c.Groups.Common)
|
||||
}
|
||||
// The size of every list equals its number in the report.
|
||||
for _, d := range c.Indicators {
|
||||
for group, want := range map[string]int{GroupNew: d.New, GroupLeft: d.Left, GroupEntered: d.Entered, GroupExited: d.Exited} {
|
||||
if got := len(mustList(t, c, group, CompareFilter{Indicator: d.Key}).Rows); got != want {
|
||||
t.Errorf("%s %s: %d rows, report says %d", group, d.Key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
for group, want := range map[string]int{GroupNew: c.Groups.New, GroupLeft: c.Groups.Left, GroupCommon: c.Groups.Common, GroupChanged: c.Groups.Changed, GroupSame: c.Groups.Same} {
|
||||
if got := len(mustList(t, c, group, CompareFilter{}).Rows); got != want {
|
||||
t.Errorf("%s: %d rows, report says %d", group, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareListErrors(t *testing.T) {
|
||||
base, target := comparedRuns(t)
|
||||
c := Compare(base, target)
|
||||
for name, x := range map[string]struct {
|
||||
group string
|
||||
f CompareFilter
|
||||
}{
|
||||
"unknown group": {"nonsense", CompareFilter{}},
|
||||
"unknown indicator": {GroupNew, CompareFilter{Indicator: "nonsense"}},
|
||||
"entered needs one": {GroupEntered, CompareFilter{}},
|
||||
"exited needs one": {GroupExited, CompareFilter{}},
|
||||
"from without to": {GroupCommon, CompareFilter{From: "pass"}},
|
||||
"to without from": {GroupCommon, CompareFilter{To: "pass"}},
|
||||
"unknown verdict": {GroupCommon, CompareFilter{From: "pass", To: "cancelled"}},
|
||||
"verdicts of new addrs": {GroupNew, CompareFilter{From: "pass", To: "pass"}},
|
||||
"verdicts of left addrs": {GroupLeft, CompareFilter{From: "pass", To: "pass"}},
|
||||
} {
|
||||
l, err := c.List(x.group, x.f)
|
||||
if _, ok := err.(ErrUnknownList); !ok || l != nil {
|
||||
t.Errorf("%s: %v %v", name, l, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A run compared with itself: nothing new, nothing left, nothing changed, and
|
||||
// the empty lists are empty tables, not nil.
|
||||
func TestCompareWithItself(t *testing.T) {
|
||||
base, _ := comparedRuns(t)
|
||||
c := Compare(base, base)
|
||||
if want := (CompareGroups{Common: 6, Same: 6}); c.Groups != want {
|
||||
t.Errorf("groups = %+v", c.Groups)
|
||||
}
|
||||
for _, d := range c.Indicators {
|
||||
if d.Delta != 0 || d.New != 0 || d.Left != 0 || d.Entered != 0 || d.Exited != 0 || d.Base != d.Target {
|
||||
t.Errorf("%+v", d)
|
||||
}
|
||||
}
|
||||
for _, group := range []string{GroupNew, GroupLeft, GroupChanged} {
|
||||
if l := mustList(t, c, group, CompareFilter{}); l.Rows == nil || len(l.Rows) != 0 {
|
||||
t.Errorf("%s: %#v", group, l.Rows)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(c.Transitions.Matrix, [][]int{{3, 0, 0}, {0, 2, 0}, {0, 0, 1}}) {
|
||||
t.Errorf("matrix = %v", c.Transitions.Matrix)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// List kinds served by Analysis.List.
|
||||
const (
|
||||
ListEgressHTTPSAny = "egress_https_any"
|
||||
ListEgressHTTPSAll = "egress_https_all"
|
||||
ListIngressSSHAny = "ingress_ssh_any"
|
||||
ListIngressSSHAll = "ingress_ssh_all"
|
||||
ListError = "error"
|
||||
// Addresses by the verdict the system gave them at aggregation.
|
||||
ListVerdictPass = "verdict_" + db.ResultPass
|
||||
ListVerdictPartial = "verdict_" + db.ResultPartial
|
||||
ListVerdictFail = "verdict_" + db.ResultFail
|
||||
)
|
||||
|
||||
// List is a table of addresses behind one indicator or one error class.
|
||||
type List struct {
|
||||
Kind string `json:"kind"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows [][]string `json:"rows"`
|
||||
}
|
||||
|
||||
// ErrUnknownList is returned for a list kind that does not exist.
|
||||
type ErrUnknownList string
|
||||
|
||||
func (e ErrUnknownList) Error() string { return fmt.Sprintf("unknown list %q", string(e)) }
|
||||
|
||||
// List builds the table for a kind; class is only used with ListError.
|
||||
func (an *Analysis) List(kind, class string) (*List, error) {
|
||||
l := &List{Kind: kind, Class: class, Rows: [][]string{}}
|
||||
switch kind {
|
||||
case ListEgressHTTPSAny, ListEgressHTTPSAll:
|
||||
l.Columns = []string{"Адрес", "Подсеть", "Валидатор", "https-проверок", "Проваленные цели"}
|
||||
if kind == ListEgressHTTPSAny {
|
||||
l.Columns[3] = "Провалено https"
|
||||
}
|
||||
for _, a := range an.sorted() {
|
||||
if a.https.n == 0 || a.https.ok == a.https.n || (kind == ListEgressHTTPSAll && a.https.ok != 0) {
|
||||
continue
|
||||
}
|
||||
targets := append([]string(nil), a.https.failedTargets...)
|
||||
sort.Strings(targets)
|
||||
count := fmt.Sprint(a.https.n)
|
||||
if kind == ListEgressHTTPSAny {
|
||||
count = fmt.Sprintf("%d из %d", a.https.n-a.https.ok, a.https.n)
|
||||
}
|
||||
l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, ShortValidator(a.https.validator), count, strings.Join(targets, ", ")})
|
||||
}
|
||||
case ListIngressSSHAny, ListIngressSSHAll:
|
||||
l.Columns = []string{"Адрес", "Подсеть", "Провалено ssh", "Площадки с провалом", "Ошибка"}
|
||||
if kind == ListIngressSSHAll {
|
||||
l.Columns = []string{"Адрес", "Подсеть", "Площадки с провалом ssh", "Ошибка"}
|
||||
}
|
||||
for _, a := range an.sorted() {
|
||||
if a.ssh.n == 0 || a.ssh.ok == a.ssh.n || (kind == ListIngressSSHAll && a.ssh.ok != 0) {
|
||||
continue
|
||||
}
|
||||
sites := an.sortSites(a.ssh.sites)
|
||||
errs := make([]string, 0, len(a.ssh.errs))
|
||||
for e := range a.ssh.errs {
|
||||
errs = append(errs, e)
|
||||
}
|
||||
sort.Strings(errs)
|
||||
if kind == ListIngressSSHAny {
|
||||
l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, fmt.Sprintf("%d из %d", len(a.ssh.sites), a.ssh.n), strings.Join(sites, ", "), strings.Join(errs, ", ")})
|
||||
} else {
|
||||
l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, strings.Join(sites, ", "), strings.Join(errs, ", ")})
|
||||
}
|
||||
}
|
||||
case ListVerdictPass, ListVerdictPartial, ListVerdictFail:
|
||||
verdict := strings.TrimPrefix(kind, "verdict_")
|
||||
l.Columns = []string{"Адрес", "Подсеть", "Валидатор", "Egress", "Ingress", "Проверок в цикле"}
|
||||
if kind == ListVerdictPartial {
|
||||
l.Columns = append(l.Columns, "Причина")
|
||||
}
|
||||
for _, a := range an.sorted() {
|
||||
if a.res.Verdict != verdict {
|
||||
continue
|
||||
}
|
||||
row := []string{a.res.IPAddress, a.subnet, orDash(ShortValidator(a.https.validator)), okOf(a.egress), okOf(a.ingress), a.checksCell()}
|
||||
if kind == ListVerdictPartial {
|
||||
row = append(row, reasonName(a.egress.ok < a.egress.n, a.ingress.ok < a.ingress.n, a.incomplete()))
|
||||
}
|
||||
l.Rows = append(l.Rows, row)
|
||||
}
|
||||
case ListError:
|
||||
if class == "" {
|
||||
return nil, ErrUnknownList("error without class")
|
||||
}
|
||||
l.Columns = []string{"Адрес", "Подсеть", "Площадка", "Валидатор", "Вердикт адреса", "Статус проверки"}
|
||||
for _, a := range an.sorted() {
|
||||
fs := append([]failedIngress(nil), a.failedIngress...)
|
||||
sort.SliceStable(fs, func(i, j int) bool { return an.siteIndex(fs[i].site) < an.siteIndex(fs[j].site) })
|
||||
for _, f := range fs {
|
||||
if f.class != class {
|
||||
continue
|
||||
}
|
||||
status := "провал, в вердикте"
|
||||
if f.late {
|
||||
status = "провал, после вердикта"
|
||||
}
|
||||
l.Rows = append(l.Rows, []string{a.res.IPAddress, a.subnet, f.site, ShortValidator(f.validator), a.res.Verdict, status})
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, ErrUnknownList(kind)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// checksCell is "stored из expected", or just stored when expected is unknown.
|
||||
func (a *addr) checksCell() string {
|
||||
if a.res.ExpectedChecks < 0 {
|
||||
return fmt.Sprint(a.stored)
|
||||
}
|
||||
return fmt.Sprintf("%d из %d", a.stored, a.res.ExpectedChecks)
|
||||
}
|
||||
|
||||
// okOf is "successful из all" of one level, "—" when it has no checks.
|
||||
func okOf(t typeStat) string {
|
||||
if t.n == 0 {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%d из %d", t.ok, t.n)
|
||||
}
|
||||
|
||||
func orDash(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sorted returns the non-cancelled addresses in numeric address order.
|
||||
func (an *Analysis) sorted() []*addr {
|
||||
out := make([]*addr, 0, len(an.addrs))
|
||||
for _, a := range an.addrs {
|
||||
if a.res.Verdict != db.ResultCancelled {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return lessIP(out[i].res.IPAddress, out[j].res.IPAddress) })
|
||||
return out
|
||||
}
|
||||
|
||||
// lessIP orders addresses numerically; text that is not an address by its text.
|
||||
func lessIP(a, b string) bool {
|
||||
x, errX := netip.ParseAddr(a)
|
||||
y, errY := netip.ParseAddr(b)
|
||||
if errX != nil || errY != nil {
|
||||
return a < b
|
||||
}
|
||||
return x.Less(y)
|
||||
}
|
||||
|
||||
func (an *Analysis) siteIndex(site string) int { return siteIndexOf(an.siteNames, site) }
|
||||
|
||||
func (an *Analysis) sortSites(sites []string) []string {
|
||||
out := append([]string(nil), sites...)
|
||||
sort.SliceStable(out, func(i, j int) bool { return an.siteIndex(out[i]) < an.siteIndex(out[j]) })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
)
|
||||
|
||||
// Load reads a finished run from the database and computes its analysis.
|
||||
func Load(ctx context.Context, d *db.DB, runID int64) (*Analysis, error) {
|
||||
run, err := d.GetRun(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results, err := d.ListRunResults(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subnets, err := d.ListSubnets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sites, err := d.ListSites(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := map[int]string{}
|
||||
for _, s := range sites {
|
||||
names[s.Index] = s.SiteID
|
||||
}
|
||||
rechecked, err := d.CountRecheckedInRun(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Compute(Input{
|
||||
Run: *run, Results: results, Subnets: subnets, SiteNames: names, Rechecked: rechecked,
|
||||
Each: func(fn func(db.RunCheck)) error { return d.EachRunCheck(ctx, runID, fn) },
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import (
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
// Token, when non-empty, is sent as "Authorization: Bearer <Token>" on
|
||||
// every request to the Control API.
|
||||
Token string
|
||||
}
|
||||
|
||||
func New(baseURL string, timeout time.Duration) *Client {
|
||||
@@ -42,6 +45,9 @@ func (c *Client) Do(ctx context.Context, method, path string, body, out interfac
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDoSetsBearerOnlyWhenTokenSet(t *testing.T) {
|
||||
var got []string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = append(got, r.Header.Get("Authorization"))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := New(ts.URL, 5*time.Second)
|
||||
if _, err := c.Do(context.Background(), http.MethodGet, "/x", nil, nil); err != nil {
|
||||
t.Fatalf("do without token: %v", err)
|
||||
}
|
||||
c.Token = "secret-agent-token"
|
||||
if _, err := c.Do(context.Background(), http.MethodPost, "/x", map[string]string{"a": "b"}, nil); err != nil {
|
||||
t.Fatalf("do with token: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "" || got[1] != "Bearer secret-agent-token" {
|
||||
t.Fatalf("Authorization headers = %q, want [\"\" \"Bearer secret-agent-token\"]", got)
|
||||
}
|
||||
}
|
||||
+110
-7
@@ -25,6 +25,15 @@ type ControlAPI struct {
|
||||
Targets map[string][]string `yaml:"targets"`
|
||||
Inbound InboundConfig `yaml:"inbound_checks"`
|
||||
IPAddresses []string `yaml:"ip_addresses"`
|
||||
Auth ControlAPIAuth `yaml:"auth"`
|
||||
}
|
||||
|
||||
// ControlAPIAuth names the environment variables control-api reads its two
|
||||
// static bearer tokens from. Values are never stored in the config. An unset
|
||||
// (empty) token leaves that access level open, with a startup warning.
|
||||
type ControlAPIAuth struct {
|
||||
AdminTokenEnv string `yaml:"admin_token_env"` // default CONTROL_API_ADMIN_TOKEN — protects /api/v1/admin/*
|
||||
AgentTokenEnv string `yaml:"agent_token_env"` // default CONTROL_API_AGENT_TOKEN — protects agent/prober write calls
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -60,6 +69,17 @@ type OpenStackConfig struct {
|
||||
UsernameEnv string `yaml:"username_env"` // default OS_USERNAME — used when auth_method: password
|
||||
UserDomainNameEnv string `yaml:"user_domain_name_env"` // default OS_USER_DOMAIN_NAME
|
||||
PasswordEnv string `yaml:"password_env"` // default OS_PASSWORD
|
||||
|
||||
// ListPageSize is how many floating IPs one Neutron list request asks
|
||||
// for (the scan reads the project page by page). Default 200.
|
||||
ListPageSize int `yaml:"list_page_size"`
|
||||
// RequestTimeoutSeconds bounds every single HTTP request to Keystone and
|
||||
// Neutron. Default 60.
|
||||
RequestTimeoutSeconds int `yaml:"request_timeout_seconds"`
|
||||
// ListPageRetries is how many times one failed page of the listing is
|
||||
// retried (backoff 1s,2s,4s,...) on network errors, 5xx and 429. Default
|
||||
// 5; a negative value disables retries.
|
||||
ListPageRetries int `yaml:"list_page_retries"`
|
||||
}
|
||||
|
||||
type OrchestratorConfig struct {
|
||||
@@ -84,6 +104,9 @@ type OrchestratorConfig struct {
|
||||
// POST /api/v1/admin/ips/scan or the dashboard's "Scan Floating IPs"
|
||||
// button either way.
|
||||
FIPScanIntervalSeconds int `yaml:"fip_scan_interval_seconds"`
|
||||
// FIPScanTimeoutSeconds is the overall deadline of one background
|
||||
// floating-IP scan (clear + paged read + enqueue). Default 1800.
|
||||
FIPScanTimeoutSeconds int `yaml:"fip_scan_timeout_seconds"`
|
||||
}
|
||||
|
||||
type AggregationConfig struct {
|
||||
@@ -155,6 +178,21 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
|
||||
if c.OpenStack.PasswordEnv == "" {
|
||||
c.OpenStack.PasswordEnv = "OS_PASSWORD"
|
||||
}
|
||||
if c.OpenStack.ListPageSize == 0 {
|
||||
c.OpenStack.ListPageSize = 200
|
||||
}
|
||||
if c.OpenStack.RequestTimeoutSeconds == 0 {
|
||||
c.OpenStack.RequestTimeoutSeconds = 60
|
||||
}
|
||||
if c.OpenStack.ListPageRetries == 0 {
|
||||
c.OpenStack.ListPageRetries = 5
|
||||
}
|
||||
if c.Auth.AdminTokenEnv == "" {
|
||||
c.Auth.AdminTokenEnv = "CONTROL_API_ADMIN_TOKEN"
|
||||
}
|
||||
if c.Auth.AgentTokenEnv == "" {
|
||||
c.Auth.AgentTokenEnv = "CONTROL_API_AGENT_TOKEN"
|
||||
}
|
||||
if c.Orchestrator.PollIntervalSeconds == 0 {
|
||||
c.Orchestrator.PollIntervalSeconds = 5
|
||||
}
|
||||
@@ -176,30 +214,49 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
|
||||
if c.Orchestrator.HeartbeatTimeoutSeconds == 0 {
|
||||
c.Orchestrator.HeartbeatTimeoutSeconds = 30
|
||||
}
|
||||
if c.Orchestrator.FIPScanTimeoutSeconds == 0 {
|
||||
c.Orchestrator.FIPScanTimeoutSeconds = 1800
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ---- validator-agent ----
|
||||
|
||||
type ValidatorAgent struct {
|
||||
ValidatorID string `yaml:"validator_id"`
|
||||
ControlAPIURL string `yaml:"control_api_url"`
|
||||
ValidatorID string `yaml:"validator_id"`
|
||||
ControlAPIURL string `yaml:"control_api_url"`
|
||||
// ControlAPITokenEnv is the name of the env var holding the agent bearer
|
||||
// token (default CONTROL_API_AGENT_TOKEN). Empty value = no token sent.
|
||||
ControlAPITokenEnv string `yaml:"control_api_token_env"`
|
||||
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||
SelfCheck SelfCheckCfg `yaml:"self_check"`
|
||||
Checks AgentChecks `yaml:"checks"`
|
||||
}
|
||||
|
||||
// Self-check methods accepted in SelfCheckCfg.Methods.
|
||||
const (
|
||||
SelfCheckIPEcho = "ip_echo"
|
||||
SelfCheckControlAPI = "control_api"
|
||||
)
|
||||
|
||||
// SelfCheckCfg configures how the agent confirms its egress actually flows
|
||||
// through the newly assigned floating IP. This must query a resource
|
||||
// genuinely outside the cloud project: OpenStack only applies floating-IP
|
||||
// SNAT to traffic leaving via the external/provider network, so any
|
||||
// in-project resource (including control-api, if it's reachable over the
|
||||
// project's internal network) would see the validator's private address
|
||||
// instead — a false negative that never changes. IPEchoURLs are tried in
|
||||
// order (falling through to the next on error/timeout, not on a genuine
|
||||
// mismatch) until one returns a parseable IP.
|
||||
// instead — a false negative that never changes. The control_api method is
|
||||
// therefore only valid when control-api is reached over the external network
|
||||
// (hosted outside the cloud); then it sees the floating IP as the source.
|
||||
//
|
||||
// Methods are tried in priority order and the self-check passes as soon as
|
||||
// any one confirms the address; the next method is tried both when one gives
|
||||
// no answer and when it reports a different address. IPEchoURLs are tried in
|
||||
// order within ip_echo (falling through to the next on error/timeout, not on
|
||||
// a genuine mismatch) until one returns a parseable IP.
|
||||
type SelfCheckCfg struct {
|
||||
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||
Methods []string `yaml:"methods"`
|
||||
IPEchoURLs []string `yaml:"ip_echo_urls"`
|
||||
}
|
||||
|
||||
@@ -223,9 +280,20 @@ func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
|
||||
if c.PollIntervalSeconds == 0 {
|
||||
c.PollIntervalSeconds = 5
|
||||
}
|
||||
if c.ControlAPITokenEnv == "" {
|
||||
c.ControlAPITokenEnv = "CONTROL_API_AGENT_TOKEN"
|
||||
}
|
||||
if c.SelfCheck.TimeoutSeconds == 0 {
|
||||
c.SelfCheck.TimeoutSeconds = 10
|
||||
}
|
||||
if len(c.SelfCheck.Methods) == 0 {
|
||||
c.SelfCheck.Methods = []string{SelfCheckIPEcho}
|
||||
}
|
||||
for _, m := range c.SelfCheck.Methods {
|
||||
if m != SelfCheckIPEcho && m != SelfCheckControlAPI {
|
||||
return nil, fmt.Errorf("self_check.methods: unknown method %q (allowed: %s, %s)", m, SelfCheckIPEcho, SelfCheckControlAPI)
|
||||
}
|
||||
}
|
||||
if len(c.SelfCheck.IPEchoURLs) == 0 {
|
||||
c.SelfCheck.IPEchoURLs = []string{"https://api.ipify.org", "https://ifconfig.me/ip"}
|
||||
}
|
||||
@@ -253,8 +321,11 @@ func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
|
||||
// ---- prober ----
|
||||
|
||||
type Prober struct {
|
||||
SiteID string `yaml:"site_id"`
|
||||
ControlAPIURL string `yaml:"control_api_url"`
|
||||
SiteID string `yaml:"site_id"`
|
||||
ControlAPIURL string `yaml:"control_api_url"`
|
||||
// ControlAPITokenEnv is the name of the env var holding the agent bearer
|
||||
// token (default CONTROL_API_AGENT_TOKEN). Empty value = no token sent.
|
||||
ControlAPITokenEnv string `yaml:"control_api_token_env"`
|
||||
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||
Checks ProberChecks `yaml:"checks"`
|
||||
}
|
||||
@@ -273,6 +344,9 @@ func LoadProber(path string) (*Prober, error) {
|
||||
if c.PollIntervalSeconds == 0 {
|
||||
c.PollIntervalSeconds = 5
|
||||
}
|
||||
if c.ControlAPITokenEnv == "" {
|
||||
c.ControlAPITokenEnv = "CONTROL_API_AGENT_TOKEN"
|
||||
}
|
||||
if c.Checks.TCPTimeoutSeconds == 0 {
|
||||
c.Checks.TCPTimeoutSeconds = 5
|
||||
}
|
||||
@@ -300,11 +374,25 @@ type AdminDashboard struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
ControlAPI DashboardControlAPIConfig `yaml:"control_api"`
|
||||
Overview DashboardOverviewConfig `yaml:"overview"`
|
||||
Auth DashboardAuthConfig `yaml:"auth"`
|
||||
}
|
||||
|
||||
// DashboardAuthConfig names the env vars holding the single administrator's
|
||||
// login, password and the session-cookie HMAC key. If username or password
|
||||
// is empty at runtime, login is not required (with a startup warning).
|
||||
type DashboardAuthConfig struct {
|
||||
UsernameEnv string `yaml:"username_env"` // default ADMIN_DASHBOARD_USERNAME
|
||||
PasswordEnv string `yaml:"password_env"` // default ADMIN_DASHBOARD_PASSWORD
|
||||
SessionSecretEnv string `yaml:"session_secret_env"` // default ADMIN_DASHBOARD_SESSION_SECRET
|
||||
SessionTTLMinutes int `yaml:"session_ttl_minutes"` // default 480
|
||||
}
|
||||
|
||||
type DashboardControlAPIConfig struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||
// TokenEnv is the name of the env var holding control-api's admin bearer
|
||||
// token (default ADMIN_DASHBOARD_CONTROL_API_TOKEN).
|
||||
TokenEnv string `yaml:"token_env"`
|
||||
}
|
||||
|
||||
// DashboardOverviewConfig configures the overview page's "текущая
|
||||
@@ -331,6 +419,21 @@ func LoadAdminDashboard(path string) (*AdminDashboard, error) {
|
||||
if c.ControlAPI.TimeoutSeconds == 0 {
|
||||
c.ControlAPI.TimeoutSeconds = 10
|
||||
}
|
||||
if c.ControlAPI.TokenEnv == "" {
|
||||
c.ControlAPI.TokenEnv = "ADMIN_DASHBOARD_CONTROL_API_TOKEN"
|
||||
}
|
||||
if c.Auth.UsernameEnv == "" {
|
||||
c.Auth.UsernameEnv = "ADMIN_DASHBOARD_USERNAME"
|
||||
}
|
||||
if c.Auth.PasswordEnv == "" {
|
||||
c.Auth.PasswordEnv = "ADMIN_DASHBOARD_PASSWORD"
|
||||
}
|
||||
if c.Auth.SessionSecretEnv == "" {
|
||||
c.Auth.SessionSecretEnv = "ADMIN_DASHBOARD_SESSION_SECRET"
|
||||
}
|
||||
if c.Auth.SessionTTLMinutes == 0 {
|
||||
c.Auth.SessionTTLMinutes = 480
|
||||
}
|
||||
if c.Overview.LastCompletedCount == 0 {
|
||||
c.Overview.LastCompletedCount = 20
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadControlAPIScanDefaults(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "c.yaml")
|
||||
if err := os.WriteFile(path, []byte("server:\n listen_addr: \":8080\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := LoadControlAPI(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if c.OpenStack.ListPageSize != 200 || c.OpenStack.RequestTimeoutSeconds != 60 ||
|
||||
c.OpenStack.ListPageRetries != 5 || c.Orchestrator.FIPScanTimeoutSeconds != 1800 {
|
||||
t.Fatalf("unexpected defaults: openstack=%+v orchestrator=%+v", c.OpenStack, c.Orchestrator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadControlAPIScanOverrides(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "c.yaml")
|
||||
yaml := "openstack:\n list_page_size: 50\n request_timeout_seconds: 10\n list_page_retries: -1\n" +
|
||||
"orchestrator:\n fip_scan_timeout_seconds: 99\n"
|
||||
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := LoadControlAPI(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if c.OpenStack.ListPageSize != 50 || c.OpenStack.RequestTimeoutSeconds != 10 ||
|
||||
c.OpenStack.ListPageRetries != -1 || c.Orchestrator.FIPScanTimeoutSeconds != 99 {
|
||||
t.Fatalf("overrides lost: openstack=%+v orchestrator=%+v", c.OpenStack, c.Orchestrator)
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped example must load and carry the scan settings, and the rxprod
|
||||
// copy must stay byte-identical to it.
|
||||
func TestControlAPIExampleConfigs(t *testing.T) {
|
||||
c, err := LoadControlAPI("../../configs/control-api.example.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("load example: %v", err)
|
||||
}
|
||||
if c.OpenStack.ListPageSize != 200 || c.Orchestrator.FIPScanTimeoutSeconds != 1800 {
|
||||
t.Fatalf("example scan settings: %+v %+v", c.OpenStack, c.Orchestrator)
|
||||
}
|
||||
a, err := os.ReadFile("../../configs/control-api.example.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile("../../rxprod-compose/sources/control-api.example.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Fatalf("rxprod-compose/sources/control-api.example.yaml differs from configs/control-api.example.yaml")
|
||||
}
|
||||
if _, err := LoadControlAPI("../../deploy/docker/control-api/control-api.docker.example.yaml"); err != nil {
|
||||
t.Fatalf("load docker example: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAgentConfig(t *testing.T, selfCheck string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "agent.yaml")
|
||||
body := "validator_id: v1\ncontrol_api_url: http://x\nself_check:\n" + selfCheck
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadValidatorAgentSelfCheckMethods(t *testing.T) {
|
||||
// No methods key: the historical behaviour, ip_echo only.
|
||||
c, err := LoadValidatorAgent(writeAgentConfig(t, " timeout_seconds: 5\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(c.SelfCheck.Methods) != 1 || c.SelfCheck.Methods[0] != SelfCheckIPEcho {
|
||||
t.Fatalf("default methods = %v, want [ip_echo]", c.SelfCheck.Methods)
|
||||
}
|
||||
|
||||
// Order is the priority and must be kept.
|
||||
c, err = LoadValidatorAgent(writeAgentConfig(t, " methods: [control_api, ip_echo]\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got := c.SelfCheck.Methods; len(got) != 2 || got[0] != SelfCheckControlAPI || got[1] != SelfCheckIPEcho {
|
||||
t.Fatalf("methods = %v, want [control_api ip_echo]", got)
|
||||
}
|
||||
|
||||
// An unknown method is a configuration error, not a silent no-op.
|
||||
if _, err := LoadValidatorAgent(writeAgentConfig(t, " methods: [control_api, ipecho]\n")); err == nil {
|
||||
t.Fatal("expected an error for an unknown self-check method")
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped agent example must load, and the rxprod copy must stay
|
||||
// byte-identical to it.
|
||||
func TestValidatorAgentExampleConfig(t *testing.T) {
|
||||
c, err := LoadValidatorAgent("../../configs/validator-agent.example.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("load example: %v", err)
|
||||
}
|
||||
if got := c.SelfCheck.Methods; len(got) != 2 || got[0] != SelfCheckControlAPI {
|
||||
t.Fatalf("example methods = %v", got)
|
||||
}
|
||||
a, _ := os.ReadFile("../../configs/validator-agent.example.yaml")
|
||||
b, err := os.ReadFile("../../rxprod-compose/sources/validator-agent.example.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Fatal("rxprod-compose/sources/validator-agent.example.yaml differs from configs/validator-agent.example.yaml")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "session"
|
||||
defaultSessionTTL = 8 * time.Hour
|
||||
|
||||
// Brute-force throttle: maxLoginFailures failures from one client IP
|
||||
// within loginFailureWindow lock that IP out until the oldest failure
|
||||
// leaves the window.
|
||||
maxLoginFailures = 5
|
||||
loginFailureWindow = 10 * time.Minute
|
||||
)
|
||||
|
||||
// authState holds everything the login/session machinery needs. A nil-safe
|
||||
// zero value is never used: Server.auth is always set by New, with
|
||||
// enabled=false when no credentials were configured.
|
||||
type authState struct {
|
||||
enabled bool
|
||||
user [sha256.Size]byte // sha256(username)
|
||||
pass [sha256.Size]byte // sha256(password)
|
||||
key []byte // HMAC key for session cookies
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
throttle *loginThrottle
|
||||
}
|
||||
|
||||
func newAuthState(cfg Config, log *slog.Logger) *authState {
|
||||
a := &authState{now: time.Now, throttle: newLoginThrottle(), ttl: cfg.SessionTTL}
|
||||
if a.ttl <= 0 {
|
||||
a.ttl = defaultSessionTTL
|
||||
}
|
||||
if cfg.Username == "" || cfg.Password == "" {
|
||||
log.Warn("dashboard login is disabled: username/password are not set, anyone who can reach this port has full access")
|
||||
return a
|
||||
}
|
||||
a.enabled = true
|
||||
a.user = sha256.Sum256([]byte(cfg.Username))
|
||||
a.pass = sha256.Sum256([]byte(cfg.Password))
|
||||
if cfg.SessionSecret != "" {
|
||||
a.key = []byte(cfg.SessionSecret)
|
||||
} else {
|
||||
a.key = make([]byte, 32)
|
||||
if _, err := rand.Read(a.key); err != nil {
|
||||
panic("dashboard: crypto/rand failed: " + err.Error())
|
||||
}
|
||||
log.Warn("dashboard session secret is not set: using a random one, sessions are reset on every restart")
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// checkCredentials compares both fields in constant time and always
|
||||
// evaluates both, so neither the length nor which field was wrong leaks.
|
||||
func (a *authState) checkCredentials(user, pass string) bool {
|
||||
u := sha256.Sum256([]byte(user))
|
||||
p := sha256.Sum256([]byte(pass))
|
||||
uOK := subtle.ConstantTimeCompare(u[:], a.user[:])
|
||||
pOK := subtle.ConstantTimeCompare(p[:], a.pass[:])
|
||||
return uOK&pOK == 1
|
||||
}
|
||||
|
||||
// ---- session cookie ----
|
||||
|
||||
type sessionPayload struct {
|
||||
User string `json:"u"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
func (a *authState) sign(msg string) []byte {
|
||||
m := hmac.New(sha256.New, a.key)
|
||||
m.Write([]byte(msg))
|
||||
return m.Sum(nil)
|
||||
}
|
||||
|
||||
func (a *authState) issue(user string) (value string, exp time.Time) {
|
||||
exp = a.now().Add(a.ttl)
|
||||
raw, _ := json.Marshal(sessionPayload{User: user, Exp: exp.Unix()})
|
||||
p := base64.RawURLEncoding.EncodeToString(raw)
|
||||
return p + "." + base64.RawURLEncoding.EncodeToString(a.sign(p)), exp
|
||||
}
|
||||
|
||||
// verify returns the session's user if value is an untampered, unexpired
|
||||
// cookie issued with this server's key.
|
||||
func (a *authState) verify(value string) (string, bool) {
|
||||
p, sig, ok := strings.Cut(value, ".")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
got, err := base64.RawURLEncoding.DecodeString(sig)
|
||||
if err != nil || !hmac.Equal(got, a.sign(p)) {
|
||||
return "", false
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(p)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var sp sessionPayload
|
||||
if json.Unmarshal(raw, &sp) != nil || sp.User == "" || a.now().Unix() >= sp.Exp {
|
||||
return "", false
|
||||
}
|
||||
return sp.User, true
|
||||
}
|
||||
|
||||
func isHTTPS(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
func (a *authState) setCookie(w http.ResponseWriter, r *http.Request, user string) {
|
||||
value, exp := a.issue(user)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName, Value: value, Path: "/", Expires: exp,
|
||||
MaxAge: int(a.ttl.Seconds()), HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func clearCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- request context ----
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
func userFromRequest(r *http.Request) string {
|
||||
u, _ := r.Context().Value(ctxKey{}).(string)
|
||||
return u
|
||||
}
|
||||
|
||||
// ---- middleware ----
|
||||
|
||||
// authMiddleware gates every route except the login page, logout and static
|
||||
// assets. With login disabled it is a pass-through (no CSRF check either).
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
a := s.auth
|
||||
if !a.enabled {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !safeMethod(r.Method) && !sameOrigin(r) {
|
||||
s.Log.Warn("rejected cross-origin request", "method", r.Method, "path", r.URL.Path)
|
||||
http.Error(w, "forbidden: cross-origin request", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if isOpenPath(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if c, err := r.Cookie(sessionCookieName); err == nil {
|
||||
if user, ok := a.verify(c.Value); ok {
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, user)))
|
||||
return
|
||||
}
|
||||
}
|
||||
// htmx polls fragments in the background: answer with an
|
||||
// HX-Redirect instead of a redirect whose login page would be
|
||||
// swapped into the fragment.
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
target := "/login"
|
||||
if r.Method == http.MethodGet {
|
||||
if n := r.URL.RequestURI(); n != "/" {
|
||||
target += "?next=" + url.QueryEscape(n)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
func isOpenPath(r *http.Request) bool {
|
||||
switch r.URL.Path {
|
||||
case "/login":
|
||||
return r.Method == http.MethodGet || r.Method == http.MethodPost
|
||||
case "/logout":
|
||||
return r.Method == http.MethodPost
|
||||
}
|
||||
return strings.HasPrefix(r.URL.Path, "/static/") && (r.Method == http.MethodGet || r.Method == http.MethodHead)
|
||||
}
|
||||
|
||||
func safeMethod(m string) bool {
|
||||
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
|
||||
}
|
||||
|
||||
// sameOrigin implements the CSRF check: Origin (or, when absent, Referer)
|
||||
// must name this very host. Browsers always send Origin on cross-site
|
||||
// POSTs; a request with neither header is refused.
|
||||
func sameOrigin(r *http.Request) bool {
|
||||
h := r.Header.Get("Origin")
|
||||
if h == "" {
|
||||
h = r.Header.Get("Referer")
|
||||
}
|
||||
if h == "" || h == "null" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(h)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(u.Host, r.Host)
|
||||
}
|
||||
|
||||
// safeNext returns next only if it is a same-origin relative path, so the
|
||||
// post-login redirect can never leave the site.
|
||||
func safeNext(next string) string {
|
||||
if next == "" || next[0] != '/' || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
|
||||
return "/"
|
||||
}
|
||||
for _, c := range next {
|
||||
if c < 0x20 || c == 0x7f || c == '\\' {
|
||||
return "/"
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(next)
|
||||
if err != nil || u.Scheme != "" || u.Host != "" {
|
||||
return "/"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// ---- brute-force throttle ----
|
||||
|
||||
type loginThrottle struct {
|
||||
mu sync.Mutex
|
||||
failures map[string][]time.Time
|
||||
}
|
||||
|
||||
func newLoginThrottle() *loginThrottle {
|
||||
return &loginThrottle{failures: map[string][]time.Time{}}
|
||||
}
|
||||
|
||||
// prune drops expired failures (all clients) — caller holds mu. The map is
|
||||
// bounded by the number of distinct IPs that failed in the last window.
|
||||
func (t *loginThrottle) prune(now time.Time) {
|
||||
for ip, fs := range t.failures {
|
||||
i := 0
|
||||
for i < len(fs) && now.Sub(fs[i]) >= loginFailureWindow {
|
||||
i++
|
||||
}
|
||||
if i == len(fs) {
|
||||
delete(t.failures, ip)
|
||||
} else if i > 0 {
|
||||
t.failures[ip] = fs[i:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blocked reports whether ip is locked out, and for how long.
|
||||
func (t *loginThrottle) blocked(ip string, now time.Time) (bool, time.Duration) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.prune(now)
|
||||
fs := t.failures[ip]
|
||||
if len(fs) < maxLoginFailures {
|
||||
return false, 0
|
||||
}
|
||||
return true, fs[0].Add(loginFailureWindow).Sub(now)
|
||||
}
|
||||
|
||||
func (t *loginThrottle) fail(ip string, now time.Time) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.prune(now)
|
||||
t.failures[ip] = append(t.failures[ip], now)
|
||||
}
|
||||
|
||||
func (t *loginThrottle) clear(ip string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
delete(t.failures, ip)
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// ---- handlers ----
|
||||
|
||||
type loginPageData struct {
|
||||
Error string
|
||||
Next string
|
||||
}
|
||||
|
||||
func (s *Server) renderLogin(w http.ResponseWriter, status int, data loginPageData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
if err := s.tmpl.ExecuteTemplate(w, "login_page", data); err != nil {
|
||||
s.Log.Error("render login", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.auth.enabled {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if c, err := r.Cookie(sessionCookieName); err == nil {
|
||||
if _, ok := s.auth.verify(c.Value); ok {
|
||||
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.renderLogin(w, http.StatusOK, loginPageData{Next: safeNext(r.URL.Query().Get("next"))})
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
a := s.auth
|
||||
if !a.enabled {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
next := safeNext(r.PostForm.Get("next"))
|
||||
ip := clientIP(r)
|
||||
now := a.now()
|
||||
|
||||
if blocked, wait := a.throttle.blocked(ip, now); blocked {
|
||||
secs := int(wait.Seconds()) + 1
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
s.Log.Warn("login throttled", "remote", ip)
|
||||
s.renderLogin(w, http.StatusTooManyRequests, loginPageData{Next: next, Error: "Слишком много попыток входа. Повторите позже."})
|
||||
return
|
||||
}
|
||||
user := r.PostForm.Get("username")
|
||||
if !a.checkCredentials(user, r.PostForm.Get("password")) {
|
||||
a.throttle.fail(ip, now)
|
||||
s.Log.Warn("login failed", "remote", ip)
|
||||
s.renderLogin(w, http.StatusOK, loginPageData{Next: next, Error: "Неверный логин или пароль"})
|
||||
return
|
||||
}
|
||||
a.throttle.clear(ip)
|
||||
a.setCookie(w, r, user)
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
clearCookie(w, r)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// withAuthInfo returns a copy of the page-data struct data with its embedded
|
||||
// PageData's AuthEnabled/User filled in, so the sidebar can show the logout
|
||||
// control. Data without an embedded PageData is returned unchanged.
|
||||
func (s *Server) withAuthInfo(r *http.Request, data interface{}) interface{} {
|
||||
if !s.auth.enabled || data == nil {
|
||||
return data
|
||||
}
|
||||
v := reflect.ValueOf(data)
|
||||
if v.Kind() != reflect.Struct {
|
||||
return data
|
||||
}
|
||||
cp := reflect.New(v.Type()).Elem()
|
||||
cp.Set(v)
|
||||
pd := cp.FieldByName("PageData")
|
||||
if !pd.IsValid() || pd.Type() != reflect.TypeOf(PageData{}) {
|
||||
return data
|
||||
}
|
||||
pd.FieldByName("AuthEnabled").SetBool(true)
|
||||
pd.FieldByName("User").SetString(userFromRequest(r))
|
||||
return cp.Interface()
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testUser = "admin"
|
||||
testPass = "correct horse battery staple"
|
||||
testSecret = "test-session-secret"
|
||||
)
|
||||
|
||||
func newAuthTestServer(t *testing.T, caURL string, mutate func(*Config)) (*Server, *httptest.Server) {
|
||||
t.Helper()
|
||||
cfg := Config{
|
||||
ControlAPIBaseURL: caURL,
|
||||
ControlAPITimeout: 5 * time.Second,
|
||||
LastCompletedCount: 20,
|
||||
OverviewPollIntervalS: 5,
|
||||
ControlAPIToken: "ca-admin-token",
|
||||
Username: testUser,
|
||||
Password: testPass,
|
||||
SessionSecret: testSecret,
|
||||
SessionTTL: time.Hour,
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&cfg)
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
srv, err := New(cfg, log)
|
||||
if err != nil {
|
||||
t.Fatalf("new dashboard server: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return srv, ts
|
||||
}
|
||||
|
||||
// noFollow is a client that returns redirects as-is.
|
||||
func noFollow(ts *httptest.Server) *http.Client {
|
||||
c := *ts.Client()
|
||||
c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &c
|
||||
}
|
||||
|
||||
type reqOpts struct {
|
||||
method string
|
||||
path string
|
||||
form url.Values
|
||||
cookie string
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
func doReq(t *testing.T, ts *httptest.Server, o reqOpts) (*http.Response, string) {
|
||||
t.Helper()
|
||||
if o.method == "" {
|
||||
o.method = http.MethodGet
|
||||
}
|
||||
var body io.Reader
|
||||
if o.form != nil {
|
||||
body = strings.NewReader(o.form.Encode())
|
||||
}
|
||||
req, err := http.NewRequest(o.method, ts.URL+o.path, body)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
if o.form != nil {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if o.cookie != "" {
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: o.cookie})
|
||||
}
|
||||
for k, v := range o.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := noFollow(ts).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", o.method, o.path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp, string(b)
|
||||
}
|
||||
|
||||
func sessionCookie(resp *http.Response) *http.Cookie {
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == sessionCookieName {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loginForm(user, pass string) url.Values {
|
||||
return url.Values{"username": {user}, "password": {pass}}
|
||||
}
|
||||
|
||||
// login performs a successful login and returns the session cookie value.
|
||||
func login(t *testing.T, ts *httptest.Server) string {
|
||||
t.Helper()
|
||||
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
|
||||
headers: map[string]string{"Origin": ts.URL}})
|
||||
c := sessionCookie(resp)
|
||||
if resp.StatusCode != http.StatusSeeOther || c == nil {
|
||||
t.Fatalf("login: status=%d cookie=%v, want 303 + session cookie", resp.StatusCode, c)
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func TestUnauthenticatedRedirectsToLogin(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
|
||||
resp, _ := doReq(t, ts, reqOpts{path: "/ips"})
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("status=%d, want 303", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "/login?next=%2Fips" {
|
||||
t.Fatalf("Location=%q, want /login?next=%%2Fips", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthenticatedHTMXGets401WithHXRedirect(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
|
||||
resp, _ := doReq(t, ts, reqOpts{path: "/overview/fragment", headers: map[string]string{"HX-Request": "true"}})
|
||||
if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("HX-Redirect") != "/login" {
|
||||
t.Fatalf("status=%d HX-Redirect=%q, want 401 + /login", resp.StatusCode, resp.Header.Get("HX-Redirect"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccessSetsCookieAndGrantsAccess(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
|
||||
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login",
|
||||
form: url.Values{"username": {testUser}, "password": {testPass}, "next": {"/ips"}},
|
||||
headers: map[string]string{"Origin": ts.URL}})
|
||||
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/ips" {
|
||||
t.Fatalf("status=%d Location=%q, want 303 /ips", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
c := sessionCookie(resp)
|
||||
if c == nil {
|
||||
t.Fatal("no session cookie")
|
||||
}
|
||||
if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode || c.Path != "/" || c.MaxAge <= 0 {
|
||||
t.Fatalf("cookie flags: httponly=%v samesite=%v path=%q maxage=%d", c.HttpOnly, c.SameSite, c.Path, c.MaxAge)
|
||||
}
|
||||
if c.Secure {
|
||||
t.Fatal("cookie must not be Secure over plain HTTP")
|
||||
}
|
||||
|
||||
resp, body := doReq(t, ts, reqOpts{path: "/overview", cookie: c.Value})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("authenticated GET /overview: status=%d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(body, "Выйти") || !strings.Contains(body, testUser) {
|
||||
t.Fatal("sidebar must show the user and the logout button when auth is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCookieSecureBehindHTTPSProxy(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
|
||||
headers: map[string]string{"Origin": ts.URL, "X-Forwarded-Proto": "https"}})
|
||||
if c := sessionCookie(resp); c == nil || !c.Secure {
|
||||
t.Fatalf("cookie=%v, want Secure with X-Forwarded-Proto=https", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPasswordShowsErrorWithoutCookie(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
|
||||
for _, f := range []url.Values{loginForm(testUser, "nope"), loginForm("nobody", testPass)} {
|
||||
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: f,
|
||||
headers: map[string]string{"Origin": ts.URL}})
|
||||
if resp.StatusCode != http.StatusOK || sessionCookie(resp) != nil {
|
||||
t.Fatalf("status=%d cookie=%v, want 200 and no cookie", resp.StatusCode, sessionCookie(resp))
|
||||
}
|
||||
if !strings.Contains(body, "Неверный логин или пароль") {
|
||||
t.Fatal("login page must show the error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedAndExpiredCookiesRejected(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
srv, ts := newAuthTestServer(t, caURL, nil)
|
||||
good := login(t, ts)
|
||||
|
||||
payload, sig, _ := strings.Cut(good, ".")
|
||||
for name, v := range map[string]string{
|
||||
"tampered payload": "AAAA" + payload[4:] + "." + sig,
|
||||
"tampered signature": payload + "." + sig[:len(sig)-2] + "AA",
|
||||
"no signature": payload,
|
||||
"garbage": "x.y",
|
||||
"signed with new key": otherKeyCookie(t),
|
||||
} {
|
||||
resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: v})
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("%s: status=%d, want 303 to /login", name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Expiry: advance the server's clock beyond the TTL.
|
||||
srv.auth.now = func() time.Time { return time.Now().Add(2 * time.Hour) }
|
||||
resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good})
|
||||
if resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("expired cookie: status=%d, want 303", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func otherKeyCookie(t *testing.T) string {
|
||||
t.Helper()
|
||||
a := newAuthState(Config{Username: "u", Password: "p", SessionSecret: "another-secret"},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
v, _ := a.issue(testUser)
|
||||
return v
|
||||
}
|
||||
|
||||
func TestLogoutClearsCookie(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
good := login(t, ts)
|
||||
|
||||
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/logout", cookie: good,
|
||||
headers: map[string]string{"Origin": ts.URL}})
|
||||
c := sessionCookie(resp)
|
||||
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" || c == nil || c.MaxAge >= 0 || c.Value != "" {
|
||||
t.Fatalf("logout: status=%d loc=%q cookie=%+v", resp.StatusCode, resp.Header.Get("Location"), c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFOriginCheck(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
good := login(t, ts)
|
||||
|
||||
cases := map[string]map[string]string{
|
||||
"foreign origin": {"Origin": "http://evil.example"},
|
||||
"null origin": {"Origin": "null"},
|
||||
"no origin/referer": {},
|
||||
"foreign referer": {"Referer": "http://evil.example/page"},
|
||||
}
|
||||
for name, h := range cases {
|
||||
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/clear", cookie: good, headers: h})
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("%s: status=%d, want 403", name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
// Login itself is covered too.
|
||||
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
|
||||
headers: map[string]string{"Origin": "http://evil.example"}})
|
||||
if resp.StatusCode != http.StatusForbidden || sessionCookie(resp) != nil {
|
||||
t.Fatalf("cross-origin login: status=%d, want 403 and no cookie", resp.StatusCode)
|
||||
}
|
||||
// Same-origin Origin, and Referer fallback, pass.
|
||||
for name, h := range map[string]map[string]string{
|
||||
"origin": {"Origin": ts.URL},
|
||||
"referer": {"Referer": ts.URL + "/ips"},
|
||||
} {
|
||||
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/clear", cookie: good, headers: h})
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusSeeOther {
|
||||
t.Fatalf("same-origin %s: status=%d, want request to be served", name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginThrottle(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
|
||||
for i := 0; i < maxLoginFailures; i++ {
|
||||
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, "bad"),
|
||||
headers: map[string]string{"Origin": ts.URL}})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("attempt %d: status=%d, want 200", i+1, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
// Now locked out, even with the right password.
|
||||
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
|
||||
headers: map[string]string{"Origin": ts.URL}})
|
||||
if resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Retry-After") == "" || sessionCookie(resp) != nil {
|
||||
t.Fatalf("status=%d Retry-After=%q, want 429 with Retry-After and no cookie", resp.StatusCode, resp.Header.Get("Retry-After"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginThrottleClearedBySuccessAndExpires(t *testing.T) {
|
||||
th := newLoginThrottle()
|
||||
now := time.Now()
|
||||
for i := 0; i < maxLoginFailures-1; i++ {
|
||||
th.fail("1.2.3.4", now)
|
||||
}
|
||||
if b, _ := th.blocked("1.2.3.4", now); b {
|
||||
t.Fatal("blocked before reaching the limit")
|
||||
}
|
||||
th.fail("1.2.3.4", now)
|
||||
if b, _ := th.blocked("1.2.3.4", now); !b {
|
||||
t.Fatal("not blocked at the limit")
|
||||
}
|
||||
if b, _ := th.blocked("1.2.3.4", now.Add(loginFailureWindow+time.Second)); b {
|
||||
t.Fatal("still blocked after the window")
|
||||
}
|
||||
if len(th.failures) != 0 {
|
||||
t.Fatal("expired entries must be pruned")
|
||||
}
|
||||
th.fail("5.6.7.8", now)
|
||||
th.clear("5.6.7.8")
|
||||
if len(th.failures) != 0 {
|
||||
t.Fatal("clear must drop the entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAndLoginPageOpen(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
|
||||
if resp, _ := doReq(t, ts, reqOpts{path: "/static/dashboard.css"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("/static/dashboard.css: status=%d, want 200", resp.StatusCode)
|
||||
}
|
||||
resp, body := doReq(t, ts, reqOpts{path: "/login"})
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `name="password"`) {
|
||||
t.Fatalf("/login: status=%d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginNextOpenRedirectRejected(t *testing.T) {
|
||||
for next, want := range map[string]string{
|
||||
"/ips": "/ips",
|
||||
"/ips?q=1": "/ips?q=1",
|
||||
"//evil.example": "/",
|
||||
"/\\evil.example": "/",
|
||||
"http://evil.example": "/",
|
||||
"https://evil.example/x": "/",
|
||||
"javascript:alert(1)": "/",
|
||||
"": "/",
|
||||
"/a\r\nSet-Cookie: x": "/",
|
||||
} {
|
||||
if got := safeNext(next); got != want {
|
||||
t.Fatalf("safeNext(%q)=%q, want %q", next, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login",
|
||||
form: url.Values{"username": {testUser}, "password": {testPass}, "next": {"//evil.example"}},
|
||||
headers: map[string]string{"Origin": ts.URL}})
|
||||
if resp.Header.Get("Location") != "/" {
|
||||
t.Fatalf("Location=%q, want /", resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlAPITokenForwarded(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
good := login(t, ts)
|
||||
|
||||
if resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /overview: status=%d", resp.StatusCode)
|
||||
}
|
||||
fake.authMu.Lock()
|
||||
defer fake.authMu.Unlock()
|
||||
if len(fake.authHeaders) == 0 {
|
||||
t.Fatal("control-api was never called")
|
||||
}
|
||||
for _, h := range fake.authHeaders {
|
||||
if h != "Bearer ca-admin-token" {
|
||||
t.Fatalf("control-api Authorization=%q, want Bearer ca-admin-token", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDisabledWithZeroConfig(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
ts := newTestServer(t, caURL) // zero auth fields
|
||||
resp, body := doReq(t, ts, reqOpts{path: "/overview"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200 without login", resp.StatusCode)
|
||||
}
|
||||
if strings.Contains(body, "/logout") {
|
||||
t.Fatal("logout control must be hidden when auth is disabled")
|
||||
}
|
||||
if resp, _ := doReq(t, ts, reqOpts{path: "/login"}); resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/" {
|
||||
t.Fatalf("/login with auth disabled: status=%d loc=%q, want redirect to /", resp.StatusCode, resp.Header.Get("Location"))
|
||||
}
|
||||
fake.authMu.Lock()
|
||||
defer fake.authMu.Unlock()
|
||||
for _, h := range fake.authHeaders {
|
||||
if h != "" {
|
||||
t.Fatalf("no token configured but Authorization=%q was sent", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDisabledWhenOnlyUsernameSet(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, func(c *Config) { c.Password = "" })
|
||||
if resp, _ := doReq(t, ts, reqOpts{path: "/overview"}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200 (auth disabled without password)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySessionSecretUsesRandomKey(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, func(c *Config) { c.SessionSecret = "" })
|
||||
good := login(t, ts)
|
||||
if resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200 with random-key session", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
+293
-22
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -39,13 +41,44 @@ func (e *apiErr) Error() string {
|
||||
type client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
// long is used for clear / bulk operations, which legitimately take far
|
||||
// longer than a plain read (thousands of rows in one transaction): same
|
||||
// transport, but a longer whole-call timeout.
|
||||
long *http.Client
|
||||
// token, when non-empty, is sent to control-api as a Bearer credential.
|
||||
token string
|
||||
}
|
||||
|
||||
// longCallTimeout is the minimum whole-call timeout for clear and bulk
|
||||
// operations (ClearQueue, DeleteIPs, SubmitIPs).
|
||||
const longCallTimeout = 120 * time.Second
|
||||
|
||||
func newClient(baseURL string, timeout time.Duration) *client {
|
||||
return &client{baseURL: baseURL, http: &http.Client{Timeout: timeout}}
|
||||
longT := longCallTimeout
|
||||
if timeout > longT {
|
||||
longT = timeout
|
||||
}
|
||||
return &client{
|
||||
baseURL: baseURL,
|
||||
http: &http.Client{Timeout: timeout},
|
||||
long: &http.Client{Timeout: longT},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) do(ctx context.Context, method, path string, body, out interface{}) error {
|
||||
return c.doWith(ctx, c.http, method, path, body, out)
|
||||
}
|
||||
|
||||
// doLong is do with the long (clear/bulk) timeout.
|
||||
func (c *client) doLong(ctx context.Context, method, path string, body, out interface{}) error {
|
||||
hc := c.long
|
||||
if hc == nil {
|
||||
hc = c.http
|
||||
}
|
||||
return c.doWith(ctx, hc, method, path, body, out)
|
||||
}
|
||||
|
||||
func (c *client) doWith(ctx context.Context, hc *http.Client, method, path string, body, out interface{}) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
@@ -61,8 +94,11 @@ func (c *client) do(ctx context.Context, method, path string, body, out interfac
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return &apiErr{Status: 0, Message: err.Error()}
|
||||
}
|
||||
@@ -91,9 +127,58 @@ func (c *client) Status(ctx context.Context) (statusResponse, error) {
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *client) ListIPs(ctx context.Context) ([]ipQueueItem, error) {
|
||||
var out []ipQueueItem
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips", nil, &out)
|
||||
// maxPageLimit is control-api's cap on `limit`.
|
||||
const maxPageLimit = 1000
|
||||
|
||||
// clampLimit keeps limit within 1..maxPageLimit: a request without `limit`
|
||||
// would make control-api answer with the legacy unbounded bare array.
|
||||
func clampLimit(limit int) int {
|
||||
if limit < 1 {
|
||||
return 1
|
||||
}
|
||||
if limit > maxPageLimit {
|
||||
return maxPageLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// ipsQuery selects one page of GET /admin/ips: server-side filters plus
|
||||
// limit/offset. Order is "sequence" (default) or "aggregated_at_desc".
|
||||
type ipsQuery struct {
|
||||
States []string
|
||||
Q string
|
||||
Result string
|
||||
Order string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func (q ipsQuery) values() url.Values {
|
||||
v := url.Values{}
|
||||
v.Set("limit", strconv.Itoa(clampLimit(q.Limit)))
|
||||
if q.Offset > 0 {
|
||||
v.Set("offset", strconv.Itoa(q.Offset))
|
||||
}
|
||||
if len(q.States) > 0 {
|
||||
v.Set("state", strings.Join(q.States, ","))
|
||||
}
|
||||
if q.Q != "" {
|
||||
v.Set("q", q.Q)
|
||||
}
|
||||
if q.Result != "" {
|
||||
v.Set("result", q.Result)
|
||||
}
|
||||
if q.Order != "" {
|
||||
v.Set("order", q.Order)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ListIPsPage returns one page of the check queue plus the total number of
|
||||
// rows matching the filter. Never loads the whole queue.
|
||||
func (c *client) ListIPsPage(ctx context.Context, q ipsQuery) (ipsPage, error) {
|
||||
var out ipsPage
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips?"+q.values().Encode(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -107,7 +192,7 @@ func (c *client) GetIP(ctx context.Context, ip string) (ipDetailResponse, error)
|
||||
// forcing a recheck of already-finished ones — see docs/API.md.
|
||||
func (c *client) SubmitIPs(ctx context.Context, addresses []string) (submitIPsResponse, error) {
|
||||
var out submitIPsResponse
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out)
|
||||
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -124,7 +209,7 @@ func (c *client) DeleteIP(ctx context.Context, ip string) error {
|
||||
// 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)
|
||||
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/delete", map[string][]string{"addresses": addresses}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -132,25 +217,63 @@ func (c *client) DeleteIPs(ctx context.Context, addresses []string) (deleteIPsRe
|
||||
// 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)
|
||||
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/clear", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ScanFloatingIPs lists the OpenStack project's free (unassociated)
|
||||
// floating IPs and submits them to the check queue — see
|
||||
// orchestrator.ScanFloatingIPs.
|
||||
func (c *client) ScanFloatingIPs(ctx context.Context) (scanIPsResponse, error) {
|
||||
var out scanIPsResponse
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips/scan", nil, &out)
|
||||
// StartScan starts control-api's background floating-IP scan (or joins the
|
||||
// one already running) and returns immediately with its current status.
|
||||
func (c *client) StartScan(ctx context.Context, dryRun bool) (scanStatusDTO, error) {
|
||||
var out scanStatusDTO
|
||||
path := "/api/v1/admin/ips/scan"
|
||||
if dryRun {
|
||||
path += "?dry_run=true"
|
||||
}
|
||||
err := c.do(ctx, http.MethodPost, path, nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ListRegistry returns every address ever submitted to the check queue,
|
||||
// each with a summary of its accumulated check history — survives an
|
||||
// address being deleted from the queue and later re-added.
|
||||
func (c *client) ListRegistry(ctx context.Context) ([]registryItem, error) {
|
||||
var out []registryItem
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry", nil, &out)
|
||||
// ScanStatus returns the progress of the background scan job.
|
||||
func (c *client) ScanStatus(ctx context.Context) (scanStatusDTO, error) {
|
||||
var out scanStatusDTO
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips/scan", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// registryQuery selects one page of GET /admin/registry.
|
||||
type registryQuery struct {
|
||||
Q string
|
||||
LastResult string
|
||||
Run int64 // only addresses with a result in this run
|
||||
Subnet string // only addresses inside this CIDR
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ListRegistryPage returns one page of the registry (every address ever
|
||||
// submitted to the check queue, each with a summary of its accumulated check
|
||||
// history — survives an address being deleted from the queue and later
|
||||
// re-added) plus the total number of rows matching the filter.
|
||||
func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registryPage, error) {
|
||||
v := url.Values{}
|
||||
v.Set("limit", strconv.Itoa(clampLimit(q.Limit)))
|
||||
if q.Offset > 0 {
|
||||
v.Set("offset", strconv.Itoa(q.Offset))
|
||||
}
|
||||
if q.Q != "" {
|
||||
v.Set("q", q.Q)
|
||||
}
|
||||
if q.LastResult != "" {
|
||||
v.Set("last_result", q.LastResult)
|
||||
}
|
||||
if q.Run > 0 {
|
||||
v.Set("run", strconv.FormatInt(q.Run, 10))
|
||||
}
|
||||
if q.Subnet != "" {
|
||||
v.Set("subnet", q.Subnet)
|
||||
}
|
||||
var out registryPage
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -233,10 +356,10 @@ func (c *client) GetOrchestratorSettings(ctx context.Context) (orchestratorSetti
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *client) PutOrchestratorSettings(ctx context.Context, fipSettleSeconds, historyRetentionCycles int) (orchestratorSettingsDTO, error) {
|
||||
func (c *client) PutOrchestratorSettings(ctx context.Context, fipSettleSeconds, historyRetentionCycles, selfCheckMaxAttempts int) (orchestratorSettingsDTO, error) {
|
||||
var out orchestratorSettingsDTO
|
||||
err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/orchestrator",
|
||||
orchestratorSettingsDTO{FIPSettleSeconds: fipSettleSeconds, HistoryRetentionCycles: historyRetentionCycles}, &out)
|
||||
orchestratorSettingsDTO{FIPSettleSeconds: fipSettleSeconds, HistoryRetentionCycles: historyRetentionCycles, SelfCheckMaxAttempts: selfCheckMaxAttempts}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -277,3 +400,151 @@ func (c *client) StopAutoCycle(ctx context.Context) (autoCycleDTO, error) {
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// analyticsRun is one entry of GET /admin/analytics/runs (the run selector).
|
||||
type analyticsRun struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
State string `json:"state"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinalizedAt *time.Time `json:"finalized_at"`
|
||||
Addresses int `json:"addresses"`
|
||||
Pass int `json:"pass"`
|
||||
Partial int `json:"partial"`
|
||||
Fail int `json:"fail"`
|
||||
Cancelled int `json:"cancelled"`
|
||||
Total int `json:"total"`
|
||||
Pending int `json:"pending"`
|
||||
}
|
||||
|
||||
func (c *client) ListAnalyticsRuns(ctx context.Context) ([]analyticsRun, error) {
|
||||
var out []analyticsRun
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetAnalyticsReport returns the analytics of one finished run as the raw
|
||||
// JSON control-api computed; the page's script reads it as it is.
|
||||
func (c *client) GetAnalyticsReport(ctx context.Context, runID int64) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/runs/"+strconv.FormatInt(runID, 10), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func analyticsListPath(runID int64, kind, class string, csv bool) string {
|
||||
v := url.Values{}
|
||||
if class != "" {
|
||||
v.Set("class", class)
|
||||
}
|
||||
if csv {
|
||||
v.Set("format", "csv")
|
||||
}
|
||||
p := "/api/v1/admin/analytics/runs/" + strconv.FormatInt(runID, 10) + "/lists/" + url.PathEscape(kind)
|
||||
if len(v) > 0 {
|
||||
p += "?" + v.Encode()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// GetAnalyticsList returns one address table (JSON) of a run.
|
||||
func (c *client) GetAnalyticsList(ctx context.Context, runID int64, kind, class string) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, analyticsListPath(runID, kind, class, false), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetAnalyticsListCSV returns the CSV file of one address table, with the
|
||||
// file name control-api proposed.
|
||||
func (c *client) GetAnalyticsListCSV(ctx context.Context, runID int64, kind, class string) ([]byte, string, error) {
|
||||
return c.getCSV(ctx, analyticsListPath(runID, kind, class, true))
|
||||
}
|
||||
|
||||
func (c *client) getCSV(ctx context.Context, path string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", &apiErr{Status: 0, Message: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
msg := string(body)
|
||||
var er errorResponse
|
||||
if json.Unmarshal(body, &er) == nil && er.Error != "" {
|
||||
msg = er.Error
|
||||
}
|
||||
return nil, "", &apiErr{Status: resp.StatusCode, Message: msg}
|
||||
}
|
||||
return body, resp.Header.Get("Content-Disposition"), nil
|
||||
}
|
||||
|
||||
// compareFilter narrows one list of the comparison of two runs.
|
||||
type compareFilter struct{ Indicator, From, To string }
|
||||
|
||||
func analyticsCompareQuery(base, target int64) url.Values {
|
||||
return url.Values{"base": {strconv.FormatInt(base, 10)}, "target": {strconv.FormatInt(target, 10)}}
|
||||
}
|
||||
|
||||
func analyticsCompareListPath(base, target int64, group string, f compareFilter, csv bool) string {
|
||||
v := analyticsCompareQuery(base, target)
|
||||
if f.Indicator != "" {
|
||||
v.Set("indicator", f.Indicator)
|
||||
}
|
||||
if f.From != "" || f.To != "" {
|
||||
v.Set("from", f.From)
|
||||
v.Set("to", f.To)
|
||||
}
|
||||
if csv {
|
||||
v.Set("format", "csv")
|
||||
}
|
||||
return "/api/v1/admin/analytics/compare/lists/" + url.PathEscape(group) + "?" + v.Encode()
|
||||
}
|
||||
|
||||
// GetAnalyticsCompare returns the comparison of two finished runs (base is the
|
||||
// older one) as the raw JSON control-api computed.
|
||||
func (c *client) GetAnalyticsCompare(ctx context.Context, base, target int64) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/analytics/compare?"+analyticsCompareQuery(base, target).Encode(), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetAnalyticsCompareList returns one address table (JSON) of the comparison.
|
||||
func (c *client) GetAnalyticsCompareList(ctx context.Context, base, target int64, group string, f compareFilter) (json.RawMessage, error) {
|
||||
var out json.RawMessage
|
||||
err := c.do(ctx, http.MethodGet, analyticsCompareListPath(base, target, group, f, false), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetAnalyticsCompareListCSV returns the CSV file of one comparison table,
|
||||
// with the file name control-api proposed.
|
||||
func (c *client) GetAnalyticsCompareListCSV(ctx context.Context, base, target int64, group string, f compareFilter) ([]byte, string, error) {
|
||||
return c.getCSV(ctx, analyticsCompareListPath(base, target, group, f, true))
|
||||
}
|
||||
|
||||
// subnetEntry is one line of the subnet list (GET/PUT /admin/config/subnets).
|
||||
type subnetEntry struct {
|
||||
CIDR string `json:"cidr"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type subnetList struct {
|
||||
Subnets []subnetEntry `json:"subnets"`
|
||||
}
|
||||
|
||||
func (c *client) GetSubnets(ctx context.Context) (subnetList, error) {
|
||||
var out subnetList
|
||||
err := c.do(ctx, http.MethodGet, "/api/v1/admin/config/subnets", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *client) PutSubnets(ctx context.Context, in subnetList) (subnetList, error) {
|
||||
var out subnetList
|
||||
err := c.do(ctx, http.MethodPut, "/api/v1/admin/config/subnets", in, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -35,6 +37,23 @@ type fakeControlAPI struct {
|
||||
inboundICMP bool
|
||||
|
||||
historyRetentionCycles int
|
||||
selfCheckMaxAttempts int
|
||||
// selfCheckFailedOn is served as self_check_failed_on of the address
|
||||
// detail and registry history endpoints.
|
||||
selfCheckFailedOn []string
|
||||
|
||||
// Analytics: the run selector, the report JSON per run, the lists per
|
||||
// "run/kind[/class]" and the subnet list of /settings.
|
||||
runs []analyticsRun
|
||||
reports map[int64]string
|
||||
lists map[string]string
|
||||
// compares is the comparison JSON per "base-target", compareLists the lists
|
||||
// per "base-target/group[/indicator]".
|
||||
compares map[string]string
|
||||
compareLists map[string]string
|
||||
|
||||
subnets subnetList
|
||||
analyticsReqs []string
|
||||
// scanFreeAddresses is what POST /ips/scan "discovers" — tests set it
|
||||
// directly rather than this fake reimplementing OpenStack floating-IP
|
||||
// filtering (already covered by internal/orchestrator's own tests).
|
||||
@@ -42,10 +61,35 @@ type fakeControlAPI struct {
|
||||
registry map[string]registryItem
|
||||
registryChecks map[string][]check
|
||||
|
||||
// Scan job state machine (see the scan handlers): POST starts a job that
|
||||
// stays "running" for scanRunPolls GET polls (0 = finishes at once), then
|
||||
// ends as done — or as error when scanFinalError is set. scan is the status
|
||||
// served by GET; tests may also set it directly (with scanPollsLeft == 0 it
|
||||
// stays as is). scanStartStatus != 0 makes POST fail with that HTTP status.
|
||||
scan scanStatusDTO
|
||||
scanRunPolls int
|
||||
scanPollsLeft int
|
||||
scanFinalError string
|
||||
scanStartStatus int
|
||||
|
||||
// Requests seen on the list endpoints, for "never loads everything" checks:
|
||||
// the raw query of every GET /ips (ipsQueries) and the number of GET
|
||||
// /registry calls without `limit` (bare), plus the sizes of the DeleteIPs /
|
||||
// SubmitIPs bulk calls.
|
||||
ipsQueries []string
|
||||
registryQueries []string
|
||||
bareIPsCalls int
|
||||
deleteChunks []int
|
||||
submitChunks []int
|
||||
|
||||
// autoCycle is the state served by /api/v1/admin/auto-cycle*;
|
||||
// autoCycleDown makes all four endpoints answer 500 (unavailable API).
|
||||
autoCycle autoCycleDTO
|
||||
autoCycleDown bool
|
||||
|
||||
// authHeaders records the Authorization header of every request served.
|
||||
authMu sync.Mutex
|
||||
authHeaders []string
|
||||
}
|
||||
|
||||
func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
|
||||
@@ -59,6 +103,8 @@ func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
|
||||
registry: map[string]registryItem{},
|
||||
registryChecks: map[string][]check{},
|
||||
autoCycle: autoCycleDTO{IntervalSeconds: 3600, Phase: "idle"},
|
||||
|
||||
selfCheckMaxAttempts: 5,
|
||||
}
|
||||
ts := httptest.NewServer(f.handler())
|
||||
t.Cleanup(ts.Close)
|
||||
@@ -82,16 +128,72 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
byState := map[string]int{}
|
||||
results := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
|
||||
for _, ip := range f.ips {
|
||||
byState[ip.State]++
|
||||
if ip.OverallResult != "" {
|
||||
results[ip.OverallResult]++
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators)})
|
||||
writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators), ResultsByOverall: results})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, f.ips)
|
||||
f.ipsQueries = append(f.ipsQueries, r.URL.RawQuery)
|
||||
qv := r.URL.Query()
|
||||
if qv.Get("limit") == "" {
|
||||
// Legacy shape: the whole queue as a bare array.
|
||||
f.bareIPsCalls++
|
||||
writeJSON(w, http.StatusOK, f.ips)
|
||||
return
|
||||
}
|
||||
limit, err := strconv.Atoi(qv.Get("limit"))
|
||||
if err != nil || limit < 1 || limit > 1000 {
|
||||
writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000")
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(qv.Get("offset"))
|
||||
var states map[string]bool
|
||||
if st := qv.Get("state"); st != "" {
|
||||
states = map[string]bool{}
|
||||
for _, x := range strings.Split(st, ",") {
|
||||
states[x] = true
|
||||
}
|
||||
}
|
||||
var matched []ipQueueItem
|
||||
for _, ip := range f.ips {
|
||||
if states != nil && !states[ip.State] {
|
||||
continue
|
||||
}
|
||||
if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), strings.ToLower(q)) {
|
||||
continue
|
||||
}
|
||||
if res := qv.Get("result"); res != "" && ip.OverallResult != res {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, ip)
|
||||
}
|
||||
if qv.Get("order") == "aggregated_at_desc" {
|
||||
sort.SliceStable(matched, func(i, j int) bool {
|
||||
a, b := matched[i].AggregatedAt, matched[j].AggregatedAt
|
||||
if a == nil || b == nil {
|
||||
return a != nil && b == nil
|
||||
}
|
||||
return a.After(*b)
|
||||
})
|
||||
} else {
|
||||
sort.SliceStable(matched, func(i, j int) bool { return matched[i].Sequence < matched[j].Sequence })
|
||||
}
|
||||
page := []ipQueueItem{}
|
||||
if offset < len(matched) {
|
||||
page = matched[offset:]
|
||||
if len(page) > limit {
|
||||
page = page[:limit]
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ipsPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/ips/{ip}", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -100,7 +202,7 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
addr := r.PathValue("ip")
|
||||
for _, ip := range f.ips {
|
||||
if ip.IPAddress == addr {
|
||||
writeJSON(w, http.StatusOK, ipDetailResponse{IP: ip, Checks: []check{}, Events: []event{}})
|
||||
writeJSON(w, http.StatusOK, ipDetailResponse{IP: ip, Checks: []check{}, Events: []event{}, SelfCheckFailedOn: f.selfCheckFailedOn})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -118,6 +220,7 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty")
|
||||
return
|
||||
}
|
||||
f.submitChunks = append(f.submitChunks, len(req.Addresses))
|
||||
resp := submitIPsResponse{}
|
||||
for _, addr := range req.Addresses {
|
||||
idx := f.findIP(addr)
|
||||
@@ -186,6 +289,7 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty")
|
||||
return
|
||||
}
|
||||
f.deleteChunks = append(f.deleteChunks, len(req.Addresses))
|
||||
resp := deleteIPsResponse{}
|
||||
for _, addr := range req.Addresses {
|
||||
idx := f.findIP(addr)
|
||||
@@ -206,6 +310,7 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
for _, ip := range f.ips {
|
||||
resp.Deleted = append(resp.Deleted, ip.IPAddress)
|
||||
}
|
||||
resp.Count = len(resp.Deleted)
|
||||
f.ips = nil
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
})
|
||||
@@ -216,6 +321,7 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
writeJSON(w, http.StatusOK, orchestratorSettingsDTO{
|
||||
FIPSettleSeconds: f.fipSettleSeconds,
|
||||
HistoryRetentionCycles: f.historyRetentionCycles,
|
||||
SelfCheckMaxAttempts: f.selfCheckMaxAttempts,
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("PUT /api/v1/admin/config/orchestrator", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -231,29 +337,49 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
writeAPIErr(w, http.StatusBadRequest, "history_retention_cycles must be >= 0")
|
||||
return
|
||||
}
|
||||
if req.SelfCheckMaxAttempts < 1 || req.SelfCheckMaxAttempts > 50 {
|
||||
writeAPIErr(w, http.StatusBadRequest, "self_check_max_attempts must be in 1..50")
|
||||
return
|
||||
}
|
||||
f.fipSettleSeconds = req.FIPSettleSeconds
|
||||
f.historyRetentionCycles = req.HistoryRetentionCycles
|
||||
f.selfCheckMaxAttempts = req.SelfCheckMaxAttempts
|
||||
writeJSON(w, http.StatusOK, orchestratorSettingsDTO{
|
||||
FIPSettleSeconds: f.fipSettleSeconds,
|
||||
HistoryRetentionCycles: f.historyRetentionCycles,
|
||||
SelfCheckMaxAttempts: f.selfCheckMaxAttempts,
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
resp := scanIPsResponse{ScannedFree: len(f.scanFreeAddresses)}
|
||||
for _, addr := range f.scanFreeAddresses {
|
||||
idx := f.findIP(addr)
|
||||
if idx < 0 {
|
||||
now := time.Now()
|
||||
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", CreatedAt: now, UpdatedAt: now})
|
||||
resp.Added = append(resp.Added, addr)
|
||||
continue
|
||||
}
|
||||
resp.Reordered = append(resp.Reordered, addr)
|
||||
if f.scanStartStatus != 0 {
|
||||
writeAPIErr(w, f.scanStartStatus, "scan refused")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
if !f.scan.Running {
|
||||
now := time.Now()
|
||||
f.scan = scanStatusDTO{State: "listing", Running: true, DryRun: r.URL.Query().Get("dry_run") == "true", StartedAt: &now}
|
||||
f.scanPollsLeft = f.scanRunPolls
|
||||
if f.scanPollsLeft == 0 {
|
||||
f.finishScan()
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, f.scan)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.scan.Running && f.scanPollsLeft > 0 {
|
||||
f.scanPollsLeft--
|
||||
if f.scanPollsLeft == 0 {
|
||||
f.finishScan()
|
||||
} else {
|
||||
f.scan.Pages++
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, f.scan)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/auto-cycle", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -325,11 +451,37 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/admin/registry", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]registryItem, 0, len(f.registry))
|
||||
qv := r.URL.Query()
|
||||
f.registryQueries = append(f.registryQueries, r.URL.RawQuery)
|
||||
matched := make([]registryItem, 0, len(f.registry))
|
||||
for _, item := range f.registry {
|
||||
out = append(out, item)
|
||||
if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(item.IPAddress), strings.ToLower(q)) {
|
||||
continue
|
||||
}
|
||||
if lr := qv.Get("last_result"); lr != "" && item.LastResult != lr {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, item)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
sort.Slice(matched, func(i, j int) bool { return matched[i].IPAddress < matched[j].IPAddress })
|
||||
if qv.Get("limit") == "" {
|
||||
writeJSON(w, http.StatusOK, matched)
|
||||
return
|
||||
}
|
||||
limit, err := strconv.Atoi(qv.Get("limit"))
|
||||
if err != nil || limit < 1 || limit > 1000 {
|
||||
writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000")
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(qv.Get("offset"))
|
||||
page := []registryItem{}
|
||||
if offset < len(matched) {
|
||||
page = matched[offset:]
|
||||
if len(page) > limit {
|
||||
page = page[:limit]
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, registryPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/registry/{ip}", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
@@ -340,9 +492,114 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
writeAPIErr(w, http.StatusNotFound, "unknown ip: "+addr)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr]})
|
||||
writeJSON(w, http.StatusOK, registryHistoryResponse{Registry: item, Checks: f.registryChecks[addr], SelfCheckFailedOn: f.selfCheckFailedOn})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := f.subnets
|
||||
if out.Subnets == nil {
|
||||
out.Subnets = []subnetEntry{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
})
|
||||
mux.HandleFunc("PUT /api/v1/admin/config/subnets", func(w http.ResponseWriter, r *http.Request) {
|
||||
var in subnetList
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeAPIErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
for _, s := range in.Subnets {
|
||||
if !strings.Contains(s.CIDR, "/") {
|
||||
writeAPIErr(w, http.StatusBadRequest, "subnet "+s.CIDR+": not a CIDR")
|
||||
return
|
||||
}
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.subnets = in
|
||||
writeJSON(w, http.StatusOK, in)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/runs", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := f.runs
|
||||
if out == nil {
|
||||
out = []analyticsRun{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
||||
rep, ok := f.reports[id]
|
||||
if !ok {
|
||||
writeAPIErr(w, http.StatusNotFound, "run not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(rep))
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/runs/{id}/lists/{kind}", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
||||
key := r.PathValue("id") + "/" + r.PathValue("kind")
|
||||
if c := r.URL.Query().Get("class"); c != "" {
|
||||
key += "/" + c
|
||||
}
|
||||
l, ok := f.lists[key]
|
||||
if !ok {
|
||||
writeAPIErr(w, http.StatusNotFound, "unknown list")
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("format") == "csv" {
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+r.PathValue("kind")+`_run`+r.PathValue("id")+`.csv"`)
|
||||
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(l))
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/compare", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
||||
rep, ok := f.compares[r.URL.Query().Get("base")+"-"+r.URL.Query().Get("target")]
|
||||
if !ok {
|
||||
writeAPIErr(w, http.StatusNotFound, "run not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(rep))
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/analytics/compare/lists/{group}", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.analyticsReqs = append(f.analyticsReqs, r.URL.RequestURI())
|
||||
q := r.URL.Query()
|
||||
key := q.Get("base") + "-" + q.Get("target") + "/" + r.PathValue("group")
|
||||
if ind := q.Get("indicator"); ind != "" {
|
||||
key += "/" + ind
|
||||
}
|
||||
l, ok := f.compareLists[key]
|
||||
if !ok {
|
||||
writeAPIErr(w, http.StatusNotFound, "unknown list")
|
||||
return
|
||||
}
|
||||
if q.Get("format") == "csv" {
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="compare_`+r.PathValue("group")+`_run`+q.Get("base")+"-"+q.Get("target")+`.csv"`)
|
||||
_, _ = w.Write([]byte("Адрес\r\n1.2.3.4\r\n"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(l))
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -532,7 +789,57 @@ func (f *fakeControlAPI) handler() http.Handler {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
})
|
||||
|
||||
return mux
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.authMu.Lock()
|
||||
f.authHeaders = append(f.authHeaders, r.Header.Get("Authorization"))
|
||||
f.authMu.Unlock()
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// finishScan ends the running scan job (caller holds f.mu): the discovered
|
||||
// free addresses are queued unless it was a dry run, or the job fails with
|
||||
// scanFinalError.
|
||||
func (f *fakeControlAPI) finishScan() {
|
||||
now := time.Now()
|
||||
f.scan.Running = false
|
||||
f.scan.FinishedAt = &now
|
||||
f.scan.Discovered = len(f.scanFreeAddresses)
|
||||
f.scan.Free = len(f.scanFreeAddresses)
|
||||
if f.scanFinalError != "" {
|
||||
f.scan.State = "error"
|
||||
f.scan.Error = f.scanFinalError
|
||||
return
|
||||
}
|
||||
f.scan.State = "done"
|
||||
if f.scan.DryRun {
|
||||
return
|
||||
}
|
||||
for _, addr := range f.scanFreeAddresses {
|
||||
if f.findIP(addr) < 0 {
|
||||
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: len(f.ips) + 1, CreatedAt: now, UpdatedAt: now})
|
||||
f.scan.Added++
|
||||
} else {
|
||||
f.scan.Reordered++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedIPs appends n queued addresses 10.x.y.z (distinct, in sequence order)
|
||||
// and returns them. Use it for tests that need pages' worth of rows.
|
||||
func (f *fakeControlAPI) seedIPs(n int) []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
now := time.Now()
|
||||
out := make([]string, 0, n)
|
||||
base := len(f.ips)
|
||||
for i := 0; i < n; i++ {
|
||||
k := base + i + 1
|
||||
addr := fmt.Sprintf("10.%d.%d.%d", k/65536, (k/256)%256, k%256)
|
||||
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: k, CreatedAt: now, UpdatedAt: now})
|
||||
out = append(out, addr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeControlAPI) findIP(addr string) int {
|
||||
@@ -587,3 +894,14 @@ func postForm(t *testing.T, ts *httptest.Server, method, path string, form url.V
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// newSlowAPI serves an empty JSON object for every request after delay.
|
||||
func newSlowAPI(t *testing.T, delay time.Duration) string {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(delay)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{})
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
return ts.URL
|
||||
}
|
||||
+197
-6
@@ -1,6 +1,7 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
@@ -20,6 +21,27 @@ type statusResponse struct {
|
||||
TotalIPs int `json:"total_ips"`
|
||||
IPsByState map[string]int `json:"ips_by_state"`
|
||||
TotalValidators int `json:"total_validators"`
|
||||
// ResultsByOverall counts finished addresses by overall result
|
||||
// (pass/partial/fail/cancelled).
|
||||
ResultsByOverall map[string]int `json:"results_by_overall"`
|
||||
}
|
||||
|
||||
// Terminal queue states: the address needs no further processing. Shared by
|
||||
// the overview progress indicator; "occupied" counts as terminal too (the
|
||||
// check cycle never ran because the floating IP was already bound).
|
||||
var terminalStates = []string{"done", "failed", "occupied"}
|
||||
|
||||
// activeStates are the states of an address that is being worked on right
|
||||
// now (everything between "queued" and a terminal state).
|
||||
var activeStates = []string{"assigning_fip", "awaiting_self_check", "checking", "aggregating"}
|
||||
|
||||
// sumStates adds up the counts of the given states in a status breakdown.
|
||||
func sumStates(byState map[string]int, states []string) int {
|
||||
n := 0
|
||||
for _, s := range states {
|
||||
n += byState[s]
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type ipQueueItem struct {
|
||||
@@ -73,6 +95,9 @@ type ipDetailResponse struct {
|
||||
IP ipQueueItem `json:"ip"`
|
||||
Checks []check `json:"checks"`
|
||||
Events []event `json:"events"`
|
||||
// SelfCheckFailedOn lists the validators whose self-check of this address
|
||||
// failed (in its current run).
|
||||
SelfCheckFailedOn []string `json:"self_check_failed_on"`
|
||||
}
|
||||
|
||||
type validator struct {
|
||||
@@ -99,14 +124,135 @@ type deleteIPsResponse struct {
|
||||
|
||||
type clearQueueResponse struct {
|
||||
Deleted []string `json:"deleted"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type scanIPsResponse struct {
|
||||
ScannedFree int `json:"scanned_free"`
|
||||
Added []string `json:"added"`
|
||||
Requeued []string `json:"requeued"`
|
||||
Reordered []string `json:"reordered"`
|
||||
SkippedInProgress []string `json:"skipped_in_progress"`
|
||||
// ipsPage is the paginated envelope of GET /admin/ips (sent when the request
|
||||
// carries `limit`).
|
||||
type ipsPage struct {
|
||||
Items []ipQueueItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
// registryPage is the paginated envelope of GET /admin/registry.
|
||||
type registryPage struct {
|
||||
Items []registryItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
// scanStatusDTO is the state of control-api's background floating-IP scan job
|
||||
// (POST/GET /api/v1/admin/ips/scan).
|
||||
type scanStatusDTO struct {
|
||||
State string `json:"state"`
|
||||
Running bool `json:"running"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Pages int `json:"pages"`
|
||||
Discovered int `json:"discovered"`
|
||||
Free int `json:"free"`
|
||||
Added int `json:"added"`
|
||||
Requeued int `json:"requeued"`
|
||||
Reordered int `json:"reordered"`
|
||||
SkippedInProgress int `json:"skipped_in_progress"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// Finished reports a job that has run to a terminal state (as opposed to
|
||||
// "idle" = never started, or still running).
|
||||
func (s scanStatusDTO) Finished() bool {
|
||||
if s.Running {
|
||||
return false
|
||||
}
|
||||
switch s.State {
|
||||
case "done", "error", "cancelled":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StateLabel is the Russian description of the job's state.
|
||||
func (s scanStatusDTO) StateLabel() string {
|
||||
switch s.State {
|
||||
case "clearing":
|
||||
return "очистка"
|
||||
case "listing":
|
||||
return "читаются страницы"
|
||||
case "enqueuing":
|
||||
return "ставятся в очередь"
|
||||
case "done":
|
||||
return "готово"
|
||||
case "error":
|
||||
return "ошибка"
|
||||
case "cancelled":
|
||||
return "отменено"
|
||||
case "idle", "":
|
||||
return "нет активного сканирования"
|
||||
default:
|
||||
return s.State
|
||||
}
|
||||
}
|
||||
|
||||
// PillClass picks the pill style for the state.
|
||||
func (s scanStatusDTO) PillClass() string {
|
||||
switch s.State {
|
||||
case "done":
|
||||
return "pill-success"
|
||||
case "error":
|
||||
return "pill-danger"
|
||||
case "cancelled":
|
||||
return "pill-cancel"
|
||||
case "clearing", "listing", "enqueuing":
|
||||
return "pill-info"
|
||||
default:
|
||||
return "pill-neutral"
|
||||
}
|
||||
}
|
||||
|
||||
// Handled is how many of the free addresses the enqueuing phase has already
|
||||
// processed.
|
||||
func (s scanStatusDTO) Handled() int {
|
||||
return s.Added + s.Requeued + s.Reordered + s.SkippedInProgress
|
||||
}
|
||||
|
||||
// Indeterminate is true while the amount of work is not known yet.
|
||||
func (s scanStatusDTO) Indeterminate() bool {
|
||||
return s.State == "clearing" || s.State == "listing" || (s.State == "enqueuing" && s.Free <= 0)
|
||||
}
|
||||
|
||||
// Elapsed is the human-readable run time: until now while running, until
|
||||
// finished_at afterwards; empty when the job never started.
|
||||
func (s scanStatusDTO) Elapsed() string {
|
||||
if s.StartedAt == nil {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if !s.Running && s.FinishedAt != nil {
|
||||
end = *s.FinishedAt
|
||||
}
|
||||
return fmtDuration(end.Sub(*s.StartedAt))
|
||||
}
|
||||
|
||||
// fmtDuration renders a duration in Russian as "2 ч 05 мин", "3 мин 07 с" or
|
||||
// "42 с" — coarse on purpose (progress/ETA display).
|
||||
func fmtDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
sec := int(d.Round(time.Second) / time.Second)
|
||||
h, m, sc := sec/3600, (sec%3600)/60, sec%60
|
||||
switch {
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%d ч %02d мин", h, m)
|
||||
case m > 0:
|
||||
return fmt.Sprintf("%d мин %02d с", m, sc)
|
||||
default:
|
||||
return fmt.Sprintf("%d с", sc)
|
||||
}
|
||||
}
|
||||
|
||||
// registryItem is one row of the durable per-address registry — see
|
||||
@@ -121,11 +267,53 @@ type registryItem struct {
|
||||
LastCheckedAt *time.Time `json:"last_checked_at"`
|
||||
InQueue bool `json:"in_queue"`
|
||||
CurrentState string `json:"current_state"`
|
||||
// LastCycleID is the cycle counted by Egress and Ingress (0 = no checks).
|
||||
LastCycleID int `json:"last_cycle_id"`
|
||||
Egress levelResult `json:"egress"`
|
||||
Ingress levelResult `json:"ingress"`
|
||||
}
|
||||
|
||||
// levelResult is "ok of total" recorded checks of one level (egress or
|
||||
// ingress) in the last cycle, split by check family — see httpapi's
|
||||
// levelResultDTO.
|
||||
type levelResult struct {
|
||||
Total int `json:"total"`
|
||||
OK int `json:"ok"`
|
||||
ByType []typeStat `json:"by_type"`
|
||||
}
|
||||
|
||||
type typeStat struct {
|
||||
Type string `json:"type"`
|
||||
Total int `json:"total"`
|
||||
OK int `json:"ok"`
|
||||
}
|
||||
|
||||
// statClass picks the colour of an "ok of total" figure: all checks passed,
|
||||
// none passed, some passed, or nothing recorded.
|
||||
func statClass(ok, total int) string {
|
||||
switch {
|
||||
case total == 0:
|
||||
return "none"
|
||||
case ok == total:
|
||||
return "ok"
|
||||
case ok == 0:
|
||||
return "fail"
|
||||
}
|
||||
return "part"
|
||||
}
|
||||
|
||||
// Class is the CSS modifier for the level's total.
|
||||
func (l levelResult) Class() string { return statClass(l.OK, l.Total) }
|
||||
|
||||
// Class is the CSS modifier for one check family.
|
||||
func (t typeStat) Class() string { return statClass(t.OK, t.Total) }
|
||||
|
||||
type registryHistoryResponse struct {
|
||||
Registry registryItem `json:"registry"`
|
||||
Checks []check `json:"checks"`
|
||||
// SelfCheckFailedOn lists the validators whose self-check of this address
|
||||
// failed, over every run.
|
||||
SelfCheckFailedOn []string `json:"self_check_failed_on"`
|
||||
}
|
||||
|
||||
type validatorDTO struct {
|
||||
@@ -162,6 +350,7 @@ type errorResponse struct {
|
||||
type orchestratorSettingsDTO struct {
|
||||
FIPSettleSeconds int `json:"fip_settle_seconds"`
|
||||
HistoryRetentionCycles int `json:"history_retention_cycles"`
|
||||
SelfCheckMaxAttempts int `json:"self_check_max_attempts"`
|
||||
}
|
||||
|
||||
type inboundChecksDTO struct {
|
||||
@@ -200,6 +389,8 @@ func (a autoCycleDTO) MaxRunMinutes() string { return secondsToMinutes(a.MaxRu
|
||||
// PhaseLabel is the Russian description of the current phase.
|
||||
func (a autoCycleDTO) PhaseLabel() string {
|
||||
switch a.Phase {
|
||||
case "scanning":
|
||||
return "сканирование Floating IP"
|
||||
case "running":
|
||||
return "идёт проверка"
|
||||
case "waiting":
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runOption is one entry of the run selector.
|
||||
type runOption struct {
|
||||
ID int64
|
||||
Label string
|
||||
Disabled bool
|
||||
Selected bool
|
||||
}
|
||||
|
||||
type analyticsPageData struct {
|
||||
PageData
|
||||
Runs []runOption
|
||||
RunID int64
|
||||
PrevURL string // older run, "" when there is none
|
||||
NextURL string // newer run
|
||||
// DataJSON is the page's data for analytics.js (run meta, labels, report),
|
||||
// HTML-safe JSON.
|
||||
DataJSON template.JS
|
||||
HasRun bool
|
||||
}
|
||||
|
||||
// analyticsMeta is what analytics.js needs besides the report.
|
||||
type analyticsMeta struct {
|
||||
RunID int64 `json:"run_id"`
|
||||
Kind string `json:"kind"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Duration string `json:"duration"`
|
||||
Rechecked int `json:"rechecked"`
|
||||
ListURL string `json:"list_url"`
|
||||
CSVURL string `json:"csv_url"`
|
||||
Registry string `json:"registry_url"`
|
||||
}
|
||||
|
||||
// handleAnalyticsPage renders the analytics of one finished run: ?run=ID, by
|
||||
// default the newest finished run. The run selector lists every run, the open
|
||||
// one disabled; nothing of any other run is on the page.
|
||||
func (s *Server) handleAnalyticsPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := analyticsPageData{}
|
||||
data.ActiveNav = "analytics"
|
||||
|
||||
runs, err := s.CA.ListAnalyticsRuns(r.Context())
|
||||
if err != nil {
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
return
|
||||
}
|
||||
want, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
|
||||
var chosen *analyticsRun
|
||||
for i := range runs { // newest first
|
||||
if runs[i].State != "finalized" || runs[i].Addresses == 0 {
|
||||
continue
|
||||
}
|
||||
if want == 0 || runs[i].ID == want {
|
||||
chosen = &runs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
var finished []analyticsRun // newest first
|
||||
for _, x := range runs {
|
||||
if x.State == "finalized" && x.Addresses > 0 {
|
||||
finished = append(finished, x)
|
||||
}
|
||||
}
|
||||
for _, x := range runs {
|
||||
if x.State == "finalized" && x.Addresses == 0 {
|
||||
continue // nothing to show for it, hide
|
||||
}
|
||||
opt := runOption{ID: x.ID, Label: runLabel(x), Disabled: x.State != "finalized"}
|
||||
if chosen != nil && x.ID == chosen.ID {
|
||||
opt.Selected = true
|
||||
}
|
||||
data.Runs = append(data.Runs, opt)
|
||||
}
|
||||
if chosen == nil {
|
||||
if want != 0 {
|
||||
data.Banner = bannerData{Message: fmt.Sprintf("Запуск %d не найден или ещё не завершён.", want), Client: true}
|
||||
}
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
return
|
||||
}
|
||||
data.RunID = chosen.ID
|
||||
for i, x := range finished {
|
||||
if x.ID == chosen.ID {
|
||||
if i+1 < len(finished) {
|
||||
data.PrevURL = "/analytics?run=" + strconv.FormatInt(finished[i+1].ID, 10)
|
||||
}
|
||||
if i > 0 {
|
||||
data.NextURL = "/analytics?run=" + strconv.FormatInt(finished[i-1].ID, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report, err := s.CA.GetAnalyticsReport(r.Context(), chosen.ID)
|
||||
if err != nil {
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
return
|
||||
}
|
||||
var info struct {
|
||||
Run struct {
|
||||
Rechecked int `json:"rechecked"`
|
||||
} `json:"run"`
|
||||
}
|
||||
_ = json.Unmarshal(report, &info)
|
||||
id := strconv.FormatInt(chosen.ID, 10)
|
||||
meta := analyticsMeta{
|
||||
RunID: chosen.ID, Kind: kindLabel(chosen.Kind),
|
||||
Start: fmtShort(chosen.StartedAt), Duration: "—", Rechecked: info.Run.Rechecked,
|
||||
ListURL: "/analytics/lists/",
|
||||
CSVURL: "/analytics/csv/",
|
||||
Registry: "/registry?run=" + id,
|
||||
}
|
||||
if chosen.FinalizedAt != nil {
|
||||
meta.End = fmtShort(*chosen.FinalizedAt)
|
||||
meta.Duration = fmtRunDuration(chosen.FinalizedAt.Sub(chosen.StartedAt))
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Meta analyticsMeta `json:"meta"`
|
||||
Report json.RawMessage `json:"report"`
|
||||
}{meta, report})
|
||||
if err != nil {
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
return
|
||||
}
|
||||
data.HasRun = true
|
||||
data.DataJSON = template.JS(payload)
|
||||
s.renderPage(w, r, "analytics_page", data)
|
||||
}
|
||||
|
||||
func parseRunParam(r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
|
||||
return id, err == nil && id > 0
|
||||
}
|
||||
|
||||
// handleAnalyticsList proxies one address table of a run as JSON.
|
||||
func (s *Server) handleAnalyticsList(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseRunParam(r)
|
||||
if !ok {
|
||||
http.Error(w, "run is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
out, err := s.CA.GetAnalyticsList(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class"))
|
||||
if err != nil {
|
||||
writeProxyError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(out)
|
||||
}
|
||||
|
||||
// handleAnalyticsCSV proxies the CSV file of one address table as a download.
|
||||
func (s *Server) handleAnalyticsCSV(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseRunParam(r)
|
||||
if !ok {
|
||||
http.Error(w, "run is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body, disposition, err := s.CA.GetAnalyticsListCSV(r.Context(), id, r.PathValue("kind"), r.URL.Query().Get("class"))
|
||||
if err != nil {
|
||||
writeProxyError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
if disposition != "" {
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// analyticsComparePageData is the page that compares two finished runs.
|
||||
type analyticsComparePageData struct {
|
||||
PageData
|
||||
RunsA, RunsB []runOption // finished runs; the chosen one of each is selected
|
||||
HasReport bool
|
||||
// DataJSON is the page's data for analytics-compare.js (run meta, the
|
||||
// comparison), HTML-safe JSON.
|
||||
DataJSON template.JS
|
||||
}
|
||||
|
||||
// compareRunMeta is how analytics-compare.js names one of the two runs.
|
||||
type compareRunMeta struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Duration string `json:"duration"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type compareMeta struct {
|
||||
Base compareRunMeta `json:"base"`
|
||||
Target compareRunMeta `json:"target"`
|
||||
ListURL string `json:"list_url"`
|
||||
CSVURL string `json:"csv_url"`
|
||||
}
|
||||
|
||||
func newCompareRunMeta(x analyticsRun) compareRunMeta {
|
||||
m := compareRunMeta{ID: x.ID, Kind: kindLabel(x.Kind), Start: fmtShort(x.StartedAt), End: "—", Duration: "—", Label: runLabel(x)}
|
||||
if x.FinalizedAt != nil {
|
||||
m.End = fmtShort(*x.FinalizedAt)
|
||||
m.Duration = fmtRunDuration(x.FinalizedAt.Sub(x.StartedAt))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// runIDParam reads a run id from a query value: set is false for an empty
|
||||
// value, ok for a positive number.
|
||||
func runIDParam(v string) (id int64, set, ok bool) {
|
||||
if v == "" {
|
||||
return 0, false, true
|
||||
}
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
return id, true, err == nil && id > 0
|
||||
}
|
||||
|
||||
// handleAnalyticsComparePage compares two finished runs: ?base=A (older) and
|
||||
// ?target=B (newer). By default B is the newest finished run and A the one
|
||||
// before it; with only target the run before it is the base, with only base
|
||||
// the newest other run is the target. The state of the page is its address.
|
||||
func (s *Server) handleAnalyticsComparePage(w http.ResponseWriter, r *http.Request) {
|
||||
data := analyticsComparePageData{}
|
||||
data.ActiveNav = "analytics"
|
||||
|
||||
runs, err := s.CA.ListAnalyticsRuns(r.Context())
|
||||
if err != nil {
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "analytics_compare_page", data)
|
||||
return
|
||||
}
|
||||
var finished []analyticsRun // newest first
|
||||
for _, x := range runs {
|
||||
if x.State == "finalized" && x.Addresses > 0 {
|
||||
finished = append(finished, x)
|
||||
}
|
||||
}
|
||||
if len(finished) < 2 {
|
||||
data.Banner = bannerData{Message: "Для сравнения нужно минимум два завершённых запуска с адресами.", Client: true}
|
||||
s.renderPage(w, r, "analytics_compare_page", data)
|
||||
return
|
||||
}
|
||||
find := func(id int64) *analyticsRun {
|
||||
for i := range finished {
|
||||
if finished[i].ID == id {
|
||||
return &finished[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
bid, bset, bok := runIDParam(r.URL.Query().Get("base"))
|
||||
tid, tset, tok := runIDParam(r.URL.Query().Get("target"))
|
||||
// The default pair is the newest run as B and the one before it as A.
|
||||
a, b := &finished[1], &finished[0]
|
||||
warn := ""
|
||||
switch {
|
||||
case !bok || !tok:
|
||||
warn = "Неверный номер запуска."
|
||||
case bset && tset && bid == tid:
|
||||
warn = "Выберите два разных запуска."
|
||||
case bset || tset:
|
||||
a, b = nil, nil
|
||||
if bset {
|
||||
if a = find(bid); a == nil {
|
||||
warn = fmt.Sprintf("Запуск %d не найден или ещё не завершён.", bid)
|
||||
}
|
||||
}
|
||||
if tset {
|
||||
if b = find(tid); b == nil {
|
||||
warn = fmt.Sprintf("Запуск %d не найден или ещё не завершён.", tid)
|
||||
}
|
||||
}
|
||||
if warn == "" {
|
||||
for i := range finished { // only one end is given: the other is next to it
|
||||
switch {
|
||||
case a == nil && i > 0 && finished[i-1].ID == b.ID:
|
||||
a = &finished[i]
|
||||
case b == nil && finished[i].ID != a.ID:
|
||||
b = &finished[i]
|
||||
}
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
warn = "Для этого запуска нет второго запуска для сравнения."
|
||||
}
|
||||
}
|
||||
}
|
||||
selA, selB := finished[1].ID, finished[0].ID
|
||||
if a != nil {
|
||||
selA = a.ID
|
||||
}
|
||||
if b != nil {
|
||||
selB = b.ID
|
||||
}
|
||||
for _, x := range finished {
|
||||
opt := runOption{ID: x.ID, Label: runLabel(x)}
|
||||
optA, optB := opt, opt
|
||||
optA.Selected, optB.Selected = x.ID == selA, x.ID == selB
|
||||
data.RunsA = append(data.RunsA, optA)
|
||||
data.RunsB = append(data.RunsB, optB)
|
||||
}
|
||||
if warn != "" {
|
||||
data.Banner = bannerData{Message: warn, Client: true}
|
||||
s.renderPage(w, r, "analytics_compare_page", data)
|
||||
return
|
||||
}
|
||||
|
||||
report, err := s.CA.GetAnalyticsCompare(r.Context(), a.ID, b.ID)
|
||||
if err != nil {
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "analytics_compare_page", data)
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Meta compareMeta `json:"meta"`
|
||||
Report json.RawMessage `json:"report"`
|
||||
}{compareMeta{Base: newCompareRunMeta(*a), Target: newCompareRunMeta(*b),
|
||||
ListURL: "/analytics/compare/lists/", CSVURL: "/analytics/compare/csv/"}, report})
|
||||
if err != nil {
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, r, "analytics_compare_page", data)
|
||||
return
|
||||
}
|
||||
data.HasReport = true
|
||||
data.DataJSON = template.JS(payload)
|
||||
s.renderPage(w, r, "analytics_compare_page", data)
|
||||
}
|
||||
|
||||
// compareParams reads the two run ids and the list filter of a proxied
|
||||
// comparison list; it writes the 400 itself when an id is missing.
|
||||
func compareParams(w http.ResponseWriter, r *http.Request) (base, target int64, f compareFilter, ok bool) {
|
||||
q := r.URL.Query()
|
||||
base, _ = strconv.ParseInt(q.Get("base"), 10, 64)
|
||||
target, _ = strconv.ParseInt(q.Get("target"), 10, 64)
|
||||
if base <= 0 || target <= 0 {
|
||||
http.Error(w, "base and target are required", http.StatusBadRequest)
|
||||
return 0, 0, f, false
|
||||
}
|
||||
return base, target, compareFilter{Indicator: q.Get("indicator"), From: q.Get("from"), To: q.Get("to")}, true
|
||||
}
|
||||
|
||||
// handleAnalyticsCompareList proxies one address table of the comparison as JSON.
|
||||
func (s *Server) handleAnalyticsCompareList(w http.ResponseWriter, r *http.Request) {
|
||||
base, target, f, ok := compareParams(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := s.CA.GetAnalyticsCompareList(r.Context(), base, target, r.PathValue("group"), f)
|
||||
if err != nil {
|
||||
writeProxyError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(out)
|
||||
}
|
||||
|
||||
// handleAnalyticsCompareCSV proxies the CSV file of one comparison table as a download.
|
||||
func (s *Server) handleAnalyticsCompareCSV(w http.ResponseWriter, r *http.Request) {
|
||||
base, target, f, ok := compareParams(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, disposition, err := s.CA.GetAnalyticsCompareListCSV(r.Context(), base, target, r.PathValue("group"), f)
|
||||
if err != nil {
|
||||
writeProxyError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
if disposition != "" {
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func writeProxyError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusBadGateway
|
||||
if ae, ok := err.(*apiErr); ok && ae.Status >= 400 && ae.Status < 500 {
|
||||
status = ae.Status
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
}
|
||||
|
||||
func kindLabel(kind string) string {
|
||||
if kind == "auto" {
|
||||
return "авто"
|
||||
}
|
||||
return "ручной"
|
||||
}
|
||||
|
||||
// groupThousands writes n with a space between thousands: 6440 -> "6 440".
|
||||
func groupThousands(n int) string {
|
||||
s := strconv.Itoa(n)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, c := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// fmtShort is a run timestamp as the page shows it: 02.10.2026 13:47.
|
||||
func fmtShort(t time.Time) string { return t.Local().Format("02.01.2006 15:04") }
|
||||
|
||||
// fmtDuration is "8 ч 42 мин", "42 мин", "под минуту".
|
||||
func fmtRunDuration(d time.Duration) string {
|
||||
m := int(d.Round(time.Minute) / time.Minute)
|
||||
switch {
|
||||
case m <= 0:
|
||||
return "меньше минуты"
|
||||
case m < 60:
|
||||
return fmt.Sprintf("%d мин", m)
|
||||
}
|
||||
return fmt.Sprintf("%d ч %d мин", m/60, m%60)
|
||||
}
|
||||
|
||||
// runLabel is the text of a run in the selector, e.g.
|
||||
// "02.10 13:47 → 22:29 · ручной · 6 440 адр. · 30% pass"; an open run reads
|
||||
// "03.10 15:30 → идёт · ручной · 120 из 800 · недоступен".
|
||||
func runLabel(x analyticsRun) string {
|
||||
start := x.StartedAt.Local().Format("02.01 15:04")
|
||||
if x.State != "finalized" {
|
||||
return fmt.Sprintf("%s → идёт · %s · %s из %s · недоступен", start, kindLabel(x.Kind),
|
||||
groupThousands(x.Total-x.Pending), groupThousands(x.Total))
|
||||
}
|
||||
end := "—"
|
||||
if x.FinalizedAt != nil {
|
||||
e := x.FinalizedAt.Local()
|
||||
if e.Format("02.01") == x.StartedAt.Local().Format("02.01") {
|
||||
end = e.Format("15:04")
|
||||
} else {
|
||||
end = e.Format("02.01 15:04")
|
||||
}
|
||||
}
|
||||
pass := 0
|
||||
if x.Addresses > 0 {
|
||||
pass = int(float64(x.Pass)/float64(x.Addresses)*100 + 0.5)
|
||||
}
|
||||
return fmt.Sprintf("%s → %s · %s · %s адр. · %d%% pass", start, end, kindLabel(x.Kind), groupThousands(x.Addresses), pass)
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fakeRun(id int64, state string, addresses, pass int) analyticsRun {
|
||||
start := time.Date(2026, 10, 2, 13, 47, 0, 0, time.UTC)
|
||||
end := start.Add(8*time.Hour + 42*time.Minute)
|
||||
r := analyticsRun{ID: id, Kind: "manual", State: state, StartedAt: start, Addresses: addresses, Pass: pass,
|
||||
Partial: addresses - pass, Total: addresses}
|
||||
if state == "finalized" {
|
||||
r.FinalizedAt = &end
|
||||
} else {
|
||||
r.Pending = 80
|
||||
r.Total = addresses + r.Pending
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// reportWith is the minimal report JSON the page needs; addresses is a marker
|
||||
// that tells the runs apart in the page source.
|
||||
func reportWith(addresses string) string {
|
||||
return `{"run":{"rechecked":0},"summary":{"addresses":` + addresses + `,"pass":1,"partial":0,"fail":0,"cancelled":0,` +
|
||||
`"egress_ok":1,"ingress_ok":1,"egress_https_any_failed":0,"egress_https_all_failed":0,"egress_https_all_targets_failed":0,` +
|
||||
`"ingress_ssh_any_failed":0,"ingress_ssh_all_failed":0,"addresses_per_minute":1},"reasons":[],"quality":{},"subnets":[],` +
|
||||
`"targets":{"types":[],"targets":[],"failed":{}},"matrix":{},"sites":{"types":[],"rows":[]},"errors":[],"validators":[]}`
|
||||
}
|
||||
|
||||
func analyticsFake(t *testing.T) (*fakeControlAPI, *httptest.Server) {
|
||||
t.Helper()
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
fake.runs = []analyticsRun{fakeRun(3, "open", 120, 40), fakeRun(2, "finalized", 900, 300), fakeRun(1, "finalized", 6440, 1962)}
|
||||
fake.reports = map[int64]string{1: reportWith("6440"), 2: reportWith("900")}
|
||||
fake.lists = map[string]string{
|
||||
"1/egress_https_any": `{"kind":"egress_https_any","columns":["Адрес","Подсеть"],"rows":[["1.2.3.4","1.2.3.0/24"]]}`,
|
||||
"1/error/SSH: таймаут": `{"kind":"error","class":"SSH: таймаут","columns":["Адрес"],"rows":[["1.2.3.4"]]}`,
|
||||
}
|
||||
return fake, newTestServer(t, caURL)
|
||||
}
|
||||
|
||||
// The page shows one run, by default the newest finished one, and asks
|
||||
// control-api for that run only; the selector lists every run, the open one
|
||||
// disabled.
|
||||
func TestAnalyticsPageShowsOneRun(t *testing.T) {
|
||||
fake, ts := analyticsFake(t)
|
||||
|
||||
page := get(t, ts, "/analytics")
|
||||
for _, want := range []string{
|
||||
`id="an-run"`, `id="analytics-data"`, `"addresses":900`, // newest finished run (2)
|
||||
"идёт · ручной · 120 из 200 · недоступен",
|
||||
"6 440 адр. · 30% pass", // run 1's option, from the run list
|
||||
`/analytics?run=1`, // the older run is one step back
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in the page, got:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, `"addresses":6440`) {
|
||||
t.Fatalf("the data of run 1 is on the page of run 2")
|
||||
}
|
||||
if !strings.Contains(page, `value="3" disabled`) {
|
||||
t.Fatalf("the open run must be listed but disabled:\n%s", page)
|
||||
}
|
||||
for _, r := range fake.analyticsReqs {
|
||||
if strings.Contains(r, "/runs/1") || strings.Contains(r, "/runs/3") {
|
||||
t.Fatalf("the page must request only the chosen run, got %v", fake.analyticsReqs)
|
||||
}
|
||||
}
|
||||
|
||||
other := get(t, ts, "/analytics?run=1")
|
||||
if !strings.Contains(other, `"addresses":6440`) || strings.Contains(other, `"addresses":900`) {
|
||||
t.Fatalf("run 1 page must carry only run 1's data:\n%s", other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsPageWithoutRunsAndWithUnknownRun(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
ts := newTestServer(t, caURL)
|
||||
page := get(t, ts, "/analytics")
|
||||
if !strings.Contains(page, "Запусков проверки пока нет") || strings.Contains(page, `id="analytics-data"`) {
|
||||
t.Fatalf("expected the empty state, got:\n%s", page)
|
||||
}
|
||||
|
||||
_, ts = analyticsFake(t)
|
||||
for _, run := range []string{"99", "3"} { // unknown, and the open one
|
||||
page = get(t, ts, "/analytics?run="+run)
|
||||
if !strings.Contains(page, "не найден или ещё не завершён") {
|
||||
t.Fatalf("run %s: expected a warning, got:\n%s", run, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsListProxyAndCSV(t *testing.T) {
|
||||
_, ts := analyticsFake(t)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/analytics/lists/egress_https_any?run=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"1.2.3.4"`) {
|
||||
t.Fatalf("list: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
q := url.Values{"run": {"1"}, "class": {"SSH: таймаут"}}
|
||||
resp, err = http.Get(ts.URL + "/analytics/lists/error?" + q.Encode())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"class":"SSH: таймаут"`) {
|
||||
t.Fatalf("error list: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, err = http.Get(ts.URL + "/analytics/csv/egress_https_any?run=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/csv") ||
|
||||
!strings.Contains(resp.Header.Get("Content-Disposition"), `attachment; filename="egress_https_any_run1.csv"`) {
|
||||
t.Fatalf("csv: %d %v %s", resp.StatusCode, resp.Header, body)
|
||||
}
|
||||
|
||||
for path, want := range map[string]int{
|
||||
"/analytics/lists/egress_https_any": http.StatusBadRequest, // no run
|
||||
"/analytics/csv/egress_https_any?run=abc": http.StatusBadRequest,
|
||||
"/analytics/lists/nonsense?run=1": http.StatusNotFound, // control-api says unknown list
|
||||
} {
|
||||
resp, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("%s: %d, want %d", path, resp.StatusCode, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compareWith is the minimal comparison JSON the page needs; new is a marker
|
||||
// that tells the pairs of runs apart in the page source.
|
||||
func compareWith(newAddrs string) string {
|
||||
return `{"runs":{"base":{"id":1,"rechecked":0,"addresses":6440},"target":{"id":2,"rechecked":0,"addresses":900}},` +
|
||||
`"groups":{"new":` + newAddrs + `,"left":2,"common":3,"changed":1,"same":2},"indicators":[],` +
|
||||
`"transitions":{"verdicts":["pass","partial","fail"],"matrix":[[0,0,0],[0,0,0],[0,0,0]],"new":[0,0,0],"left":[0,0,0]},"cancelled":{"base":0,"target":0}}`
|
||||
}
|
||||
|
||||
// compareFake is analyticsFake with the comparisons of runs 1 and 2 (run 3 is open).
|
||||
func compareFake(t *testing.T) (*fakeControlAPI, *httptest.Server) {
|
||||
t.Helper()
|
||||
fake, ts := analyticsFake(t)
|
||||
fake.compares = map[string]string{"1-2": compareWith("111"), "2-1": compareWith("222")}
|
||||
fake.compareLists = map[string]string{
|
||||
"1-2/changed": `{"group":"changed","columns":["Адрес","Подсеть"],"rows":[["1.2.3.4","1.2.3.0/24"]]}`,
|
||||
"1-2/new/verdict_pass": `{"group":"new","indicator":"verdict_pass","columns":["Адрес"],"rows":[["5.6.7.8"]]}`,
|
||||
}
|
||||
return fake, ts
|
||||
}
|
||||
|
||||
func compareRequests(fake *fakeControlAPI) []string {
|
||||
var out []string
|
||||
for _, r := range fake.analyticsReqs {
|
||||
if strings.Contains(r, "/compare") {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// By default B is the newest finished run and A the one before it; the open
|
||||
// run is not offered; the page asks control-api for that pair only.
|
||||
func TestAnalyticsComparePageDefaultPair(t *testing.T) {
|
||||
fake, ts := compareFake(t)
|
||||
|
||||
page := get(t, ts, "/analytics/compare")
|
||||
for _, want := range []string{
|
||||
`id="an-base"`, `id="an-target"`, `id="an-swap"`, `id="analytics-data"`, `"new":111`,
|
||||
`<option value="1" selected>`, `<option value="2" selected>`, // A is run 1, B is run 2
|
||||
`id="an-dlg"`, `/static/analytics-dialog.js`, `/static/analytics-compare.js`,
|
||||
"6\u00a0440 адр. · 30% pass",
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in the page, got:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
if strings.Count(page, `<option value="1" selected>`) != 1 || strings.Count(page, `<option value="2" selected>`) != 1 {
|
||||
t.Fatalf("one run is chosen in each list:\n%s", page)
|
||||
}
|
||||
if strings.Contains(page, `value="3"`) {
|
||||
t.Fatalf("an open run must not be offered:\n%s", page)
|
||||
}
|
||||
if got := compareRequests(fake); len(got) != 1 || !strings.Contains(got[0], "base=1") || !strings.Contains(got[0], "target=2") {
|
||||
t.Fatalf("expected one request for base=1&target=2, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The pair in the address: both ends, only the new one (the base is the run
|
||||
// before it), only the old one (the target is the newest other run).
|
||||
func TestAnalyticsComparePageExplicitPair(t *testing.T) {
|
||||
fake, ts := compareFake(t)
|
||||
|
||||
page := get(t, ts, "/analytics/compare?base=2&target=1")
|
||||
if !strings.Contains(page, `"new":222`) || !strings.Contains(page, `<option value="2" selected>`) {
|
||||
t.Fatalf("swapped pair:\n%s", page)
|
||||
}
|
||||
if page = get(t, ts, "/analytics/compare?target=2"); !strings.Contains(page, `"new":111`) {
|
||||
t.Fatalf("only target=2 must compare with run 1:\n%s", page)
|
||||
}
|
||||
if page = get(t, ts, "/analytics/compare?base=2"); !strings.Contains(page, `"new":222`) {
|
||||
t.Fatalf("only base=2 must compare with the newest other run, 1:\n%s", page)
|
||||
}
|
||||
if page = get(t, ts, "/analytics/compare?target=1"); strings.Contains(page, `id="analytics-data"`) ||
|
||||
!strings.Contains(page, "нет второго запуска") {
|
||||
t.Fatalf("run 1 is the oldest, nothing to compare it with:\n%s", page)
|
||||
}
|
||||
if got := compareRequests(fake); len(got) != 3 {
|
||||
t.Fatalf("the refused pair must not reach control-api, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsComparePageWarnings(t *testing.T) {
|
||||
fake, ts := compareFake(t)
|
||||
for _, c := range []struct{ query, want string }{
|
||||
{"base=1&target=1", "Выберите два разных запуска"},
|
||||
{"base=99&target=1", "Запуск 99 не найден или ещё не завершён"},
|
||||
// the open run
|
||||
{"base=1&target=3", "Запуск 3 не найден или ещё не завершён"},
|
||||
{"base=abc&target=1", "Неверный номер запуска"},
|
||||
{"base=0&target=1", "Неверный номер запуска"},
|
||||
} {
|
||||
page := get(t, ts, "/analytics/compare?"+c.query)
|
||||
if !strings.Contains(page, c.want) || strings.Contains(page, `id="analytics-data"`) || !strings.Contains(page, `id="an-base"`) {
|
||||
t.Fatalf("%s: expected the warning %q and the run lists without data, got:\n%s", c.query, c.want, page)
|
||||
}
|
||||
}
|
||||
if got := compareRequests(fake); len(got) != 0 {
|
||||
t.Fatalf("a bad pair must not reach control-api, got %v", got)
|
||||
}
|
||||
|
||||
// Fewer than two finished runs: nothing to compare.
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
fake.runs = []analyticsRun{fakeRun(3, "open", 120, 40), fakeRun(1, "finalized", 6440, 1962)}
|
||||
page := get(t, newTestServer(t, caURL), "/analytics/compare")
|
||||
if !strings.Contains(page, "минимум два завершённых запуска") || strings.Contains(page, `id="an-base"`) {
|
||||
t.Fatalf("expected the empty state, got:\n%s", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsCompareListProxyAndCSV(t *testing.T) {
|
||||
_, ts := compareFake(t)
|
||||
fetch := func(path string) (int, http.Header, string) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode, resp.Header, string(body)
|
||||
}
|
||||
|
||||
code, _, body := fetch("/analytics/compare/lists/changed?base=1&target=2")
|
||||
if code != http.StatusOK || !strings.Contains(body, `"1.2.3.4"`) {
|
||||
t.Fatalf("list: %d %s", code, body)
|
||||
}
|
||||
// The indicator and the verdicts travel to control-api.
|
||||
code, _, body = fetch("/analytics/compare/lists/new?base=1&target=2&indicator=verdict_pass")
|
||||
if code != http.StatusOK || !strings.Contains(body, `"5.6.7.8"`) {
|
||||
t.Fatalf("list with an indicator: %d %s", code, body)
|
||||
}
|
||||
code, header, body := fetch("/analytics/compare/csv/changed?base=1&target=2")
|
||||
if code != http.StatusOK || !strings.HasPrefix(header.Get("Content-Type"), "text/csv") ||
|
||||
!strings.Contains(header.Get("Content-Disposition"), `attachment; filename="compare_changed_run1-2.csv"`) {
|
||||
t.Fatalf("csv: %d %v %s", code, header, body)
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
// no runs
|
||||
{"/analytics/compare/lists/changed", http.StatusBadRequest},
|
||||
{"/analytics/compare/lists/changed?base=1", http.StatusBadRequest},
|
||||
{"/analytics/compare/csv/changed?base=abc&target=2", http.StatusBadRequest},
|
||||
// control-api says unknown list
|
||||
{"/analytics/compare/lists/nonsense?base=1&target=2", http.StatusNotFound},
|
||||
{"/analytics/compare/csv/nonsense?base=1&target=2", http.StatusNotFound},
|
||||
} {
|
||||
if code, _, _ := fetch(c.path); code != c.want {
|
||||
t.Errorf("%s: %d, want %d", c.path, code, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The single-run page after the dialog moved to analytics-dialog.js: it still
|
||||
// carries the dialog, loads the shared script before its own, offers the
|
||||
// comparison, and both scripts are served.
|
||||
func TestAnalyticsPageUsesSharedDialog(t *testing.T) {
|
||||
_, ts := analyticsFake(t)
|
||||
|
||||
page := get(t, ts, "/analytics")
|
||||
iDialog, iPage := strings.Index(page, "/static/analytics-dialog.js"), strings.Index(page, "/static/analytics.js")
|
||||
if iDialog < 0 || iPage < 0 || iDialog > iPage {
|
||||
t.Fatalf("analytics-dialog.js must come before analytics.js (%d, %d):\n%s", iDialog, iPage, page)
|
||||
}
|
||||
for _, want := range []string{`id="an-dlg"`, `id="an-dlg-tbl"`, `id="an-dlg-csv"`, `id="an-tip"`, `href="/analytics/compare?target=2"`} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in the page, got:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
"/static/analytics-dialog.js": "window.AnalyticsDialog =",
|
||||
"/static/analytics.js": "window.AnalyticsDialog",
|
||||
"/static/analytics-compare.js": "window.AnalyticsDialog",
|
||||
} {
|
||||
if body := get(t, ts, path); !strings.Contains(body, want) {
|
||||
t.Errorf("%s does not contain %q", path, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sidebar: no window-chrome dots next to the logo, the Analytics entry,
|
||||
// and the link state, theme toggle and logout above the navigation.
|
||||
func TestSidebarSessionBlockOnTop(t *testing.T) {
|
||||
_, caURL := newFakeControlAPI(t)
|
||||
_, ts := newAuthTestServer(t, caURL, nil)
|
||||
cookie := login(t, ts)
|
||||
_, page := doReq(t, ts, reqOpts{path: "/overview", cookie: cookie})
|
||||
|
||||
if strings.Contains(page, "brand-chrome") {
|
||||
t.Fatalf("the three dots next to the logo are back")
|
||||
}
|
||||
iBrand := strings.Index(page, `class="brand"`)
|
||||
iAPI := strings.Index(page, `class="session-api"`)
|
||||
iLogout := strings.Index(page, `action="/logout"`)
|
||||
iNav := strings.Index(page, `class="nav-groups"`)
|
||||
iFoot := strings.Index(page, "sidebar-foot")
|
||||
if !(iBrand >= 0 && iBrand < iAPI && iAPI < iLogout && iLogout < iNav) || iFoot >= 0 {
|
||||
t.Fatalf("expected brand < control-api state < logout < navigation (and no old footer): %d %d %d %d foot=%d", iBrand, iAPI, iLogout, iNav, iFoot)
|
||||
}
|
||||
for _, link := range []string{`href="/analytics"`, `href="/registry"`, `href="/settings"`} {
|
||||
if !strings.Contains(page, link) {
|
||||
t.Fatalf("missing nav link %s", link)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, "pulse-dot down") {
|
||||
t.Fatalf("the link state must be fine while control-api answers")
|
||||
}
|
||||
}
|
||||
|
||||
// The link indicator turns red when control-api cannot be reached.
|
||||
func TestSidebarShowsLostControlAPI(t *testing.T) {
|
||||
ts := newTestServer(t, "http://127.0.0.1:1") // nothing listens there
|
||||
page := get(t, ts, "/overview")
|
||||
if !strings.Contains(page, "pulse-dot down") || !strings.Contains(page, "нет связи") {
|
||||
t.Fatalf("expected the lost-link indicator, got:\n%s", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsSubnetsForm(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
ts := newTestServer(t, caURL)
|
||||
|
||||
body := postForm(t, ts, "PUT", "/settings/subnets", url.Values{"subnets": {"83.166.248.0/21 москва\n\n 10.0.0.0/8\n"}})
|
||||
if len(fake.subnets.Subnets) != 2 ||
|
||||
fake.subnets.Subnets[0] != (subnetEntry{CIDR: "83.166.248.0/21", Label: "москва"}) ||
|
||||
fake.subnets.Subnets[1] != (subnetEntry{CIDR: "10.0.0.0/8"}) {
|
||||
t.Fatalf("subnets saved: %+v", fake.subnets)
|
||||
}
|
||||
if !strings.Contains(body, "83.166.248.0/21 москва") || !strings.Contains(body, "10.0.0.0/8") {
|
||||
t.Fatalf("the saved list must come back in the form:\n%s", body)
|
||||
}
|
||||
|
||||
body = postForm(t, ts, "PUT", "/settings/subnets", url.Values{"subnets": {"not-a-cidr"}})
|
||||
if !strings.Contains(body, "alert-warning") || !strings.Contains(body, "not a CIDR") {
|
||||
t.Fatalf("expected the validation error in the banner:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The drill-down from the analytics page: run and subnet go to control-api,
|
||||
// come back as hidden fields and a visible chip; a malformed subnet is dropped.
|
||||
func TestRegistryDrillDownFromAnalytics(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
ts := newTestServer(t, caURL)
|
||||
|
||||
page := get(t, ts, "/registry?run=2&subnet="+url.QueryEscape("10.0.0.0/24"))
|
||||
if len(fake.registryQueries) == 0 {
|
||||
t.Fatal("no registry request")
|
||||
}
|
||||
last := fake.registryQueries[len(fake.registryQueries)-1]
|
||||
if !strings.Contains(last, "run=2") || !strings.Contains(last, "subnet=10.0.0.0%2F24") {
|
||||
t.Fatalf("control-api request %q lacks the run or subnet", last)
|
||||
}
|
||||
for _, want := range []string{`name="run" value="2"`, `name="subnet" value="10.0.0.0/24"`, "запуск 2", "подсеть 10.0.0.0/24", "сбросить фильтр"} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("expected %q in:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
|
||||
page = get(t, ts, "/registry?subnet=garbage")
|
||||
last = fake.registryQueries[len(fake.registryQueries)-1]
|
||||
if strings.Contains(last, "subnet=") || strings.Contains(page, "сбросить фильтр") {
|
||||
t.Fatalf("a malformed subnet must be ignored: %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
// The filter of a comparison list goes to control-api as it is.
|
||||
func TestAnalyticsCompareListPath(t *testing.T) {
|
||||
got := analyticsCompareListPath(1, 2, "common", compareFilter{Indicator: "verdict_pass", From: "partial", To: "pass"}, true)
|
||||
want := "/api/v1/admin/analytics/compare/lists/common?base=1&format=csv&from=partial&indicator=verdict_pass&target=2&to=pass"
|
||||
if got != want {
|
||||
t.Errorf("path = %q, want %q", got, want)
|
||||
}
|
||||
if got := analyticsCompareListPath(3, 4, "new", compareFilter{}, false); got != "/api/v1/admin/analytics/compare/lists/new?base=3&target=4" {
|
||||
t.Errorf("path = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func (s *Server) handleCheckTypesPage(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.loadCheckTypesPage(r)
|
||||
data.ActiveNav = "check-types"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, "checktypes_page", data)
|
||||
s.renderPage(w, r, "checktypes_page", data)
|
||||
}
|
||||
|
||||
func (s *Server) renderCheckTypesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
|
||||
|
||||
@@ -1,14 +1,122 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// bulkChunk is how many addresses go into one DeleteIPs/SubmitIPs call when
|
||||
// an operation spans a whole filter ("scope=all").
|
||||
const bulkChunk = 500
|
||||
|
||||
// ipStates are the queue states control-api accepts in the `state` filter.
|
||||
var ipStates = []string{"queued", "assigning_fip", "awaiting_self_check", "checking", "aggregating", "done", "failed", "occupied"}
|
||||
|
||||
var ipResults = []string{"pass", "partial", "fail", "cancelled"}
|
||||
|
||||
func containsStr(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ipsFilter is the server-side filter of the /ips list. State is a filter
|
||||
// token: one of "", "queued", "active" (every in-progress state), "done",
|
||||
// "failed", "occupied", or a comma-separated list of raw queue states; the
|
||||
// result filter is separate (Result) and may be combined with it.
|
||||
type ipsFilter struct {
|
||||
Q string
|
||||
State string
|
||||
Result string
|
||||
}
|
||||
|
||||
// parseIPsFilter reads q/state/result from the request (query or form body).
|
||||
// The state <select> posts a single `state` value, so "result:pass" selects a
|
||||
// result filter; anything unrecognised is dropped rather than forwarded.
|
||||
func parseIPsFilter(r *http.Request) ipsFilter {
|
||||
f := ipsFilter{
|
||||
Q: strings.TrimSpace(r.FormValue("q")),
|
||||
State: strings.TrimSpace(r.FormValue("state")),
|
||||
Result: strings.TrimSpace(r.FormValue("result")),
|
||||
}
|
||||
if res, ok := strings.CutPrefix(f.State, "result:"); ok {
|
||||
f.State, f.Result = "", res
|
||||
}
|
||||
if !containsStr(ipResults, f.Result) {
|
||||
f.Result = ""
|
||||
}
|
||||
switch f.State {
|
||||
case "", "active":
|
||||
default:
|
||||
for _, st := range strings.Split(f.State, ",") {
|
||||
if !containsStr(ipStates, st) {
|
||||
f.State = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// States expands the state token into the list sent to control-api.
|
||||
func (f ipsFilter) States() []string {
|
||||
switch f.State {
|
||||
case "":
|
||||
return nil
|
||||
case "active":
|
||||
return activeStates
|
||||
default:
|
||||
return strings.Split(f.State, ",")
|
||||
}
|
||||
}
|
||||
|
||||
// Active reports whether any filter is applied.
|
||||
func (f ipsFilter) Active() bool { return f.Q != "" || f.State != "" || f.Result != "" }
|
||||
|
||||
// Token is the value of the state <select> option matching the filter.
|
||||
func (f ipsFilter) Token() string {
|
||||
if f.State == "" && f.Result != "" {
|
||||
return "result:" + f.Result
|
||||
}
|
||||
return f.State
|
||||
}
|
||||
|
||||
func (f ipsFilter) values() url.Values {
|
||||
v := url.Values{}
|
||||
if f.Q != "" {
|
||||
v.Set("q", f.Q)
|
||||
}
|
||||
if f.State != "" {
|
||||
v.Set("state", f.State)
|
||||
}
|
||||
if f.Result != "" {
|
||||
v.Set("result", f.Result)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type ipsPageData struct {
|
||||
PageData
|
||||
Items []ipQueueItem
|
||||
FIPSettleSeconds int
|
||||
Filter ipsFilter
|
||||
Page, PerPage int
|
||||
// Total is the number of rows matching the filter; QueueTotal is the whole
|
||||
// queue (what "Очистить всё" would remove).
|
||||
Total, QueueTotal int
|
||||
Pager pagerData
|
||||
// SelfURL is this page's own URL (filter + page), re-requested to reload
|
||||
// the table when a scan finishes.
|
||||
SelfURL string
|
||||
PerPageOptions []int
|
||||
Scan scanProgressData
|
||||
}
|
||||
|
||||
type ipDetailData struct {
|
||||
@@ -16,16 +124,70 @@ type ipDetailData struct {
|
||||
Detail ipDetailResponse
|
||||
}
|
||||
|
||||
func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.CA.ListIPs(r.Context())
|
||||
settings, settingsErr := s.CA.GetOrchestratorSettings(r.Context())
|
||||
// loadIPsData fetches the page of the queue selected by the request's
|
||||
// page/per_page/q/state/result params. A page that became empty (e.g. after
|
||||
// deleting its last rows) is clamped to the last non-empty page.
|
||||
func (s *Server) loadIPsData(r *http.Request) (ipsPageData, error) {
|
||||
ctx := r.Context()
|
||||
f := parseIPsFilter(r)
|
||||
perPage := parsePerPage(r.FormValue("per_page"))
|
||||
page := parsePage(r.FormValue("page"))
|
||||
q := ipsQuery{States: f.States(), Q: f.Q, Result: f.Result, Order: "sequence", Limit: perPage, Offset: (page - 1) * perPage}
|
||||
|
||||
res, err := s.CA.ListIPsPage(ctx, q)
|
||||
if err == nil {
|
||||
err = settingsErr
|
||||
if clamped := clampPage(page, res.Total, perPage); clamped != page {
|
||||
page = clamped
|
||||
q.Offset = (page - 1) * perPage
|
||||
res, err = s.CA.ListIPsPage(ctx, q)
|
||||
}
|
||||
}
|
||||
data := ipsPageData{
|
||||
Items: res.Items,
|
||||
Filter: f,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
Total: res.Total,
|
||||
QueueTotal: res.Total,
|
||||
PerPageOptions: perPageOptions,
|
||||
}
|
||||
data.Pager = newPager("/ips", "ips-table-wrap", f.values(), page, perPage, res.Total)
|
||||
data.SelfURL = pageURL("/ips", f.values(), page, perPage)
|
||||
|
||||
if settings, settingsErr := s.CA.GetOrchestratorSettings(ctx); settingsErr != nil {
|
||||
if err == nil {
|
||||
err = settingsErr
|
||||
}
|
||||
} else {
|
||||
data.FIPSettleSeconds = settings.FIPSettleSeconds
|
||||
}
|
||||
if err == nil && f.Active() {
|
||||
// "Очистить всё" ignores the filter: show the real queue size in its
|
||||
// confirmation. Non-fatal — the label just falls back to the filtered total.
|
||||
if st, stErr := s.CA.Status(ctx); stErr == nil {
|
||||
data.QueueTotal = st.TotalIPs
|
||||
}
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.loadIPsData(r)
|
||||
// A filter/pager request from htmx swaps only #ips-table-wrap (hx-select),
|
||||
// so there is no need to re-render the whole page (and re-query the scan
|
||||
// status). A history-restore fetch needs the full page.
|
||||
if r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-History-Restore-Request") != "true" {
|
||||
s.renderFragment(w, "ips_table_wrap", data, err)
|
||||
return
|
||||
}
|
||||
if st, scanErr := s.CA.ScanStatus(r.Context()); scanErr != nil {
|
||||
s.Log.Warn("ips: scan status unavailable", "err", scanErr)
|
||||
} else {
|
||||
data.Scan = newScanProgress(st)
|
||||
}
|
||||
data := ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds}
|
||||
data.ActiveNav = "ips"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, "ips_page", data)
|
||||
s.renderPage(w, r, "ips_page", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -34,23 +196,21 @@ func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) {
|
||||
data := ipDetailData{Detail: detail}
|
||||
data.ActiveNav = "ips"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, "ip_detail_page", data)
|
||||
s.renderPage(w, r, "ip_detail_page", data)
|
||||
}
|
||||
|
||||
// renderIPsTable re-fetches the current queue and renders the ips_table
|
||||
// fragment, tagging actionErr (if any) on the shared error banner. Called
|
||||
// after every mutating /ips/* request so the table always reflects true
|
||||
// current state regardless of whether the mutation itself succeeded.
|
||||
// renderIPsTable re-fetches the current page of the queue (same page/filter as
|
||||
// the request, carried in hidden #ips-form inputs) and renders the
|
||||
// ips_table_wrap fragment, tagging actionErr (if any) on the shared error
|
||||
// banner. Called after every mutating /ips/* request so the table always
|
||||
// reflects true current state regardless of whether the mutation itself
|
||||
// succeeded.
|
||||
func (s *Server) renderIPsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
|
||||
items, listErr := s.CA.ListIPs(r.Context())
|
||||
data, err := s.loadIPsData(r)
|
||||
if actionErr == nil {
|
||||
actionErr = listErr
|
||||
actionErr = err
|
||||
}
|
||||
settings, settingsErr := s.CA.GetOrchestratorSettings(r.Context())
|
||||
if actionErr == nil {
|
||||
actionErr = settingsErr
|
||||
}
|
||||
s.renderFragment(w, "ips_table", ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds}, actionErr)
|
||||
s.renderFragment(w, "ips_table_wrap", data, actionErr)
|
||||
}
|
||||
|
||||
func (s *Server) handleIPsSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -63,7 +223,7 @@ func (s *Server) handleIPsSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "укажите хотя бы один адрес"})
|
||||
return
|
||||
}
|
||||
_, err := s.CA.SubmitIPs(r.Context(), addresses)
|
||||
err := s.submitChunked(r.Context(), addresses)
|
||||
s.renderIPsTable(w, r, err)
|
||||
}
|
||||
|
||||
@@ -85,31 +245,76 @@ func (s *Server) handleIPDelete(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderIPsTable(w, r, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleIPsDeleteSelected(w http.ResponseWriter, r *http.Request) {
|
||||
// submitChunked feeds addresses to SubmitIPs in chunks of bulkChunk so one
|
||||
// huge list never becomes one huge request/transaction.
|
||||
func (s *Server) submitChunked(ctx context.Context, addresses []string) error {
|
||||
for _, part := range chunk(addresses, bulkChunk) {
|
||||
if _, err := s.CA.SubmitIPs(ctx, part); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveFilterAddresses lists every address matching filter by paging
|
||||
// ListIPsPage (≤ maxPageLimit rows per call), for "select all N by filter".
|
||||
func (s *Server) resolveFilterAddresses(ctx context.Context, f ipsFilter) ([]string, error) {
|
||||
var out []string
|
||||
for offset := 0; ; {
|
||||
page, err := s.CA.ListIPsPage(ctx, ipsQuery{States: f.States(), Q: f.Q, Result: f.Result, Order: "sequence", Limit: maxPageLimit, Offset: offset})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, it := range page.Items {
|
||||
out = append(out, it.IPAddress)
|
||||
}
|
||||
offset += len(page.Items)
|
||||
if len(page.Items) == 0 || offset >= page.Total {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bulkAddresses returns the addresses a bulk delete/recheck acts on: the
|
||||
// checked rows of the current page, or — with scope=all — everything that
|
||||
// matches the current filter, resolved server-side.
|
||||
func (s *Server) bulkAddresses(r *http.Request) ([]string, error) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err))
|
||||
return
|
||||
return nil, fmt.Errorf("invalid form: %w", err)
|
||||
}
|
||||
var addresses []string
|
||||
if r.FormValue("scope") == "all" {
|
||||
var err error
|
||||
addresses, err = s.resolveFilterAddresses(r.Context(), parseIPsFilter(r))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
addresses = r.Form["addresses"]
|
||||
}
|
||||
addresses := r.Form["addresses"]
|
||||
if len(addresses) == 0 {
|
||||
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"})
|
||||
return
|
||||
return nil, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"}
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleIPsDeleteSelected(w http.ResponseWriter, r *http.Request) {
|
||||
addresses, err := s.bulkAddresses(r)
|
||||
if err == nil {
|
||||
for _, part := range chunk(addresses, bulkChunk) {
|
||||
if _, err = s.CA.DeleteIPs(r.Context(), part); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err := s.CA.DeleteIPs(r.Context(), addresses)
|
||||
s.renderIPsTable(w, r, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleIPsRecheckSelected(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err))
|
||||
return
|
||||
addresses, err := s.bulkAddresses(r)
|
||||
if err == nil {
|
||||
err = s.submitChunked(r.Context(), addresses)
|
||||
}
|
||||
addresses := r.Form["addresses"]
|
||||
if len(addresses) == 0 {
|
||||
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"})
|
||||
return
|
||||
}
|
||||
_, err := s.CA.SubmitIPs(r.Context(), addresses)
|
||||
s.renderIPsTable(w, r, err)
|
||||
}
|
||||
|
||||
@@ -118,10 +323,46 @@ func (s *Server) handleIPsClear(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderIPsTable(w, r, err)
|
||||
}
|
||||
|
||||
// handleIPsScan lists the OpenStack project's free (unassociated) floating
|
||||
// IPs and submits them to the check queue in one step — see
|
||||
// client.ScanFloatingIPs.
|
||||
// scanProgressData drives the scan_progress partial. Poll keeps the partial's
|
||||
// own hx-trigger="every 2s" alive: while the job runs, or while control-api is
|
||||
// transiently unreachable (so one failed poll doesn't freeze the panel).
|
||||
type scanProgressData struct {
|
||||
Status scanStatusDTO
|
||||
Poll bool
|
||||
}
|
||||
|
||||
func newScanProgress(st scanStatusDTO) scanProgressData {
|
||||
return scanProgressData{Status: st, Poll: st.Running}
|
||||
}
|
||||
|
||||
// renderScanProgress renders the scan panel fragment. A finished job also
|
||||
// sends `HX-Trigger: scan-finished`, which makes #ips-table-wrap reload.
|
||||
// Errors go to the shared banner; only transient ones (transport/5xx) keep
|
||||
// polling when keepPolling is set.
|
||||
func (s *Server) renderScanProgress(w http.ResponseWriter, st scanStatusDTO, err error, keepPolling bool) {
|
||||
data := newScanProgress(st)
|
||||
if err != nil {
|
||||
data = scanProgressData{}
|
||||
var ae *apiErr
|
||||
if keepPolling && errors.As(err, &ae) && (ae.Status == 0 || ae.Status >= 500) {
|
||||
data.Poll = true
|
||||
}
|
||||
} else if st.Finished() {
|
||||
w.Header().Set("HX-Trigger", "scan-finished")
|
||||
}
|
||||
s.renderFragment(w, "scan_progress", data, err)
|
||||
}
|
||||
|
||||
// handleIPsScan starts the background floating-IP scan (or joins the running
|
||||
// one) and returns the progress panel at once — the job itself runs in
|
||||
// control-api, so this never waits for OpenStack. ?dry_run=true only counts.
|
||||
func (s *Server) handleIPsScan(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := s.CA.ScanFloatingIPs(r.Context())
|
||||
s.renderIPsTable(w, r, err)
|
||||
st, err := s.CA.StartScan(r.Context(), r.URL.Query().Get("dry_run") == "true")
|
||||
s.renderScanProgress(w, st, err, false)
|
||||
}
|
||||
|
||||
// handleIPsScanStatus is the progress panel's poll target.
|
||||
func (s *Server) handleIPsScanStatus(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := s.CA.ScanStatus(r.Context())
|
||||
s.renderScanProgress(w, st, err, true)
|
||||
}
|
||||
@@ -2,23 +2,51 @@ package dashboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// overviewActiveLimit caps the "В работе" table; overviewQueueLimit caps
|
||||
// the compact list of the next queued addresses. The queue itself can hold
|
||||
// thousands of rows, so the overview never lists more than these.
|
||||
overviewActiveLimit = 100
|
||||
overviewQueueLimit = 10
|
||||
// etaMinSamples is how many completed rows (with AggregatedAt) the ETA
|
||||
// needs to derive a rate from.
|
||||
etaMinSamples = 5
|
||||
)
|
||||
|
||||
// overviewProgress is the "Готово D из T" indicator of the stats block.
|
||||
type overviewProgress struct {
|
||||
Total, Done, Active, Queued, Percent int
|
||||
// ETA is a rough time-to-finish estimate ("" when not enough data).
|
||||
ETA string
|
||||
}
|
||||
|
||||
type overviewData struct {
|
||||
PageData
|
||||
Status statusResponse
|
||||
CurrentItems []ipQueueItem
|
||||
Status statusResponse
|
||||
// ActiveItems are the addresses being checked right now (≤ overviewActiveLimit
|
||||
// of ActiveTotal); QueuedItems are the next few waiting ones (QueuedTotal
|
||||
// in total). Both stay empty under a result filter: an address without a
|
||||
// verdict can't match one.
|
||||
ActiveItems []ipQueueItem
|
||||
ActiveTotal int
|
||||
QueuedItems []ipQueueItem
|
||||
QueuedTotal int
|
||||
LastCompleted []ipQueueItem
|
||||
Breakdown map[string]int
|
||||
LastN int
|
||||
PollSeconds int
|
||||
Query string
|
||||
StatusFilter string
|
||||
Progress overviewProgress
|
||||
// AutoCycle is nil when the auto-cycle status could not be fetched; the
|
||||
// indicator is then simply hidden (a non-fatal failure).
|
||||
// indicator is then simply hidden (a non-fatal failure). Scan is likewise
|
||||
// nil when the scan status is unavailable.
|
||||
AutoCycle *autoCycleDTO
|
||||
Scan *scanStatusDTO
|
||||
}
|
||||
|
||||
func (s *Server) loadOverview(r *http.Request) (overviewData, error) {
|
||||
@@ -27,37 +55,65 @@ func (s *Server) loadOverview(r *http.Request) (overviewData, error) {
|
||||
if err != nil {
|
||||
return overviewData{}, err
|
||||
}
|
||||
ips, err := s.CA.ListIPs(ctx)
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
resultFilter := r.URL.Query().Get("status")
|
||||
if !containsStr(ipResults, resultFilter) {
|
||||
resultFilter = ""
|
||||
}
|
||||
lastN := s.Cfg.LastCompletedCount
|
||||
if lastN < 1 {
|
||||
lastN = 20
|
||||
}
|
||||
|
||||
data := overviewData{
|
||||
Status: status,
|
||||
LastN: lastN,
|
||||
PollSeconds: s.Cfg.OverviewPollIntervalS,
|
||||
Query: q,
|
||||
StatusFilter: resultFilter,
|
||||
}
|
||||
|
||||
if resultFilter == "" {
|
||||
active, err := s.CA.ListIPsPage(ctx, ipsQuery{States: activeStates, Q: q, Order: "sequence", Limit: overviewActiveLimit})
|
||||
if err != nil {
|
||||
return overviewData{}, err
|
||||
}
|
||||
queued, err := s.CA.ListIPsPage(ctx, ipsQuery{States: []string{"queued"}, Q: q, Order: "sequence", Limit: overviewQueueLimit})
|
||||
if err != nil {
|
||||
return overviewData{}, err
|
||||
}
|
||||
data.ActiveItems, data.ActiveTotal = active.Items, active.Total
|
||||
data.QueuedItems, data.QueuedTotal = queued.Items, queued.Total
|
||||
}
|
||||
completed, err := s.CA.ListIPsPage(ctx, ipsQuery{States: []string{"done", "failed"}, Q: q, Result: resultFilter, Order: "aggregated_at_desc", Limit: lastN})
|
||||
if err != nil {
|
||||
return overviewData{}, err
|
||||
}
|
||||
var autoCycle *autoCycleDTO
|
||||
data.LastCompleted = completed.Items
|
||||
data.Breakdown = resultBreakdown(completed.Items)
|
||||
|
||||
// ETA from the unfiltered window only: a filtered list is not a sample of
|
||||
// the checks' real throughput.
|
||||
data.Progress = computeProgress(status, completed.Items, q == "" && resultFilter == "")
|
||||
|
||||
if ac, acErr := s.CA.GetAutoCycle(ctx); acErr != nil {
|
||||
s.Log.Warn("overview: auto-cycle status unavailable", "err", acErr)
|
||||
} else {
|
||||
autoCycle = &ac
|
||||
data.AutoCycle = &ac
|
||||
}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
resultFilter := r.URL.Query().Get("status")
|
||||
last := lastCompleted(ips, s.Cfg.LastCompletedCount)
|
||||
return overviewData{
|
||||
Status: status,
|
||||
CurrentItems: filterQueueItems(currentlyChecking(ips), q, resultFilter),
|
||||
LastCompleted: filterQueueItems(last, q, resultFilter),
|
||||
Breakdown: resultBreakdown(last),
|
||||
LastN: s.Cfg.LastCompletedCount,
|
||||
PollSeconds: s.Cfg.OverviewPollIntervalS,
|
||||
Query: q,
|
||||
StatusFilter: resultFilter,
|
||||
AutoCycle: autoCycle,
|
||||
}, nil
|
||||
if sc, scErr := s.CA.ScanStatus(ctx); scErr != nil {
|
||||
s.Log.Warn("overview: scan status unavailable", "err", scErr)
|
||||
} else if sc.Running {
|
||||
data.Scan = &sc
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.loadOverview(r)
|
||||
data.ActiveNav = "overview"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, "overview_page", data)
|
||||
s.renderPage(w, r, "overview_page", data)
|
||||
}
|
||||
|
||||
// handleOverviewFragment serves both the recurring poll and every
|
||||
@@ -75,40 +131,45 @@ func (s *Server) handleOverviewFragment(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// currentlyChecking is every IP not yet in a terminal state, ordered by
|
||||
// queue position — the "текущая проверка" live snapshot. No backend
|
||||
// concept of a "run" exists; this is computed fresh on every request.
|
||||
func currentlyChecking(ips []ipQueueItem) []ipQueueItem {
|
||||
var out []ipQueueItem
|
||||
for _, ip := range ips {
|
||||
if ip.State != "done" && ip.State != "failed" {
|
||||
out = append(out, ip)
|
||||
// computeProgress derives the done/active/queued counters from the status
|
||||
// breakdown (done = every terminal state, "occupied" included) and, when
|
||||
// useETA is set and the newest-first list of completed rows has at least
|
||||
// etaMinSamples with AggregatedAt, a rough ETA from their completion rate.
|
||||
func computeProgress(st statusResponse, completed []ipQueueItem, useETA bool) overviewProgress {
|
||||
p := overviewProgress{
|
||||
Total: st.TotalIPs,
|
||||
Done: sumStates(st.IPsByState, terminalStates),
|
||||
Active: sumStates(st.IPsByState, activeStates),
|
||||
Queued: st.IPsByState["queued"],
|
||||
}
|
||||
if p.Total > 0 {
|
||||
p.Percent = p.Done * 100 / p.Total
|
||||
}
|
||||
remaining := p.Active + p.Queued
|
||||
if !useETA || remaining == 0 {
|
||||
return p
|
||||
}
|
||||
var stamps []time.Time
|
||||
for _, ip := range completed {
|
||||
if ip.AggregatedAt != nil {
|
||||
stamps = append(stamps, *ip.AggregatedAt)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Sequence < out[j].Sequence })
|
||||
return out
|
||||
}
|
||||
|
||||
// lastCompleted returns the n most recently completed (done/failed) IPs by
|
||||
// AggregatedAt descending — the "последняя завершённая проверка" summary
|
||||
// window. This is an operational definition, not a real "batch": resubmit
|
||||
// n if the window size needs tuning (overview.last_completed_count).
|
||||
func lastCompleted(ips []ipQueueItem, n int) []ipQueueItem {
|
||||
var done []ipQueueItem
|
||||
for _, ip := range ips {
|
||||
if (ip.State == "done" || ip.State == "failed") && ip.AggregatedAt != nil {
|
||||
done = append(done, ip)
|
||||
}
|
||||
if len(stamps) < etaMinSamples {
|
||||
return p
|
||||
}
|
||||
sort.Slice(done, func(i, j int) bool { return done[i].AggregatedAt.After(*done[j].AggregatedAt) })
|
||||
if len(done) > n {
|
||||
done = done[:n]
|
||||
// completed is newest-first: stamps[0] is the newest, the last the oldest.
|
||||
span := stamps[0].Sub(stamps[len(stamps)-1])
|
||||
if span <= 0 {
|
||||
return p
|
||||
}
|
||||
return done
|
||||
perItem := span / time.Duration(len(stamps)-1)
|
||||
p.ETA = fmtDuration(perItem * time.Duration(remaining))
|
||||
return p
|
||||
}
|
||||
|
||||
// resultBreakdown counts OverallResult values across exactly the given
|
||||
// items (normally the output of lastCompleted) — pass/partial/fail/cancelled.
|
||||
// items (the "последние N завершённых" window) — pass/partial/fail/cancelled.
|
||||
func resultBreakdown(items []ipQueueItem) map[string]int {
|
||||
out := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
|
||||
for _, ip := range items {
|
||||
@@ -116,26 +177,3 @@ func resultBreakdown(items []ipQueueItem) map[string]int {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// filterQueueItems narrows items to those whose address contains q
|
||||
// (case-insensitive substring) and, if status is set, whose OverallResult
|
||||
// matches it exactly. A still-in-progress item always has an empty
|
||||
// OverallResult, so picking any specific status hides it — the intended
|
||||
// behavior for "Текущая проверка", which has no verdict yet.
|
||||
func filterQueueItems(items []ipQueueItem, q, status string) []ipQueueItem {
|
||||
if q == "" && status == "" {
|
||||
return items
|
||||
}
|
||||
q = strings.ToLower(q)
|
||||
out := make([]ipQueueItem, 0, len(items))
|
||||
for _, ip := range items {
|
||||
if q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), q) {
|
||||
continue
|
||||
}
|
||||
if status != "" && ip.OverallResult != status {
|
||||
continue
|
||||
}
|
||||
out = append(out, ip)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -2,14 +2,23 @@ package dashboard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type registryPageData struct {
|
||||
PageData
|
||||
Items []registryItem
|
||||
Query string
|
||||
StatusFilter string
|
||||
Items []registryItem
|
||||
Query string
|
||||
StatusFilter string
|
||||
Run int64 // drill-down from the analytics page
|
||||
Subnet string
|
||||
Page, PerPage int
|
||||
Total int
|
||||
Pager pagerData
|
||||
PerPageOptions []int
|
||||
}
|
||||
|
||||
type registryDetailData struct {
|
||||
@@ -22,41 +31,62 @@ type registryDetailData struct {
|
||||
// record that survives an address being deleted from /ips and later
|
||||
// re-added. See internal/db/migrations/0007_ip_registry.sql. Optional
|
||||
// ?q=&status= query params narrow the list by address substring and by
|
||||
// LastResult — see filterRegistryItems.
|
||||
// LastResult, and ?page=&per_page= select a page — all applied server-side
|
||||
// (control-api's ListRegistryPage), so only the visible rows are transferred.
|
||||
func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.CA.ListRegistry(r.Context())
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
status := r.URL.Query().Get("status")
|
||||
if !containsStr(ipResults, status) {
|
||||
status = ""
|
||||
}
|
||||
perPage := parsePerPage(r.URL.Query().Get("per_page"))
|
||||
page := parsePage(r.URL.Query().Get("page"))
|
||||
run, _ := strconv.ParseInt(r.URL.Query().Get("run"), 10, 64)
|
||||
if run < 0 {
|
||||
run = 0
|
||||
}
|
||||
subnet := strings.TrimSpace(r.URL.Query().Get("subnet"))
|
||||
if _, err := netip.ParsePrefix(subnet); err != nil {
|
||||
subnet = ""
|
||||
}
|
||||
query := registryQuery{Q: q, LastResult: status, Run: run, Subnet: subnet, Limit: perPage, Offset: (page - 1) * perPage}
|
||||
|
||||
res, err := s.CA.ListRegistryPage(r.Context(), query)
|
||||
if err == nil {
|
||||
if clamped := clampPage(page, res.Total, perPage); clamped != page {
|
||||
page = clamped
|
||||
query.Offset = (page - 1) * perPage
|
||||
res, err = s.CA.ListRegistryPage(r.Context(), query)
|
||||
}
|
||||
}
|
||||
params := url.Values{}
|
||||
if q != "" {
|
||||
params.Set("q", q)
|
||||
}
|
||||
if status != "" {
|
||||
params.Set("status", status)
|
||||
}
|
||||
if run > 0 {
|
||||
params.Set("run", strconv.FormatInt(run, 10))
|
||||
}
|
||||
if subnet != "" {
|
||||
params.Set("subnet", subnet)
|
||||
}
|
||||
data := registryPageData{
|
||||
Items: filterRegistryItems(items, q, status),
|
||||
Query: q,
|
||||
StatusFilter: status,
|
||||
Run: run,
|
||||
Subnet: subnet,
|
||||
Items: res.Items,
|
||||
Query: q,
|
||||
StatusFilter: status,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
Total: res.Total,
|
||||
Pager: newPager("/registry", "registry-table-wrap", params, page, perPage, res.Total),
|
||||
PerPageOptions: perPageOptions,
|
||||
}
|
||||
data.ActiveNav = "registry"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, "registry_page", data)
|
||||
}
|
||||
|
||||
// filterRegistryItems narrows items to those whose address contains q
|
||||
// (case-insensitive substring) and, if status is set, whose LastResult
|
||||
// matches it exactly — the registry list's search-by-IP and
|
||||
// filter-by-status, mirroring filterQueueItems in handlers_overview.go.
|
||||
func filterRegistryItems(items []registryItem, q, status string) []registryItem {
|
||||
if q == "" && status == "" {
|
||||
return items
|
||||
}
|
||||
q = strings.ToLower(q)
|
||||
out := make([]registryItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
if q != "" && !strings.Contains(strings.ToLower(it.IPAddress), q) {
|
||||
continue
|
||||
}
|
||||
if status != "" && it.LastResult != status {
|
||||
continue
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
s.renderPage(w, r, "registry_page", data)
|
||||
}
|
||||
|
||||
// handleRegistryDetail shows one address's full retained check history
|
||||
@@ -68,5 +98,5 @@ func (s *Server) handleRegistryDetail(w http.ResponseWriter, r *http.Request) {
|
||||
data := registryDetailData{History: history}
|
||||
data.ActiveNav = "registry"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, "registry_detail_page", data)
|
||||
s.renderPage(w, r, "registry_detail_page", data)
|
||||
}
|
||||
@@ -14,6 +14,8 @@ type settingsPageData struct {
|
||||
Inbound inboundChecksDTO
|
||||
// AutoCycle is the automatic-check-cycle panel's status/parameters.
|
||||
AutoCycle autoCycleDTO
|
||||
// Subnets is the list the analytics page groups addresses by.
|
||||
Subnets subnetList
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -26,10 +28,14 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil {
|
||||
err = autoCycleErr
|
||||
}
|
||||
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}
|
||||
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
|
||||
if err == nil {
|
||||
err = subnetsErr
|
||||
}
|
||||
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}
|
||||
data.ActiveNav = "settings"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, "settings_page", data)
|
||||
s.renderPage(w, r, "settings_page", data)
|
||||
}
|
||||
|
||||
// renderSettingsForm re-fetches the current settings and renders the
|
||||
@@ -49,7 +55,11 @@ func (s *Server) renderSettingsForm(w http.ResponseWriter, r *http.Request, acti
|
||||
if actionErr == nil {
|
||||
actionErr = autoCycleErr
|
||||
}
|
||||
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}, actionErr)
|
||||
subnets, subnetsErr := s.CA.GetSubnets(r.Context())
|
||||
if actionErr == nil {
|
||||
actionErr = subnetsErr
|
||||
}
|
||||
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle, Subnets: subnets}, actionErr)
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -67,7 +77,12 @@ func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderSettingsForm(w, r, &apiErr{Status: http.StatusBadRequest, Message: "глубина истории должна быть целым числом циклов"})
|
||||
return
|
||||
}
|
||||
_, err = s.CA.PutOrchestratorSettings(r.Context(), seconds, retentionCycles)
|
||||
selfCheckMax, err := strconv.Atoi(r.PostFormValue("self_check_max_attempts"))
|
||||
if err != nil {
|
||||
s.renderSettingsForm(w, r, &apiErr{Status: http.StatusBadRequest, Message: "потолок провалов self-check должен быть целым числом"})
|
||||
return
|
||||
}
|
||||
_, err = s.CA.PutOrchestratorSettings(r.Context(), seconds, retentionCycles, selfCheckMax)
|
||||
s.renderSettingsForm(w, r, err)
|
||||
}
|
||||
|
||||
@@ -102,6 +117,26 @@ func (s *Server) handleInboundChecksPut(w http.ResponseWriter, r *http.Request)
|
||||
s.renderSettingsForm(w, r, err)
|
||||
}
|
||||
|
||||
// handleSubnetsPut saves the subnet list from the textarea of /settings: one
|
||||
// subnet per line, CIDR first and an optional label after a space.
|
||||
func (s *Server) handleSubnetsPut(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderSettingsForm(w, r, fmt.Errorf("invalid form: %w", err))
|
||||
return
|
||||
}
|
||||
list := subnetList{Subnets: []subnetEntry{}}
|
||||
for _, line := range strings.Split(r.PostFormValue("subnets"), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
cidr, label, _ := strings.Cut(line, " ")
|
||||
list.Subnets = append(list.Subnets, subnetEntry{CIDR: strings.TrimSpace(cidr), Label: strings.TrimSpace(label)})
|
||||
}
|
||||
_, err := s.CA.PutSubnets(r.Context(), list)
|
||||
s.renderSettingsForm(w, r, err)
|
||||
}
|
||||
|
||||
// parseMinutes converts a form field holding a (possibly fractional) number
|
||||
// of minutes into whole seconds.
|
||||
func parseMinutes(raw string) (int, error) {
|
||||
|
||||
@@ -16,7 +16,7 @@ func (s *Server) handleSitesPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := sitesPageData{Items: items}
|
||||
data.ActiveNav = "sites"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, "sites_page", data)
|
||||
s.renderPage(w, r, "sites_page", data)
|
||||
}
|
||||
|
||||
func (s *Server) renderSitesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
|
||||
|
||||
@@ -83,7 +83,7 @@ func (s *Server) handleTargetsPage(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.loadTargetsPage(r)
|
||||
data.ActiveNav = "targets"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, "targets_page", data)
|
||||
s.renderPage(w, r, "targets_page", data)
|
||||
}
|
||||
|
||||
// renderTargetsTable renders the #targets-table-wrap swap target plus,
|
||||
|
||||
@@ -307,9 +307,12 @@ func TestSettingsGetAndPut(t *testing.T) {
|
||||
if !strings.Contains(page, `value="0"`) {
|
||||
t.Fatalf("expected default 0 in the form, got:\n%s", page)
|
||||
}
|
||||
if !strings.Contains(page, "Потолок провалов self-check на адрес") || !strings.Contains(page, `id="self_check_max_attempts" name="self_check_max_attempts" min="1" max="50" step="1" value="5"`) {
|
||||
t.Fatalf("expected the self-check ceiling field with the default 5, got:\n%s", page)
|
||||
}
|
||||
|
||||
body := postForm(t, ts, "PUT", "/settings", map[string][]string{
|
||||
"fip_settle_seconds": {"15"}, "history_retention_cycles": {"10"},
|
||||
"fip_settle_seconds": {"15"}, "history_retention_cycles": {"10"}, "self_check_max_attempts": {"8"},
|
||||
})
|
||||
if !strings.Contains(body, `value="15"`) || !strings.Contains(body, `value="10"`) {
|
||||
t.Fatalf("expected updated values 15/10 in re-rendered form, got:\n%s", body)
|
||||
@@ -320,11 +323,14 @@ func TestSettingsGetAndPut(t *testing.T) {
|
||||
if fake.historyRetentionCycles != 10 {
|
||||
t.Fatalf("expected fake control-api history_retention_cycles updated, got %d", fake.historyRetentionCycles)
|
||||
}
|
||||
if fake.selfCheckMaxAttempts != 8 || !strings.Contains(body, `name="self_check_max_attempts" min="1" max="50" step="1" value="8"`) {
|
||||
t.Fatalf("expected the self-check ceiling 8 saved and shown, got %d:\n%s", fake.selfCheckMaxAttempts, body)
|
||||
}
|
||||
|
||||
// A control-api validation error (negative value here) surfaces via
|
||||
// the banner, not a crash.
|
||||
body = postForm(t, ts, "PUT", "/settings", map[string][]string{
|
||||
"fip_settle_seconds": {"-1"}, "history_retention_cycles": {"10"},
|
||||
"fip_settle_seconds": {"-1"}, "history_retention_cycles": {"10"}, "self_check_max_attempts": {"8"},
|
||||
})
|
||||
if !strings.Contains(body, "alert-warning") {
|
||||
t.Fatalf("expected client error banner for invalid value, got:\n%s", body)
|
||||
@@ -333,7 +339,7 @@ func TestSettingsGetAndPut(t *testing.T) {
|
||||
// A non-numeric value is caught by the dashboard itself before it ever
|
||||
// reaches control-api.
|
||||
body = postForm(t, ts, "PUT", "/settings", map[string][]string{
|
||||
"fip_settle_seconds": {"not-a-number"}, "history_retention_cycles": {"10"},
|
||||
"fip_settle_seconds": {"not-a-number"}, "history_retention_cycles": {"10"}, "self_check_max_attempts": {"8"},
|
||||
})
|
||||
if !strings.Contains(body, "alert-warning") {
|
||||
t.Fatalf("expected client error banner for non-numeric value, got:\n%s", body)
|
||||
@@ -341,11 +347,49 @@ func TestSettingsGetAndPut(t *testing.T) {
|
||||
|
||||
// Same for a non-numeric retention value.
|
||||
body = postForm(t, ts, "PUT", "/settings", map[string][]string{
|
||||
"fip_settle_seconds": {"15"}, "history_retention_cycles": {"not-a-number"},
|
||||
"fip_settle_seconds": {"15"}, "history_retention_cycles": {"not-a-number"}, "self_check_max_attempts": {"8"},
|
||||
})
|
||||
if !strings.Contains(body, "alert-warning") {
|
||||
t.Fatalf("expected client error banner for non-numeric retention value, got:\n%s", body)
|
||||
}
|
||||
|
||||
// A ceiling outside 1..50 is refused by control-api and shown as a
|
||||
// warning; the form keeps the stored value.
|
||||
body = postForm(t, ts, "PUT", "/settings", map[string][]string{
|
||||
"fip_settle_seconds": {"15"}, "history_retention_cycles": {"10"}, "self_check_max_attempts": {"51"},
|
||||
})
|
||||
if !strings.Contains(body, "alert-warning") || fake.selfCheckMaxAttempts != 8 {
|
||||
t.Fatalf("expected a warning and the stored ceiling 8 kept, got %d:\n%s", fake.selfCheckMaxAttempts, body)
|
||||
}
|
||||
|
||||
// A non-numeric ceiling is caught by the dashboard itself.
|
||||
body = postForm(t, ts, "PUT", "/settings", map[string][]string{
|
||||
"fip_settle_seconds": {"15"}, "history_retention_cycles": {"10"}, "self_check_max_attempts": {"many"},
|
||||
})
|
||||
if !strings.Contains(body, "alert-warning") {
|
||||
t.Fatalf("expected client error banner for non-numeric ceiling, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The address pages say on which validators the self-check failed.
|
||||
func TestAddressPagesShowSelfCheckFailedOn(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
now := time.Now()
|
||||
fake.ips = []ipQueueItem{{IPAddress: "5.5.5.5", State: "checking", UpdatedAt: now, CreatedAt: now}}
|
||||
fake.registry["5.5.5.5"] = registryItem{IPAddress: "5.5.5.5", FirstSeenAt: now, LastSeenAt: now}
|
||||
ts := newTestServer(t, caURL)
|
||||
|
||||
for _, path := range []string{"/ips/5.5.5.5", "/registry/5.5.5.5"} {
|
||||
if page := get(t, ts, path); strings.Contains(page, "Self-check не прошёл на") {
|
||||
t.Fatalf("%s: no failures, but the line is shown:\n%s", path, page)
|
||||
}
|
||||
}
|
||||
fake.selfCheckFailedOn = []string{"vkiplab-v17", "vkiplab-v13"}
|
||||
for _, path := range []string{"/ips/5.5.5.5", "/registry/5.5.5.5"} {
|
||||
if page := get(t, ts, path); !strings.Contains(page, "Self-check не прошёл на: vkiplab-v17, vkiplab-v13") {
|
||||
t.Fatalf("%s: expected the failed validators line, got:\n%s", path, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsPageShowsInboundChecks(t *testing.T) {
|
||||
@@ -558,20 +602,6 @@ func TestTargetsAndCheckTypesRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPsScan(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
fake.scanFreeAddresses = []string{"5.5.5.5"}
|
||||
ts := newTestServer(t, caURL)
|
||||
|
||||
body := postForm(t, ts, "POST", "/ips/scan", nil)
|
||||
if !strings.Contains(body, "5.5.5.5") {
|
||||
t.Fatalf("expected scanned address in re-rendered table, got:\n%s", body)
|
||||
}
|
||||
if len(fake.ips) != 1 || fake.ips[0].IPAddress != "5.5.5.5" {
|
||||
t.Fatalf("expected fake control-api queue to contain the scanned address, got %+v", fake.ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryPageAndDetail(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
now := time.Now()
|
||||
@@ -604,6 +634,38 @@ func TestRegistryPageAndDetail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryPageShowsEgressIngressLevels proves the list shows, under the
|
||||
// verdict, "ok из total" per level and per check family, coloured by outcome,
|
||||
// and omits the block for an address with no recorded cycle.
|
||||
func TestRegistryPageShowsEgressIngressLevels(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
now := time.Now()
|
||||
fake.registry["9.9.9.9"] = registryItem{
|
||||
IPAddress: "9.9.9.9", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "partial",
|
||||
LastCycleID: 1,
|
||||
Egress: levelResult{Total: 5, OK: 5, ByType: []typeStat{{"https", 3, 3}, {"icmp", 2, 2}}},
|
||||
Ingress: levelResult{Total: 4, OK: 3, ByType: []typeStat{{"icmp", 1, 1}, {"tcp", 3, 2}}},
|
||||
}
|
||||
fake.registry["8.8.8.8"] = registryItem{IPAddress: "8.8.8.8", FirstSeenAt: now, LastSeenAt: now}
|
||||
ts := newTestServer(t, caURL)
|
||||
|
||||
body := get(t, ts, "/registry")
|
||||
for _, want := range []string{
|
||||
"Egress", "Ingress",
|
||||
`stat stat-ok">5 из 5<`, `stat stat-part">3 из 4<`,
|
||||
`chip chip-ok">https 3 из 3<`, `chip chip-ok">icmp 2 из 2<`,
|
||||
`chip chip-part">tcp 2 из 3<`,
|
||||
"цикла 1",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected %q in registry page, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Count(body, `class="levels"`) != 1 {
|
||||
t.Fatalf("expected the levels block only for the address with checks, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryFilterByQueryAndStatus proves the ?q=&status= params on
|
||||
// /registry narrow the list by address substring and by LastResult, and
|
||||
// that the filter form echoes the applied values back into its inputs.
|
||||
@@ -667,64 +729,6 @@ func TestControlAPIUnreachable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterQueueItems(t *testing.T) {
|
||||
items := []ipQueueItem{
|
||||
{IPAddress: "1.1.1.1", OverallResult: "pass"},
|
||||
{IPAddress: "1.1.1.2", OverallResult: "fail"},
|
||||
{IPAddress: "2.2.2.2", OverallResult: "pass"},
|
||||
}
|
||||
|
||||
if got := filterQueueItems(items, "", ""); len(got) != 3 {
|
||||
t.Fatalf("expected no-op with empty q/status, got %+v", got)
|
||||
}
|
||||
if got := filterQueueItems(items, "1.1.1", ""); len(got) != 2 {
|
||||
t.Fatalf("expected 2 matches for q=1.1.1, got %+v", got)
|
||||
}
|
||||
if got := filterQueueItems(items, "1.1.1.1", ""); len(got) != 1 || got[0].IPAddress != "1.1.1.1" {
|
||||
t.Fatalf("expected exact-substring match, got %+v", got)
|
||||
}
|
||||
if got := filterQueueItems(items, "1.1.1.1", ""); len(got) != 1 {
|
||||
t.Fatalf("expected search to be case/substring based, got %+v", got)
|
||||
}
|
||||
if got := filterQueueItems(items, "", "pass"); len(got) != 2 {
|
||||
t.Fatalf("expected 2 matches for status=pass, got %+v", got)
|
||||
}
|
||||
if got := filterQueueItems(items, "1.1.1", "pass"); len(got) != 1 || got[0].IPAddress != "1.1.1.1" {
|
||||
t.Fatalf("expected q+status combined with AND, got %+v", got)
|
||||
}
|
||||
if got := filterQueueItems(items, "9.9.9.9", ""); len(got) != 0 {
|
||||
t.Fatalf("expected no matches, got %+v", got)
|
||||
}
|
||||
|
||||
// Case-insensitivity, via a query with mixed-case letters (IP octets
|
||||
// are numeric, so exercise it through IPv6-shaped input instead).
|
||||
mixed := []ipQueueItem{{IPAddress: "fe80::AbCd"}}
|
||||
if got := filterQueueItems(mixed, "abcd", ""); len(got) != 1 {
|
||||
t.Fatalf("expected case-insensitive search to match, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRegistryItems(t *testing.T) {
|
||||
items := []registryItem{
|
||||
{IPAddress: "1.1.1.1", LastResult: "pass"},
|
||||
{IPAddress: "1.1.1.2", LastResult: "partial"},
|
||||
{IPAddress: "2.2.2.2", LastResult: ""},
|
||||
}
|
||||
|
||||
if got := filterRegistryItems(items, "", ""); len(got) != 3 {
|
||||
t.Fatalf("expected no-op with empty q/status, got %+v", got)
|
||||
}
|
||||
if got := filterRegistryItems(items, "1.1.1", ""); len(got) != 2 {
|
||||
t.Fatalf("expected 2 matches for q=1.1.1, got %+v", got)
|
||||
}
|
||||
if got := filterRegistryItems(items, "", "partial"); len(got) != 1 || got[0].IPAddress != "1.1.1.2" {
|
||||
t.Fatalf("expected exactly the partial-result address, got %+v", got)
|
||||
}
|
||||
if got := filterRegistryItems(items, "2.2.2", "partial"); len(got) != 0 {
|
||||
t.Fatalf("expected q+status combined with AND to exclude non-matching, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoCyclePanelRenders(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
finished := time.Now().Add(-time.Hour)
|
||||
|
||||
@@ -15,7 +15,7 @@ func (s *Server) handleValidatorsPage(w http.ResponseWriter, r *http.Request) {
|
||||
data := validatorsPageData{Items: items}
|
||||
data.ActiveNav = "validators"
|
||||
data.Banner = bannerFor(err)
|
||||
s.renderPage(w, "validators_page", data)
|
||||
s.renderPage(w, r, "validators_page", data)
|
||||
}
|
||||
|
||||
func (s *Server) renderValidatorsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Server-side pagination shared by /ips and /registry: `page` (1-based,
|
||||
// clamped to the last page) and `per_page` (one of perPageOptions, else
|
||||
// defaultPerPage).
|
||||
|
||||
const defaultPerPage = 50
|
||||
|
||||
var perPageOptions = []int{25, 50, 100, 200}
|
||||
|
||||
// parsePerPage accepts only the whitelisted page sizes.
|
||||
func parsePerPage(raw string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return defaultPerPage
|
||||
}
|
||||
for _, o := range perPageOptions {
|
||||
if n == o {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return defaultPerPage
|
||||
}
|
||||
|
||||
// parsePage returns the 1-based page number; anything invalid is page 1.
|
||||
func parsePage(raw string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func pageCount(total, perPage int) int {
|
||||
if total <= 0 || perPage <= 0 {
|
||||
return 1
|
||||
}
|
||||
return (total + perPage - 1) / perPage
|
||||
}
|
||||
|
||||
// clampPage limits page to the last non-empty page for the given total.
|
||||
func clampPage(page, total, perPage int) int {
|
||||
if last := pageCount(total, perPage); page > last {
|
||||
page = last
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
// pagerData drives the shared "pager" template partial.
|
||||
type pagerData struct {
|
||||
// Wrap is the id of the swap-target element holding the table (the
|
||||
// ‹ › links replace it via hx-select + hx-target).
|
||||
Wrap string
|
||||
Page, PerPage, Total, Pages, From, To int
|
||||
PrevURL, NextURL string
|
||||
}
|
||||
|
||||
// newPager builds the pager for page (already clamped) of total rows. base is
|
||||
// the page path; params are the filters to preserve in the links (without
|
||||
// page/per_page, which the pager sets itself).
|
||||
func newPager(base, wrap string, params url.Values, page, perPage, total int) pagerData {
|
||||
p := pagerData{Wrap: wrap, Page: page, PerPage: perPage, Total: total, Pages: pageCount(total, perPage)}
|
||||
if total > 0 {
|
||||
p.From = (page-1)*perPage + 1
|
||||
p.To = page * perPage
|
||||
if p.To > total {
|
||||
p.To = total
|
||||
}
|
||||
}
|
||||
if page > 1 {
|
||||
p.PrevURL = pageURL(base, params, page-1, perPage)
|
||||
}
|
||||
if page < p.Pages {
|
||||
p.NextURL = pageURL(base, params, page+1, perPage)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// pageURL builds base?...&page=N&per_page=M with every value escaped by
|
||||
// url.Values (so a search string like "a&b" can't smuggle in a parameter).
|
||||
// Page 1 omits `page`.
|
||||
func pageURL(base string, params url.Values, page, perPage int) string {
|
||||
v := url.Values{}
|
||||
for k, vals := range params {
|
||||
for _, val := range vals {
|
||||
if val != "" {
|
||||
v.Add(k, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
v.Set("per_page", strconv.Itoa(perPage))
|
||||
if page > 1 {
|
||||
v.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
return base + "?" + v.Encode()
|
||||
}
|
||||
|
||||
// chunk splits list into slices of at most size elements.
|
||||
func chunk(list []string, size int) [][]string {
|
||||
var out [][]string
|
||||
for len(list) > size {
|
||||
out = append(out, list[:size])
|
||||
list = list[size:]
|
||||
}
|
||||
if len(list) > 0 {
|
||||
out = append(out, list)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user