New external site management. New external prober heartbeat feature.

This commit is contained in:
ayurishchev committed 2026-08-26 20:47:54 +03:00
1 parent 42f584dd3c
commit ef24cc9858
38 files changed
+1058 -158

No files matched your search

+3 -3
View File
@@ -1,4 +1,4 @@
5703aa9367f814fd1c02ab8ee821f4ebea28934387b084e0f843efe1505530b7 control-api 118e09f98944ac59593af603c04a1d5f0e7cafb6549b5a562300bc31d9c769da control-api
48c9b99fa88be751d9badfba8b7d80f894e326a743a90e85478f1d2251ce4ab6 validator-agent 48c9b99fa88be751d9badfba8b7d80f894e326a743a90e85478f1d2251ce4ab6 validator-agent
43fb660b78179b204b57388241a508345881aa16f3531d77b8fd75af60e7f2d8 prober 8877cf7c6d2bdd58faf70865be602b3db5f18508f4607c8942bc8899ce0fb1e6 prober
ef796e473d647a173954d40c646904898d16bf2c831b3f87bea3e1e5b809888b admin-dashboard 73bdf0ccda4912f25710a09bd37eaa0620f1b6e5002cb50e6b8b057cecb17f97 admin-dashboard
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+3
View File
@@ -102,6 +102,9 @@ func runOrchestratorLoop(ctx context.Context, orch *orchestrator.Orchestrator, c
if err := orch.SweepStaleHeartbeats(ctx); err != nil { if err := orch.SweepStaleHeartbeats(ctx); err != nil {
log.Error("sweep stale heartbeats", "err", err) log.Error("sweep stale heartbeats", "err", err)
} }
if err := orch.SweepStaleSiteHeartbeats(ctx); err != nil {
log.Error("sweep stale site heartbeats", "err", err)
}
} }
} }
} }
+5 -5
View File
@@ -67,11 +67,11 @@ validators:
os_port_id: "REPLACE_WITH_NEUTRON_PORT_ID_2" os_port_id: "REPLACE_WITH_NEUTRON_PORT_ID_2"
# Inbound (prober) checks are OPTIONAL: list here only the external sites # Inbound (prober) checks are OPTIONAL: list here only the external sites
# you actually run a `prober` on, up to 3 slots (index 1-3, matches # you actually run a `prober` on — index is any integer >= 1, no cap on
# ip_queue.siteN_complete — the schema is capped at 3). Leave this list # how many slots you configure. Leave this list empty to disable inbound
# empty to disable inbound checks entirely — the overall result is then # checks entirely — the overall result is then based on egress checks
# based on egress checks alone, and aggregation doesn't wait on any # alone, and aggregation doesn't wait on any prober. A partial list (e.g.
# prober. A partial list (e.g. just index 1) only waits on that one site. # just index 1) only waits on that one site.
sites: sites:
- site_id: "site-1" - site_id: "site-1"
index: 1 index: 1
+32 -8
View File
@@ -209,7 +209,12 @@ target)` в рамках текущей попытки — безопасна и
### `POST /api/v1/probers/register` ### `POST /api/v1/probers/register`
Регистрация пробера. `site_id` должен присутствовать в конфиге control-api Регистрация пробера. `site_id` должен присутствовать в конфиге control-api
(`sites[].site_id`), иначе — `400`. (`sites[].site_id`), иначе — `400`. Помимо привычной проверки, запоминает
`hostname` и переводит площадку в состояние `idle` (если она была
`unregistered`/`unreachable`) — то же самое, что делает `validator-agent`
при своей регистрации (см.
[«Управление площадками»](USAGE.md#управление-площадками-проберами) в
USAGE.md про состояния площадки).
Запрос: Запрос:
```json ```json
@@ -218,6 +223,17 @@ target)` в рамках текущей попытки — безопасна и
Ответ: `{"ok": true, "poll_interval_seconds": 5}`. Ответ: `{"ok": true, "poll_interval_seconds": 5}`.
### `POST /api/v1/probers/{site_id}/heartbeat`
«Я жив». Обновляет `last_heartbeat_at` площадки. Если площадка не
присылает heartbeat дольше `orchestrator.heartbeat_timeout_seconds`
(тот же параметр, что и для валидаторов), она помечается `unreachable`.
Полный аналог `POST /api/v1/agents/{id}/heartbeat` для пробера.
Запрос: тело не требуется.
Ответ: `{"ok": true}`. `404`, если `site_id` не сконфигурирован.
### `GET /api/v1/probers/{site_id}/assignments` ### `GET /api/v1/probers/{site_id}/assignments`
Список всех IP, которые сейчас находятся в состоянии `checking` — то есть Список всех IP, которые сейчас находятся в состоянии `checking` — то есть
@@ -424,15 +440,23 @@ curl -s -X POST "$BASE/api/v1/admin/ips/clear"
### Площадки: `/api/v1/admin/config/sites` ### Площадки: `/api/v1/admin/config/sites`
Слотов ровно три (`index` ∈ {1, 2, 3}) — это ограничение схемы БД Число слотов не ограничено — `index` может быть любым целым `>= 1`,
(`ip_queue.site{1,2,3}_complete`), а не искусственное. Пустой список слотов столько площадок, сколько нужно оператору. Пустой список слотов — штатный
— штатный сценарий, отключающий inbound-проверки целиком (см. сценарий, отключающий inbound-проверки целиком (см.
[USAGE.md](USAGE.md#управление-площадками-проберами)). [USAGE.md](USAGE.md#управление-площадками-проберами)).
Помимо `index`/`site_id`, объект площадки несёт состояние подключения
пробера — `hostname`/`state`/`last_heartbeat_at`, тот же смысл, что у
аналогичных полей валидатора (`unregistered`/`idle`/`unreachable`,
обновляются через `POST /api/v1/probers/register` и `POST
/api/v1/probers/{site_id}/heartbeat`, см. выше). `PUT` на слот всегда
сбрасывает эти три поля к значениям «ещё не подключался» — новый (или
даже тот же) `site_id` трактуется как новая идентичность пробера.
| Метод | Путь | Тело | Успех | Ошибки | | Метод | Путь | Тело | Успех | Ошибки |
|---|---|---|---|---| |---|---|---|---|---|
| GET | `/api/v1/admin/config/sites` | — | `[{"index","site_id"}]` (до 3 строк) | | | GET | `/api/v1/admin/config/sites` | — | `[{"index","site_id","hostname","state","last_heartbeat_at"}]` | |
| PUT | `/api/v1/admin/config/sites/{index}` | `{"site_id"}` | `200` | `400`, если `index` не 1..3; `409`, если `site_id` уже занят другим слотом | | PUT | `/api/v1/admin/config/sites/{index}` | `{"site_id"}` | `200` | `400`, если `index < 1`; `409`, если `site_id` уже занят другим слотом |
| DELETE | `/api/v1/admin/config/sites/{index}` | — | `200` | `404` | | DELETE | `/api/v1/admin/config/sites/{index}` | — | `200` | `404` |
### Группы целей: `/api/v1/admin/config/targets` ### Группы целей: `/api/v1/admin/config/targets`
@@ -570,8 +594,8 @@ queued ──(control-api сам, без вызова API)──▶ assigning_fi
в БД при этом уже `awaiting_self_check`. в БД при этом уже `awaiting_self_check`.
Площадки (`siteN_complete`) — опциональны: сколько их учитывается, Площадки (`siteN_complete`) — опциональны: сколько их учитывается,
целиком определяется текущим списком `sites` (0–3 записи, управляется целиком определяется текущим списком `sites` (без ограничения по числу
через `/api/v1/admin/config/sites` — см. записей, управляется через `/api/v1/admin/config/sites` — см.
[выше](#управление-очередью-и-конфигурацией)). Пустой список — агрегация [выше](#управление-очередью-и-конфигурацией)). Пустой список — агрегация
ждёт только `egress_complete`, ни одна площадка не требуется. Подробнее — ждёт только `egress_complete`, ни одна площадка не требуется. Подробнее —
[USAGE.md](USAGE.md#управление-площадками-проберами). [USAGE.md](USAGE.md#управление-площадками-проберами).
+1 -1
View File
@@ -52,7 +52,7 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
| `/ips` | Полная очередь. Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний), и всегда — «Удалить» (безвозвратно, в отличие от «Отменить», см. ниже). Чекбоксы у строк + кнопка «Удалить выбранные» удаляют список одним вызовом; «Очистить всё» удаляет вообще всё, включая активные проверки — обе операции требуют явного подтверждения. Пока не истекла настроенная на `/settings` пауза (`fip_settle_seconds`), только что привязавший Floating IP адрес показывает отдельный бейдж «прогрев FIP» вместо обычного статуса. | | `/ips` | Полная очередь. Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний), и всегда — «Удалить» (безвозвратно, в отличие от «Отменить», см. ниже). Чекбоксы у строк + кнопка «Удалить выбранные» удаляют список одним вызовом; «Очистить всё» удаляет вообще всё, включая активные проверки — обе операции требуют явного подтверждения. Пока не истекла настроенная на `/settings` пауза (`fip_settle_seconds`), только что привязавший Floating IP адрес показывает отдельный бейдж «прогрев FIP» вместо обычного статуса. |
| `/ips/{ip}` | Детали одного адреса: все проверки текущей попытки и вся история событий. | | `/ips/{ip}` | Детали одного адреса: все проверки текущей попытки и вся история событий. |
| `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. | | `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. |
| `/sites` | Три фиксированных слота площадок (1/2/3) — назначить/сменить/освободить `site_id`. | | `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). |
| `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. | | `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. |
| `/check-types` | Типы проверок (`https`/`icmp`/`ssh`/...), включение/выключение, привязка к группам целей. | | `/check-types` | Типы проверок (`https`/`icmp`/`ssh`/...), включение/выключение, привязка к группам целей. |
| `/settings` | Две формы: `fip_settle_seconds` — пауза (в секундах) между привязкой Floating IP и началом self-check («прогрев» дата-плейна OpenStack, см. [USAGE.md](USAGE.md#пауза-перед-self-check-fip_settle_seconds)); и типы проверок пробера — TCP-порты (через запятую) + чекбокс ICMP, общие для всех площадок (см. [USAGE.md](USAGE.md#управление-типами-проверок-пробера)). | | `/settings` | Две формы: `fip_settle_seconds` — пауза (в секундах) между привязкой Floating IP и началом self-check («прогрев» дата-плейна OpenStack, см. [USAGE.md](USAGE.md#пауза-перед-self-check-fip_settle_seconds)); и типы проверок пробера — TCP-порты (через запятую) + чекбокс ICMP, общие для всех площадок (см. [USAGE.md](USAGE.md#управление-типами-проверок-пробера)). |
+5 -5
View File
@@ -222,9 +222,9 @@ flowchart TB
проверки — подробнее о значениях полей см. проверки — подробнее о значениях полей см.
[USAGE.md](USAGE.md#значения-полей-ip). [USAGE.md](USAGE.md#значения-полей-ip).
> На диаграмме показан полный вариант с тремя площадками — это не > На диаграмме показан пример с тремя площадками — это лишь иллюстрация,
> обязательный минимум. Площадки опциональны: сколько их ожидать (0–3), > не ограничение. Площадки опциональны и их число не ограничено: сколько
> определяется списком `sites` в конфиге control-api. При пустом списке > их ожидать, определяется списком `sites` в конфиге control-api (пустой
> четвёртый поток телеметрии (`inbound-site-N`) просто отсутствует, и > список — ни одного потока `inbound-site-N`, агрегация ждёт только
> агрегация ждёт только `egress_complete`. См. > `egress_complete`; N площадок — N параллельных потоков телеметрии). См.
> [USAGE.md](USAGE.md#управление-площадками-проберами). > [USAGE.md](USAGE.md#управление-площадками-проберами).
+3 -3
View File
@@ -176,9 +176,9 @@ cp configs/control-api.example.yaml /etc/cloud-ip-validator/control-api.yaml
соответствующего `validator-agent`) и `os_port_id` — **ID Neutron-порта** соответствующего `validator-agent`) и `os_port_id` — **ID Neutron-порта**
основного сетевого интерфейса ВМ-валидатора (узнать: `openstack port основного сетевого интерфейса ВМ-валидатора (узнать: `openstack port
list --server <имя-ВМ>` или в веб-консоли облака). list --server <имя-ВМ>` или в веб-консоли облака).
- **`sites`** — три внешние площадки, `site_id` + `index` (1, 2 или 3). - **`sites`** — внешние площадки, `site_id` + `index` (число слотов не
`site_id` должен совпадать с `site_id` в конфиге соответствующего ограничено; в примере ниже — три). `site_id` должен совпадать с
`prober`. `site_id` в конфиге соответствующего `prober`.
- **`ip_addresses`** — список публичных IPv4-адресов на проверку, **в - **`ip_addresses`** — список публичных IPv4-адресов на проверку, **в
порядке обработки**. Адреса должны существовать в сервисном проекте как порядке обработки**. Адреса должны существовать в сервисном проекте как
уже выделенные (allocated) floating IP — инструмент их не создаёт. уже выделенные (allocated) floating IP — инструмент их не создаёт.
+43 -22
View File
@@ -123,10 +123,14 @@ curl -s http://<control-api>:8080/api/v1/admin/ips \
| `AttemptNumber` | Номер попытки — растёт при каждом requeue (сбой привязки, сбой self-check, реклейм по таймауту) | | `AttemptNumber` | Номер попытки — растёт при каждом requeue (сбой привязки, сбой self-check, реклейм по таймауту) |
| `RetryCount` | Сколько раз адрес уже переставлялся в очередь заново | | `RetryCount` | Сколько раз адрес уже переставлялся в очередь заново |
| `EgressComplete` | Валидатор закончил исходящие проверки | | `EgressComplete` | Валидатор закончил исходящие проверки |
| `Site1Complete` / `Site2Complete` / `Site3Complete` | Соответствующая площадка закончила входящие проверки |
| `OverallResult` | Итог: `pass`, `partial`, `fail`, `cancelled` (принудительно остановлена, см. [«Принудительная остановка проверки»](#принудительная-остановка-проверки)), либо пусто, пока проверка не завершена | | `OverallResult` | Итог: `pass`, `partial`, `fail`, `cancelled` (принудительно остановлена, см. [«Принудительная остановка проверки»](#принудительная-остановка-проверки)), либо пусто, пока проверка не завершена |
| `AssignedAt` / `AggregatedAt` / `FIPReleasedAt` | Метки времени соответствующих этапов | | `AssignedAt` / `AggregatedAt` / `FIPReleasedAt` | Метки времени соответствующих этапов |
> Завершённость входящих проверок по каждой конкретной площадке в этом
> списке не отображается (площадок теперь может быть сколько угодно, а не
> фиксированные три) — детали по конкретной площадке смотрите в массиве
> `checks` ответа `GET /api/v1/admin/ips/{ip}` (`Source: "inbound-site-N"`).
## Как читать итоговый результат (pass/partial/fail) ## Как читать итоговый результат (pass/partial/fail)
- **`pass`** — прошли все проверки: все исходящие, плюс все площадки по - **`pass`** — прошли все проверки: все исходящие, плюс все площадки по
@@ -237,18 +241,17 @@ curl -s -X DELETE http://<control-api>:8080/api/v1/admin/config/validators/valid
## Управление площадками (проберами) ## Управление площадками (проберами)
**Входящие (inbound/prober) проверки полностью опциональны.** Список **Входящие (inbound/prober) проверки полностью опциональны, а число
`sites` в конфиге control-api и есть переключатель: пусто — inbound- площадок не ограничено.** Список `sites` в конфиге control-api и есть
проверки выключены целиком, итоговый результат считается только по переключатель: пусто — inbound-проверки выключены целиком, итоговый
исходящим (egress) проверкам, и агрегация не ждёт вообще ни одного результат считается только по исходящим (egress) проверкам, и агрегация
пробера. Указан один или два слота — ждём только их, остальные не не ждёт вообще ни одного пробера. Указано N слотов — ждём именно их.
учитываются. Указаны все три — работает как в исходной схеме процесса.
Явного отдельного флага "включить/выключить" нет — самого списка `sites` Явного отдельного флага "включить/выключить" нет — самого списка `sites`
достаточно. достаточно.
Чтобы добавить площадку (без перезапуска control-api) — назначьте Чтобы добавить площадку (без перезапуска control-api) — назначьте
`site_id` на один из трёх слотов (`index` 1, 2 или 3 — см. ограничение `site_id` любому свободному слоту (`index` — любое целое `>= 1`, слотов
ниже) через API: может быть сколько угодно) через API:
```bash ```bash
curl -s -X PUT http://<control-api>:8080/api/v1/admin/config/sites/1 \ curl -s -X PUT http://<control-api>:8080/api/v1/admin/config/sites/1 \
-d '{"site_id": "site-1"}' -d '{"site_id": "site-1"}'
@@ -269,12 +272,26 @@ curl -s -X DELETE http://<control-api>:8080/api/v1/admin/config/sites/1
> игнорируется при всех последующих рестартах. Для стенда, который уже > игнорируется при всех последующих рестартах. Для стенда, который уже
> хоть раз запускался, используйте API выше. > хоть раз запускался, используйте API выше.
> Важно: количество *возможных* слотов площадок жёстко зашито в схему БД ### Состояния площадки
> (`Site1Complete`/`Site2Complete`/`Site3Complete`) — не более **трёх**,
> как и описано в исходной схеме процесса. `index` может быть только 1, 2 По аналогии с валидаторами (см. [«Управление
> или 3. Использовать *меньше* трёх (в том числе ноль) — штатный, валидаторами»](#управление-валидаторами) выше), у каждой площадки есть
> поддерживаемый сценарий; *больше* трёх потребует доработки схемы состояние подключения — видно и в `GET /api/v1/admin/config/sites`
> данных, одной правкой конфига не обойтись. (поля `hostname`/`state`/`last_heartbeat_at`), и на странице `/sites`
дашборда бейджем:
- **`unregistered`** — слот сконфигурирован, но процесс `prober` на этой
площадке ещё ни разу не подключался (не вызывал `POST
/api/v1/probers/register`).
- **`idle`** — площадка на связи: `prober` зарегистрирован и присылает
heartbeat (`POST /api/v1/probers/{site_id}/heartbeat`) на каждом опросе.
- **`unreachable`** — площадка пропустила heartbeat дольше
`orchestrator.heartbeat_timeout_seconds` (тот же параметр, что и для
валидаторов) — вероятно, процесс `prober` упал или потерял сеть до
control-api.
В отличие от валидатора, у площадки нет состояний `assigned`/`checking`
— пробер не привязан к одному IP, а на каждом опросе обрабатывает сразу
весь активный набор.
## Управление типами проверок пробера ## Управление типами проверок пробера
@@ -522,13 +539,17 @@ https://api.ipify.org`) и логи `journalctl -u validator-agent` на пре
сети) покажет приватный адрес валидатора независимо от того, правильно сети) покажет приватный адрес валидатора независимо от того, правильно
ли привязан FIP, и всегда будет давать ложный провал. ли привязан FIP, и всегда будет давать ложный провал.
**Площадка (`site-N`) никогда не отчитывается (`SiteNComplete` всегда **Площадка (`site-N`) никогда не отчитывается по конкретному IP.**
`false`).** Сперва проверьте статус самой площадки — `GET
Проверьте, что `prober` на этой площадке запущен и его `site_id` в /api/v1/admin/config/sites`, поле `state`. `unregistered` или
конфиге совпадает с `site_id` в конфиге control-api. Проверьте, что `unreachable` означает, что `prober` на этой площадке вообще не на связи
площадка имеет сетевой доступ и до `control-api`, и до проверяемого с control-api (см. [«Состояния площадки»](#состояния-площадки) выше) —
адреса (входящий трафик на 22/80/443/8080 + ICMP — это отдельная проверьте, что процесс запущен и его `site_id` в конфиге совпадает с
связность от связи с control-api, см. `site_id` в конфиге control-api, и что площадка имеет сетевой доступ до
`control-api`. Если статус `idle` (площадка на связи), а конкретный IP
всё равно не получает отметку о завершении — проверьте отдельно
связность до проверяемого адреса (входящий трафик на 22/80/443/8080 +
ICMP — это отдельная связность от связи с control-api, см.
[SETUP.md](SETUP.md#сетевые-доступы)). [SETUP.md](SETUP.md#сетевые-доступы)).
**Много адресов зависло в `checking` дольше ожидаемого.** **Много адресов зависло в `checking` дольше ожидаемого.**
+1 -1
View File
@@ -91,7 +91,7 @@ type ValidatorConfig struct {
type SiteConfig struct { type SiteConfig struct {
SiteID string `yaml:"site_id"` SiteID string `yaml:"site_id"`
Index int `yaml:"index"` // 1, 2, or 3 — maps to ip_queue.siteN_complete Index int `yaml:"index"` // any int >= 1, no cap on the number of slots
} }
// CheckTypeConfig maps a check type (https, icmp, ssh) to the named target // CheckTypeConfig maps a check type (https, icmp, ssh) to the named target
+15 -5
View File
@@ -26,6 +26,8 @@ type fakeControlAPI struct {
ips []ipQueueItem ips []ipQueueItem
validators []validatorDTO validators []validatorDTO
sites map[int]string sites map[int]string
siteState map[int]string
siteHostname map[int]string
groups map[string][]string groups map[string][]string
checkTypes map[string]checkTypeDTO checkTypes map[string]checkTypeDTO
fipSettleSeconds int fipSettleSeconds int
@@ -36,9 +38,11 @@ type fakeControlAPI struct {
func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) { func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
t.Helper() t.Helper()
f := &fakeControlAPI{ f := &fakeControlAPI{
sites: map[int]string{}, sites: map[int]string{},
groups: map[string][]string{}, siteState: map[int]string{},
checkTypes: map[string]checkTypeDTO{}, siteHostname: map[int]string{},
groups: map[string][]string{},
checkTypes: map[string]checkTypeDTO{},
} }
ts := httptest.NewServer(f.handler()) ts := httptest.NewServer(f.handler())
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
@@ -285,7 +289,7 @@ func (f *fakeControlAPI) handler() http.Handler {
defer f.mu.Unlock() defer f.mu.Unlock()
var out []siteDTO var out []siteDTO
for idx, id := range f.sites { for idx, id := range f.sites {
out = append(out, siteDTO{Index: idx, SiteID: id}) out = append(out, siteDTO{Index: idx, SiteID: id, Hostname: f.siteHostname[idx], State: f.siteState[idx]})
} }
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
}) })
@@ -305,7 +309,11 @@ func (f *fakeControlAPI) handler() http.Handler {
} }
} }
f.sites[idx] = req.SiteID f.sites[idx] = req.SiteID
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID}) // UpsertSite always resets connection status on write (see its
// control-api doc comment) — mirror that here.
f.siteState[idx] = "unregistered"
f.siteHostname[idx] = ""
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID, State: "unregistered"})
}) })
mux.HandleFunc("DELETE /api/v1/admin/config/sites/{index}", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("DELETE /api/v1/admin/config/sites/{index}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock() f.mu.Lock()
@@ -313,6 +321,8 @@ func (f *fakeControlAPI) handler() http.Handler {
var idx int var idx int
fmt.Sscanf(r.PathValue("index"), "%d", &idx) fmt.Sscanf(r.PathValue("index"), "%d", &idx)
delete(f.sites, idx) delete(f.sites, idx)
delete(f.siteState, idx)
delete(f.siteHostname, idx)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}) })
+5 -5
View File
@@ -30,9 +30,6 @@ type ipQueueItem struct {
RetryCount int RetryCount int
LeaseExpiresAt *time.Time LeaseExpiresAt *time.Time
EgressComplete bool EgressComplete bool
Site1Complete bool
Site2Complete bool
Site3Complete bool
OverallResult string OverallResult string
AssignedAt *time.Time AssignedAt *time.Time
FIPAssociatedAt *time.Time FIPAssociatedAt *time.Time
@@ -106,8 +103,11 @@ type validatorDTO struct {
} }
type siteDTO struct { type siteDTO struct {
Index int `json:"index"` Index int `json:"index"`
SiteID string `json:"site_id"` SiteID string `json:"site_id"`
Hostname string `json:"hostname"`
State string `json:"state"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
} }
type targetGroupDTO struct { type targetGroupDTO struct {
+22 -17
View File
@@ -8,26 +8,12 @@ import (
type sitesPageData struct { type sitesPageData struct {
PageData PageData
Items []siteDTO // always exactly 3 entries, index 1..3, SiteID=="" for an empty slot Items []siteDTO // dynamic length — as many slots as configured, no fixed cap
}
// fillSlots pads control-api's response (which only lists assigned slots)
// out to all three fixed slots, so the table always renders 3 rows.
func fillSlots(sites []siteDTO) []siteDTO {
byIndex := make(map[int]string, len(sites))
for _, s := range sites {
byIndex[s.Index] = s.SiteID
}
out := make([]siteDTO, 3)
for i := 0; i < 3; i++ {
out[i] = siteDTO{Index: i + 1, SiteID: byIndex[i+1]}
}
return out
} }
func (s *Server) handleSitesPage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSitesPage(w http.ResponseWriter, r *http.Request) {
items, err := s.CA.ListSites(r.Context()) items, err := s.CA.ListSites(r.Context())
data := sitesPageData{Items: fillSlots(items)} data := sitesPageData{Items: items}
data.ActiveNav = "sites" data.ActiveNav = "sites"
data.Banner = bannerFor(err) data.Banner = bannerFor(err)
s.renderPage(w, "sites_page", data) s.renderPage(w, "sites_page", data)
@@ -38,7 +24,26 @@ func (s *Server) renderSitesTable(w http.ResponseWriter, r *http.Request, action
if actionErr == nil { if actionErr == nil {
actionErr = listErr actionErr = listErr
} }
s.renderFragment(w, "sites_table", sitesPageData{Items: fillSlots(items)}, actionErr) s.renderFragment(w, "sites_table", sitesPageData{Items: items}, actionErr)
}
// handleSiteCreate adds a brand-new slot via the "add site" form — same
// create-alongside-update pattern as /targets' handleTargetCreate, just
// backed by the same PutSite call the per-row form already uses (there's
// no separate "create" endpoint on control-api since PUT is already an
// upsert).
func (s *Server) handleSiteCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderSitesTable(w, r, fmt.Errorf("invalid form: %w", err))
return
}
index, convErr := strconv.Atoi(r.PostFormValue("index"))
if convErr != nil {
s.renderSitesTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "index должен быть числом"})
return
}
err := s.CA.PutSite(r.Context(), index, r.PostFormValue("site_id"))
s.renderSitesTable(w, r, err)
} }
func (s *Server) handleSitePut(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSitePut(w http.ResponseWriter, r *http.Request) {
+51 -4
View File
@@ -316,11 +316,11 @@ func TestSitesPutAndConflict(t *testing.T) {
t.Fatalf("expected site-1 assigned to slot 1, got:\n%s", body) t.Fatalf("expected site-1 assigned to slot 1, got:\n%s", body)
} }
// Slot page always renders exactly 3 rows, including empty ones — count // The table renders exactly as many rows as configured slots — no
// the per-slot PUT forms rather than raw <tr> (the thead row has one too). // fixed cap, no padding to a fixed count.
page := get(t, ts, "/sites") page := get(t, ts, "/sites")
if strings.Count(page, `hx-put="/sites/`) != 3 { if strings.Count(page, `hx-put="/sites/`) != 1 {
t.Fatalf("expected exactly 3 site slot rows, got:\n%s", page) t.Fatalf("expected exactly 1 site slot row, got:\n%s", page)
} }
body = postForm(t, ts, "PUT", "/sites/2", map[string][]string{"site_id": {"site-1"}}) body = postForm(t, ts, "PUT", "/sites/2", map[string][]string{"site_id": {"site-1"}})
@@ -329,6 +329,53 @@ func TestSitesPutAndConflict(t *testing.T) {
} }
} }
// TestSiteCreateAddsSlotBeyondThree proves there's no hardcoded cap on the
// number of prober slots — the "add site" form can create slot 4 and
// beyond.
func TestSiteCreateAddsSlotBeyondThree(t *testing.T) {
_, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
postForm(t, ts, "PUT", "/sites/1", map[string][]string{"site_id": {"site-1"}})
postForm(t, ts, "PUT", "/sites/2", map[string][]string{"site_id": {"site-2"}})
postForm(t, ts, "PUT", "/sites/3", map[string][]string{"site_id": {"site-3"}})
body := postForm(t, ts, "POST", "/sites", map[string][]string{"index": {"4"}, "site_id": {"site-4"}})
if !strings.Contains(body, "site-4") {
t.Fatalf("expected slot 4 created, got:\n%s", body)
}
if strings.Count(body, `hx-put="/sites/`) != 4 {
t.Fatalf("expected exactly 4 site slot rows, got:\n%s", body)
}
// A malformed index is caught by the dashboard itself.
body = postForm(t, ts, "POST", "/sites", map[string][]string{"index": {"not-a-number"}, "site_id": {"site-5"}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for non-numeric index, got:\n%s", body)
}
}
func TestSitesPageShowsProberBadge(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.sites[1] = "site-1"
fake.siteState[1] = "idle"
fake.siteHostname[1] = "probe-host-1"
fake.sites[2] = "site-2"
fake.siteState[2] = "unreachable"
ts := newTestServer(t, caURL)
page := get(t, ts, "/sites")
if !strings.Contains(page, "probe-host-1") {
t.Fatalf("expected hostname shown, got:\n%s", page)
}
if !strings.Contains(page, "pill-success") || !strings.Contains(page, ">idle<") {
t.Fatalf("expected idle badge for site-1, got:\n%s", page)
}
if !strings.Contains(page, "pill-danger") || !strings.Contains(page, ">unreachable<") {
t.Fatalf("expected unreachable badge for site-2, got:\n%s", page)
}
}
func TestTargetsAndCheckTypesRoundTrip(t *testing.T) { func TestTargetsAndCheckTypesRoundTrip(t *testing.T) {
_, caURL := newFakeControlAPI(t) _, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL) ts := newTestServer(t, caURL)
+8 -4
View File
@@ -101,10 +101,14 @@ func joinInts(ints []int, sep string) string {
var funcMap = template.FuncMap{ var funcMap = template.FuncMap{
"ipBadge": ipBadge, "ipBadge": ipBadge,
"validatorBadge": validatorBadge, "validatorBadge": validatorBadge,
"fmtTime": fmtTime, // proberBadge reuses validatorBadge as-is: prober states
"deref": derefStr, // (unregistered/idle/unreachable) are a subset of validator states
"join": strings.Join, // with identical labels/styles, so no separate function is needed.
"joinInts": joinInts, "proberBadge": validatorBadge,
"fmtTime": fmtTime,
"deref": derefStr,
"join": strings.Join,
"joinInts": joinInts,
} }
func parseTemplates() (*template.Template, error) { func parseTemplates() (*template.Template, error) {
+1
View File
@@ -23,6 +23,7 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("DELETE /validators/{id}", s.handleValidatorDelete) mux.HandleFunc("DELETE /validators/{id}", s.handleValidatorDelete)
mux.HandleFunc("GET /sites", s.handleSitesPage) mux.HandleFunc("GET /sites", s.handleSitesPage)
mux.HandleFunc("POST /sites", s.handleSiteCreate)
mux.HandleFunc("PUT /sites/{index}", s.handleSitePut) mux.HandleFunc("PUT /sites/{index}", s.handleSitePut)
mux.HandleFunc("DELETE /sites/{index}", s.handleSiteDelete) mux.HandleFunc("DELETE /sites/{index}", s.handleSiteDelete)
+28 -6
View File
@@ -20,8 +20,27 @@
{{define "sites_content"}} {{define "sites_content"}}
<div class="topbar"><h1>Площадки</h1></div> <div class="topbar"><h1>Площадки</h1></div>
<p class="muted" style="margin-bottom:16px">Ровно три слота (1, 2, 3) — жёсткое ограничение схемы БД control-api. <p class="muted" style="margin-bottom:16px">Внешние площадки (проберы), проверяющие адреса «снаружи». Число площадок
Пустой слот — площадка не используется, входящие проверки для неё не ожидаются.</p> не ограничено — добавляйте столько слотов, сколько нужно. «Статус» показывает, подключён ли прямо сейчас
процесс prober на этой площадке (по heartbeat, аналогично валидаторам).</p>
<div class="panel">
<div class="panel-body">
<form hx-post="/sites" hx-target="#sites-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<div class="field-row">
<div class="field" style="flex: 0 1 120px;">
<label>Слот (index)</label>
<input type="number" name="index" min="1" step="1" required>
</div>
<div class="field" style="flex: 1 1 240px;">
<label>site_id</label>
<input type="text" name="site_id" placeholder="site-4" required>
</div>
<button type="submit" class="btn btn-primary">Добавить</button>
</div>
</form>
</div>
</div>
<div id="sites-table-wrap"> <div id="sites-table-wrap">
{{template "sites_table" .}} {{template "sites_table" .}}
@@ -32,9 +51,10 @@
<div class="panel"> <div class="panel">
<div class="table-scroll"> <div class="table-scroll">
<table> <table>
<thead><tr><th>Слот</th><th>site_id</th><th></th></tr></thead> <thead><tr><th>Слот</th><th>site_id</th><th>Хост</th><th>Статус</th><th>Heartbeat</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Items}} {{range .Items}}
{{$b := proberBadge .State}}
<tr> <tr>
<td class="num" data-label="Слот">{{.Index}}</td> <td class="num" data-label="Слот">{{.Index}}</td>
<td data-label="site_id"> <td data-label="site_id">
@@ -43,17 +63,19 @@
<button type="submit" class="btn btn-ghost btn-sm">Сохранить</button> <button type="submit" class="btn btn-ghost btn-sm">Сохранить</button>
</form> </form>
</td> </td>
<td class="mono" data-label="Хост">{{if .Hostname}}{{.Hostname}}{{else}}—{{end}}</td>
<td data-label="Статус"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
<td data-label="Heartbeat">{{fmtTime .LastHeartbeatAt}}</td>
<td data-label=""> <td data-label="">
<div class="actions"> <div class="actions">
{{if .SiteID}} <button class="btn btn-danger-ghost btn-sm" hx-delete="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить слот {{.Index}} ({{.SiteID}})?">Удалить</button>
<button class="btn btn-danger-ghost btn-sm" hx-delete="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML" hx-confirm="Освободить слот {{.Index}}?">Очистить</button>
{{end}}
</div> </div>
</td> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
{{if not .Items}}<p class="muted" style="padding:16px">Площадок пока нет — добавьте первую формой выше.</p>{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
+8
View File
@@ -25,6 +25,12 @@ var fipSettleDelaySchema string
//go:embed migrations/0004_inbound_checks_admin.sql //go:embed migrations/0004_inbound_checks_admin.sql
var inboundChecksAdminSchema string var inboundChecksAdminSchema string
//go:embed migrations/0005_unbounded_sites.sql
var unboundedSitesSchema string
//go:embed migrations/0006_prober_heartbeat.sql
var proberHeartbeatSchema string
// migrations is the ordered list of schema versions. Each entry's SQL is // migrations is the ordered list of schema versions. Each entry's SQL is
// applied, in order, for any version greater than the database's current // applied, in order, for any version greater than the database's current
// PRAGMA user_version — so a fresh database walks the whole list and an // PRAGMA user_version — so a fresh database walks the whole list and an
@@ -37,6 +43,8 @@ var migrations = []struct {
{2, dynamicConfigSchema}, {2, dynamicConfigSchema},
{3, fipSettleDelaySchema}, {3, fipSettleDelaySchema},
{4, inboundChecksAdminSchema}, {4, inboundChecksAdminSchema},
{5, unboundedSitesSchema},
{6, proberHeartbeatSchema},
} }
type DB struct { type DB struct {
@@ -0,0 +1,19 @@
-- Remove the hardcoded 3-site limit: ip_queue.site{1,2,3}_complete are
-- replaced by a child table with no cap on site_idx, keyed by
-- attempt_number the same way `checks` already is (see docs/USAGE.md).
ALTER TABLE ip_queue DROP COLUMN site1_complete;
ALTER TABLE ip_queue DROP COLUMN site2_complete;
ALTER TABLE ip_queue DROP COLUMN site3_complete;
-- Per-IP-per-site inbound-check completion. No fixed cap on site_idx, and
-- no reset needed on retry — a new attempt_number simply has no rows yet,
-- unlike the old fixed columns which had to be explicitly zeroed.
CREATE TABLE ip_site_checks (
ip_id INTEGER NOT NULL REFERENCES ip_queue(id),
attempt_number INTEGER NOT NULL,
site_idx INTEGER NOT NULL,
complete BOOLEAN NOT NULL DEFAULT 0,
completed_at TIMESTAMP,
PRIMARY KEY (ip_id, attempt_number, site_idx)
);
@@ -0,0 +1,8 @@
-- Prober availability indication (see docs/USAGE.md, "Управление
-- площадками"): sites gain the same hostname/state/heartbeat tracking
-- validators already have, so the dashboard can show whether a prober is
-- actually connected and polling.
ALTER TABLE sites ADD COLUMN hostname TEXT NOT NULL DEFAULT '';
ALTER TABLE sites ADD COLUMN state TEXT NOT NULL DEFAULT 'unregistered';
ALTER TABLE sites ADD COLUMN last_heartbeat_at TIMESTAMP;
+13 -7
View File
@@ -12,6 +12,12 @@ const (
ValidatorChecking = "checking" ValidatorChecking = "checking"
ValidatorUnreachable = "unreachable" ValidatorUnreachable = "unreachable"
// Site (prober) availability states — no assigned/checking equivalent,
// since a prober isn't bound to one IP at a time.
SiteUnregistered = "unregistered"
SiteIdle = "idle"
SiteUnreachable = "unreachable"
IPQueued = "queued" IPQueued = "queued"
IPAssigningFIP = "assigning_fip" IPAssigningFIP = "assigning_fip"
IPAwaitingSelfCheck = "awaiting_self_check" IPAwaitingSelfCheck = "awaiting_self_check"
@@ -79,9 +85,6 @@ type IPQueueItem struct {
RetryCount int RetryCount int
LeaseExpiresAt *time.Time LeaseExpiresAt *time.Time
EgressComplete bool EgressComplete bool
Site1Complete bool
Site2Complete bool
Site3Complete bool
OverallResult string OverallResult string
AssignedAt *time.Time AssignedAt *time.Time
FIPAssociatedAt *time.Time FIPAssociatedAt *time.Time
@@ -119,10 +122,13 @@ type Event struct {
} }
type Site struct { type Site struct {
Index int Index int
SiteID string SiteID string
CreatedAt time.Time Hostname string
UpdatedAt time.Time State string
LastHeartbeatAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
} }
type TargetGroup struct { type TargetGroup struct {
+18 -3
View File
@@ -28,13 +28,18 @@ func TestUpsertSiteValidation(t *testing.T) {
if err := d.UpsertSite(ctx, 0, "site-1"); !errors.Is(err, ErrValidation) { if err := d.UpsertSite(ctx, 0, "site-1"); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for idx=0, got %v", err) t.Fatalf("expected ErrValidation for idx=0, got %v", err)
} }
if err := d.UpsertSite(ctx, 4, "site-1"); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for idx=4, got %v", err)
}
if err := d.UpsertSite(ctx, 1, ""); !errors.Is(err, ErrValidation) { if err := d.UpsertSite(ctx, 1, ""); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for empty site_id, got %v", err) t.Fatalf("expected ErrValidation for empty site_id, got %v", err)
} }
// No cap on the number of slots — idx=4 and idx=100 must both succeed.
if err := d.UpsertSite(ctx, 4, "site-4"); err != nil {
t.Fatalf("upsert site 4: %v", err)
}
if err := d.UpsertSite(ctx, 100, "site-100"); err != nil {
t.Fatalf("upsert site 100: %v", err)
}
if err := d.UpsertSite(ctx, 1, "site-1"); err != nil { if err := d.UpsertSite(ctx, 1, "site-1"); err != nil {
t.Fatalf("upsert site 1: %v", err) t.Fatalf("upsert site 1: %v", err)
} }
@@ -352,6 +357,9 @@ func TestDeleteIP(t *testing.T) {
if err := d.InsertEvent(ctx, Event{SourceType: "control-api", IPID: &ip.ID, EventType: "test_event", OccurredAt: Now()}); err != nil { if err := d.InsertEvent(ctx, Event{SourceType: "control-api", IPID: &ip.ID, EventType: "test_event", OccurredAt: Now()}); err != nil {
t.Fatalf("insert event: %v", err) t.Fatalf("insert event: %v", err)
} }
if err := d.SetSiteComplete(ctx, ip.ID, 1); err != nil {
t.Fatalf("set site complete: %v", err)
}
if err := d.DeleteIP(ctx, ip.ID); err != nil { if err := d.DeleteIP(ctx, ip.ID); err != nil {
t.Fatalf("delete ip: %v", err) t.Fatalf("delete ip: %v", err)
@@ -366,6 +374,13 @@ func TestDeleteIP(t *testing.T) {
if len(checks) != 0 { if len(checks) != 0 {
t.Fatalf("expected checks gone, got %+v", checks) t.Fatalf("expected checks gone, got %+v", checks)
} }
completed, err := d.ListCompletedSiteIndices(ctx, ip.ID, ip.AttemptNumber)
if err != nil {
t.Fatalf("list completed site indices: %v", err)
}
if len(completed) != 0 {
t.Fatalf("expected ip_site_checks rows gone, got %+v", completed)
}
events, err := d.ListEventsForIP(ctx, ip.ID) events, err := d.ListEventsForIP(ctx, ip.ID)
if err != nil { if err != nil {
t.Fatalf("list events: %v", err) t.Fatalf("list events: %v", err)
+7 -14
View File
@@ -196,7 +196,7 @@ func (d *DB) RequeueOrFail(ctx context.Context, ipID int64, validatorID string,
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
UPDATE ip_queue SET UPDATE ip_queue SET
state=?, owner_validator_id=NULL, fip_id='', retry_count=?, attempt_number=attempt_number+1, state=?, owner_validator_id=NULL, fip_id='', retry_count=?, attempt_number=attempt_number+1,
lease_expires_at=NULL, egress_complete=0, site1_complete=0, site2_complete=0, site3_complete=0, lease_expires_at=NULL, egress_complete=0,
overall_result='', assigned_at=NULL, fip_associated_at=NULL, updated_at=? overall_result='', assigned_at=NULL, fip_associated_at=NULL, updated_at=?
WHERE id=? WHERE id=?
`, nextState, retryCount, now, ipID) `, nextState, retryCount, now, ipID)
@@ -282,7 +282,7 @@ func (d *DB) SubmitIPs(ctx context.Context, addresses []string) (SubmitIPsResult
UPDATE ip_queue SET UPDATE ip_queue SET
state=?, sequence=?, owner_validator_id=NULL, fip_id='', retry_count=0, state=?, sequence=?, owner_validator_id=NULL, fip_id='', retry_count=0,
attempt_number=attempt_number+1, lease_expires_at=NULL, egress_complete=0, attempt_number=attempt_number+1, lease_expires_at=NULL, egress_complete=0,
site1_complete=0, site2_complete=0, site3_complete=0, overall_result='', overall_result='',
assigned_at=NULL, fip_associated_at=NULL, aggregated_at=NULL, fip_released_at=NULL, updated_at=? assigned_at=NULL, fip_associated_at=NULL, aggregated_at=NULL, fip_released_at=NULL, updated_at=?
WHERE ip_address=? WHERE ip_address=?
`, IPQueued, seq, now, addr); err != nil { `, IPQueued, seq, now, addr); err != nil {
@@ -409,6 +409,9 @@ func deleteIPTx(ctx context.Context, tx *sql.Tx, ipID int64) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE ip_id=?`, ipID); err != nil { if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE ip_id=?`, ipID); err != nil {
return fmt.Errorf("delete events: %w", err) return fmt.Errorf("delete events: %w", err)
} }
if _, err := tx.ExecContext(ctx, `DELETE FROM ip_site_checks WHERE ip_id=?`, ipID); err != nil {
return fmt.Errorf("delete ip_site_checks: %w", err)
}
res, err := tx.ExecContext(ctx, `DELETE FROM ip_queue WHERE id=?`, ipID) res, err := tx.ExecContext(ctx, `DELETE FROM ip_queue WHERE id=?`, ipID)
if err != nil { if err != nil {
return fmt.Errorf("delete ip_queue row: %w", err) return fmt.Errorf("delete ip_queue row: %w", err)
@@ -424,16 +427,6 @@ func (d *DB) SetEgressComplete(ctx context.Context, ipID int64) error {
return err return err
} }
// SetSiteComplete marks completion for prober site 1, 2, or 3.
func (d *DB) SetSiteComplete(ctx context.Context, ipID int64, siteIndex int) error {
col := map[int]string{1: "site1_complete", 2: "site2_complete", 3: "site3_complete"}[siteIndex]
if col == "" {
return fmt.Errorf("invalid site index %d", siteIndex)
}
_, err := d.ExecContext(ctx, fmt.Sprintf(`UPDATE ip_queue SET %s=1, updated_at=? WHERE id=?`, col), timeToDB(Now()), ipID)
return err
}
func (d *DB) GetIP(ctx context.Context, ipID int64) (*IPQueueItem, error) { func (d *DB) GetIP(ctx context.Context, ipID int64) (*IPQueueItem, error) {
row := d.QueryRowContext(ctx, ipQueueSelect+`WHERE id=?`, ipID) row := d.QueryRowContext(ctx, ipQueueSelect+`WHERE id=?`, ipID)
return scanIPQueueItem(row) return scanIPQueueItem(row)
@@ -479,7 +472,7 @@ func (d *DB) ListExpiredLeases(ctx context.Context, now time.Time) ([]IPQueueIte
const ipQueueSelect = ` const ipQueueSelect = `
SELECT id, ip_address, sequence, state, owner_validator_id, fip_id, attempt_number, retry_count, SELECT id, ip_address, sequence, state, owner_validator_id, fip_id, attempt_number, retry_count,
lease_expires_at, egress_complete, site1_complete, site2_complete, site3_complete, overall_result, lease_expires_at, egress_complete, overall_result,
assigned_at, fip_associated_at, aggregated_at, fip_released_at, created_at, updated_at assigned_at, fip_associated_at, aggregated_at, fip_released_at, created_at, updated_at
FROM ip_queue FROM ip_queue
` `
@@ -504,7 +497,7 @@ func scanIPQueueItem(row rowScanner) (*IPQueueItem, error) {
if err := row.Scan( if err := row.Scan(
&item.ID, &item.IPAddress, &item.Sequence, &item.State, &owner, &item.FIPID, &item.ID, &item.IPAddress, &item.Sequence, &item.State, &owner, &item.FIPID,
&item.AttemptNumber, &item.RetryCount, &leaseExpires, &item.AttemptNumber, &item.RetryCount, &leaseExpires,
&item.EgressComplete, &item.Site1Complete, &item.Site2Complete, &item.Site3Complete, &item.EgressComplete,
&item.OverallResult, &assignedAt, &fipAssociatedAt, &aggregatedAt, &fipReleasedAt, &createdAt, &updatedAt, &item.OverallResult, &assignedAt, &fipAssociatedAt, &aggregatedAt, &fipReleasedAt, &createdAt, &updatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
+39
View File
@@ -0,0 +1,39 @@
package db
import "context"
// SetSiteComplete marks a given site's inbound checks complete for an IP's
// *current* attempt. attempt_number is resolved via subquery from
// ip_queue rather than taken as a parameter, so the call signature stays
// the same as before the fixed-3-columns design was replaced by this
// table. No cap on siteIndex.
func (d *DB) SetSiteComplete(ctx context.Context, ipID int64, siteIndex int) error {
_, err := d.ExecContext(ctx, `
INSERT INTO ip_site_checks (ip_id, attempt_number, site_idx, complete, completed_at)
SELECT ?, attempt_number, ?, 1, ? FROM ip_queue WHERE id=?
ON CONFLICT(ip_id, attempt_number, site_idx) DO UPDATE SET complete=1, completed_at=excluded.completed_at
`, ipID, siteIndex, timeToDB(Now()), ipID)
return err
}
// ListCompletedSiteIndices returns the set of site indices that have
// reported completion for the given IP's attempt.
func (d *DB) ListCompletedSiteIndices(ctx context.Context, ipID int64, attemptNumber int) (map[int]bool, error) {
rows, err := d.QueryContext(ctx, `
SELECT site_idx FROM ip_site_checks WHERE ip_id=? AND attempt_number=? AND complete=1
`, ipID, attemptNumber)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int]bool{}
for rows.Next() {
var idx int
if err := rows.Scan(&idx); err != nil {
return nil, err
}
out[idx] = true
}
return out, rows.Err()
}
+92
View File
@@ -0,0 +1,92 @@
package db
import "testing"
func TestSetSiteCompleteAndListCompletedSiteIndices(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
if err != nil {
t.Fatalf("get ip: %v", err)
}
for _, idx := range []int{1, 2, 4} {
if err := d.SetSiteComplete(ctx, ip.ID, idx); err != nil {
t.Fatalf("set site %d complete: %v", idx, err)
}
}
completed, err := d.ListCompletedSiteIndices(ctx, ip.ID, ip.AttemptNumber)
if err != nil {
t.Fatalf("list completed site indices: %v", err)
}
for _, idx := range []int{1, 2, 4} {
if !completed[idx] {
t.Fatalf("expected site %d complete, got %+v", idx, completed)
}
}
if completed[3] {
t.Fatalf("expected site 3 not complete, got %+v", completed)
}
// Idempotent re-mark doesn't error or duplicate.
if err := d.SetSiteComplete(ctx, ip.ID, 1); err != nil {
t.Fatalf("re-mark site 1 complete: %v", err)
}
}
// TestNewAttemptStartsWithNoCompletedSites confirms a bumped
// attempt_number (via SubmitIPs requeue) starts with an empty completed
// set even though the previous attempt had rows — proving no explicit
// reset is needed on retry, unlike the old fixed-3-column design.
func TestNewAttemptStartsWithNoCompletedSites(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
if err != nil {
t.Fatalf("get ip: %v", err)
}
if err := d.SetSiteComplete(ctx, ip.ID, 1); err != nil {
t.Fatalf("set site complete: %v", err)
}
// Force the IP to a terminal state, then requeue via SubmitIPs — the
// same path an admin-triggered recheck takes — to bump attempt_number.
if _, err := d.ExecContext(ctx, `UPDATE ip_queue SET state=? WHERE id=?`, IPFailed, ip.ID); err != nil {
t.Fatalf("force failed: %v", err)
}
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("resubmit: %v", err)
}
requeued, err := d.GetIP(ctx, ip.ID)
if err != nil {
t.Fatalf("get ip after requeue: %v", err)
}
if requeued.AttemptNumber != ip.AttemptNumber+1 {
t.Fatalf("expected attempt_number bumped, got %d", requeued.AttemptNumber)
}
completed, err := d.ListCompletedSiteIndices(ctx, ip.ID, requeued.AttemptNumber)
if err != nil {
t.Fatalf("list completed site indices for new attempt: %v", err)
}
if len(completed) != 0 {
t.Fatalf("expected no completed sites for the new attempt, got %+v", completed)
}
// The old attempt's row is still there as history, not wiped.
old, err := d.ListCompletedSiteIndices(ctx, ip.ID, ip.AttemptNumber)
if err != nil {
t.Fatalf("list completed site indices for old attempt: %v", err)
}
if !old[1] {
t.Fatalf("expected old attempt's completion to remain as history, got %+v", old)
}
}
+116 -19
View File
@@ -4,12 +4,13 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"time"
) )
// ListSites returns all configured prober sites, ordered by slot index. // ListSites returns all configured prober sites, ordered by slot index.
func (d *DB) ListSites(ctx context.Context) ([]Site, error) { func (d *DB) ListSites(ctx context.Context) ([]Site, error) {
rows, err := d.QueryContext(ctx, ` rows, err := d.QueryContext(ctx, `
SELECT idx, site_id, created_at, updated_at FROM sites ORDER BY idx SELECT idx, site_id, hostname, state, last_heartbeat_at, created_at, updated_at FROM sites ORDER BY idx
`) `)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -18,24 +19,37 @@ func (d *DB) ListSites(ctx context.Context) ([]Site, error) {
var out []Site var out []Site
for rows.Next() { for rows.Next() {
var s Site s, err := scanSite(rows)
var createdAt, updatedAt string if err != nil {
if err := rows.Scan(&s.Index, &s.SiteID, &createdAt, &updatedAt); err != nil {
return nil, err return nil, err
} }
var err error out = append(out, *s)
if s.CreatedAt, err = dbToTime(createdAt); err != nil {
return nil, err
}
if s.UpdatedAt, err = dbToTime(updatedAt); err != nil {
return nil, err
}
out = append(out, s)
} }
return out, rows.Err() return out, rows.Err()
} }
// GetSiteIndex resolves a configured site_id to its 1/2/3 slot index. It func scanSite(row rowScanner) (*Site, error) {
var s Site
var lastHeartbeat sql.NullString
var createdAt, updatedAt string
if err := row.Scan(&s.Index, &s.SiteID, &s.Hostname, &s.State, &lastHeartbeat, &createdAt, &updatedAt); err != nil {
return nil, err
}
hb, err := nullStringToTimePtr(lastHeartbeat)
if err != nil {
return nil, err
}
s.LastHeartbeatAt = hb
if s.CreatedAt, err = dbToTime(createdAt); err != nil {
return nil, err
}
if s.UpdatedAt, err = dbToTime(updatedAt); err != nil {
return nil, err
}
return &s, nil
}
// GetSiteIndex resolves a configured site_id to its slot index. It
// returns (0, nil) — not an error — if no site with that ID is configured, // returns (0, nil) — not an error — if no site with that ID is configured,
// matching the "0 means unconfigured" convention used throughout the // matching the "0 means unconfigured" convention used throughout the
// prober-facing handlers. // prober-facing handlers.
@@ -52,12 +66,21 @@ func (d *DB) GetSiteIndex(ctx context.Context, siteID string) (int, error) {
} }
// UpsertSite assigns (or renames) the site occupying the given slot. idx // UpsertSite assigns (or renames) the site occupying the given slot. idx
// must be 1, 2, or 3 — the schema hard-caps the number of prober slots at // has no upper bound — an admin can configure as many prober sites as they
// three (see ip_queue.site{1,2,3}_complete). site_id must be unique across // need — only idx >= 1 is enforced (slot numbering starts at 1, matching
// the existing admin-facing convention). site_id must be unique across
// slots. // slots.
//
// Re-pointing an existing slot to a (possibly different) site_id always
// resets hostname/state/last_heartbeat_at to their "never connected"
// defaults: a new site_id is a new prober identity, and even a same-value
// PUT is treated as idempotent-intent rather than a heartbeat, so the old
// prober's connection status must not silently linger. The prober
// re-establishes state on its next register/heartbeat within
// poll_interval_seconds.
func (d *DB) UpsertSite(ctx context.Context, idx int, siteID string) error { func (d *DB) UpsertSite(ctx context.Context, idx int, siteID string) error {
if idx < 1 || idx > 3 { if idx < 1 {
return fmt.Errorf("site index must be 1, 2, or 3, got %d: %w", idx, ErrValidation) return fmt.Errorf("site index must be >= 1, got %d: %w", idx, ErrValidation)
} }
if siteID == "" { if siteID == "" {
return fmt.Errorf("site_id must not be empty: %w", ErrValidation) return fmt.Errorf("site_id must not be empty: %w", ErrValidation)
@@ -76,8 +99,10 @@ func (d *DB) UpsertSite(ctx context.Context, idx int, siteID string) error {
_, err = d.ExecContext(ctx, ` _, err = d.ExecContext(ctx, `
INSERT INTO sites (idx, site_id, created_at, updated_at) INSERT INTO sites (idx, site_id, created_at, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(idx) DO UPDATE SET site_id=excluded.site_id, updated_at=excluded.updated_at ON CONFLICT(idx) DO UPDATE SET
`, idx, siteID, now, now) site_id=excluded.site_id, updated_at=excluded.updated_at,
hostname='', state=?, last_heartbeat_at=NULL
`, idx, siteID, now, now, SiteUnregistered)
if err != nil { if err != nil {
return fmt.Errorf("upsert site: %w", err) return fmt.Errorf("upsert site: %w", err)
} }
@@ -95,3 +120,75 @@ func (d *DB) DeleteSite(ctx context.Context, idx int) error {
} }
return nil return nil
} }
// RegisterSiteProber records that a prober has (re)connected for this
// site: sets hostname, stamps last_heartbeat_at, and reactivates state to
// idle if it was unregistered or unreachable — mirrors RegisterValidator's
// reactivation UPDATE. Called once at prober startup. Returns
// sql.ErrNoRows if site_id is unconfigured.
func (d *DB) RegisterSiteProber(ctx context.Context, siteID, hostname string) error {
now := timeToDB(Now())
res, err := d.ExecContext(ctx, `
UPDATE sites SET hostname=?, last_heartbeat_at=?, updated_at=?,
state = CASE WHEN state IN (?, ?) THEN ? ELSE state END
WHERE site_id=?
`, hostname, now, now, SiteUnregistered, SiteUnreachable, SiteIdle, siteID)
if err != nil {
return fmt.Errorf("register site prober: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// SiteHeartbeat is the periodic "I'm alive" touch — mirrors Heartbeat for
// validators exactly: stamps last_heartbeat_at and flips state from
// unreachable back to idle (does not touch hostname).
func (d *DB) SiteHeartbeat(ctx context.Context, siteID string) error {
now := timeToDB(Now())
res, err := d.ExecContext(ctx, `
UPDATE sites SET last_heartbeat_at=?, updated_at=?,
state = CASE WHEN state=? THEN ? ELSE state END
WHERE site_id=?
`, now, now, SiteUnreachable, SiteIdle, siteID)
if err != nil {
return fmt.Errorf("site heartbeat: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// ListStaleSiteHeartbeats returns sites whose last heartbeat predates the
// given cutoff and that aren't already marked unreachable.
func (d *DB) ListStaleSiteHeartbeats(ctx context.Context, cutoff time.Time) ([]Site, error) {
rows, err := d.QueryContext(ctx, `
SELECT idx, site_id, hostname, state, last_heartbeat_at, created_at, updated_at
FROM sites
WHERE state != ? AND last_heartbeat_at IS NOT NULL AND last_heartbeat_at < ?
`, SiteUnreachable, timeToDB(cutoff))
if err != nil {
return nil, err
}
defer rows.Close()
var out []Site
for rows.Next() {
s, err := scanSite(rows)
if err != nil {
return nil, err
}
out = append(out, *s)
}
return out, rows.Err()
}
func (d *DB) MarkSiteUnreachable(ctx context.Context, siteID string) error {
_, err := d.ExecContext(ctx, `UPDATE sites SET state=?, updated_at=? WHERE site_id=?`,
SiteUnreachable, timeToDB(Now()), siteID)
return err
}
+172
View File
@@ -0,0 +1,172 @@
package db
import (
"database/sql"
"errors"
"testing"
"time"
)
func TestRegisterSiteProberUnknownSiteReturnsErrNoRows(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.RegisterSiteProber(ctx, "unknown-site", "host-1"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected sql.ErrNoRows for unknown site, got %v", err)
}
}
func TestRegisterSiteProberSetsHostnameAndIdleState(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.UpsertSite(ctx, 1, "site-1"); err != nil {
t.Fatalf("upsert site: %v", err)
}
sites, err := d.ListSites(ctx)
if err != nil {
t.Fatalf("list sites: %v", err)
}
if sites[0].State != SiteUnregistered {
t.Fatalf("expected freshly-created slot unregistered, got %s", sites[0].State)
}
if err := d.RegisterSiteProber(ctx, "site-1", "probe-host-1"); err != nil {
t.Fatalf("register site prober: %v", err)
}
sites, err = d.ListSites(ctx)
if err != nil {
t.Fatalf("list sites after register: %v", err)
}
if sites[0].State != SiteIdle {
t.Fatalf("expected idle after register, got %s", sites[0].State)
}
if sites[0].Hostname != "probe-host-1" {
t.Fatalf("expected hostname set, got %q", sites[0].Hostname)
}
if sites[0].LastHeartbeatAt == nil {
t.Fatalf("expected last_heartbeat_at stamped")
}
}
func TestRegisterSiteProberReactivatesFromUnreachable(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.UpsertSite(ctx, 1, "site-1"); err != nil {
t.Fatalf("upsert site: %v", err)
}
if err := d.MarkSiteUnreachable(ctx, "site-1"); err != nil {
t.Fatalf("mark unreachable: %v", err)
}
if err := d.RegisterSiteProber(ctx, "site-1", "probe-host-1"); err != nil {
t.Fatalf("register site prober: %v", err)
}
sites, err := d.ListSites(ctx)
if err != nil {
t.Fatalf("list sites: %v", err)
}
if sites[0].State != SiteIdle {
t.Fatalf("expected reactivated to idle, got %s", sites[0].State)
}
}
func TestSiteHeartbeatRoundTrip(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.UpsertSite(ctx, 1, "site-1"); err != nil {
t.Fatalf("upsert site: %v", err)
}
if err := d.SiteHeartbeat(ctx, "unknown-site"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected sql.ErrNoRows for unknown site, got %v", err)
}
if err := d.SiteHeartbeat(ctx, "site-1"); err != nil {
t.Fatalf("heartbeat: %v", err)
}
sites, err := d.ListSites(ctx)
if err != nil {
t.Fatalf("list sites: %v", err)
}
// A plain heartbeat (no prior register) doesn't flip an unregistered
// site to idle — only reactivates from unreachable, mirroring
// validators' Heartbeat.
if sites[0].State != SiteUnregistered {
t.Fatalf("expected still unregistered after bare heartbeat, got %s", sites[0].State)
}
if sites[0].LastHeartbeatAt == nil {
t.Fatalf("expected last_heartbeat_at stamped")
}
if sites[0].Hostname != "" {
t.Fatalf("expected heartbeat not to set hostname, got %q", sites[0].Hostname)
}
if err := d.MarkSiteUnreachable(ctx, "site-1"); err != nil {
t.Fatalf("mark unreachable: %v", err)
}
if err := d.SiteHeartbeat(ctx, "site-1"); err != nil {
t.Fatalf("heartbeat after unreachable: %v", err)
}
sites, err = d.ListSites(ctx)
if err != nil {
t.Fatalf("list sites: %v", err)
}
if sites[0].State != SiteIdle {
t.Fatalf("expected heartbeat to reactivate from unreachable to idle, got %s", sites[0].State)
}
}
func TestListStaleSiteHeartbeatsAndMarkUnreachable(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.UpsertSite(ctx, 1, "site-1"); err != nil {
t.Fatalf("upsert site: %v", err)
}
if err := d.UpsertSite(ctx, 2, "site-2"); err != nil {
t.Fatalf("upsert site: %v", err)
}
// site-3 never heartbeats at all — must not show up as "stale" (it's
// simply unregistered, which is a distinct, expected state).
if err := d.UpsertSite(ctx, 3, "site-3"); err != nil {
t.Fatalf("upsert site: %v", err)
}
if err := d.SiteHeartbeat(ctx, "site-1"); err != nil {
t.Fatalf("heartbeat site-1: %v", err)
}
if err := d.SiteHeartbeat(ctx, "site-2"); err != nil {
t.Fatalf("heartbeat site-2: %v", err)
}
cutoff := Now().Add(1 * time.Hour) // everything heartbeated is "before" this
stale, err := d.ListStaleSiteHeartbeats(ctx, cutoff)
if err != nil {
t.Fatalf("list stale site heartbeats: %v", err)
}
if len(stale) != 2 {
t.Fatalf("expected 2 stale sites (site-1, site-2), got %+v", stale)
}
for _, s := range stale {
if err := d.MarkSiteUnreachable(ctx, s.SiteID); err != nil {
t.Fatalf("mark unreachable %s: %v", s.SiteID, err)
}
}
// Now already-unreachable sites are excluded from a subsequent sweep.
stale, err = d.ListStaleSiteHeartbeats(ctx, cutoff)
if err != nil {
t.Fatalf("list stale site heartbeats after mark: %v", err)
}
if len(stale) != 0 {
t.Fatalf("expected no stale sites left, got %+v", stale)
}
sites, err := d.ListSites(ctx)
if err != nil {
t.Fatalf("list sites: %v", err)
}
byID := map[string]Site{}
for _, s := range sites {
byID[s.SiteID] = s
}
if byID["site-1"].State != SiteUnreachable || byID["site-2"].State != SiteUnreachable {
t.Fatalf("expected site-1/site-2 unreachable, got %+v", sites)
}
if byID["site-3"].State != SiteUnregistered {
t.Fatalf("expected site-3 still unregistered (never heartbeated), got %s", byID["site-3"].State)
}
}
+7 -2
View File
@@ -1,5 +1,7 @@
package httpapi package httpapi
import "time"
// DTOs for the admin queue-management and dynamic-config endpoints // DTOs for the admin queue-management and dynamic-config endpoints
// (/api/v1/admin/ips, /api/v1/admin/config/*). Unlike the older read-only // (/api/v1/admin/ips, /api/v1/admin/config/*). Unlike the older read-only
// admin endpoints (which marshal internal/db model structs directly, in // admin endpoints (which marshal internal/db model structs directly, in
@@ -46,8 +48,11 @@ type updateValidatorRequest struct {
} }
type siteDTO struct { type siteDTO struct {
Index int `json:"index"` Index int `json:"index"`
SiteID string `json:"site_id"` SiteID string `json:"site_id"`
Hostname string `json:"hostname"`
State string `json:"state"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
} }
type putSiteRequest struct { type putSiteRequest struct {
+9 -2
View File
@@ -3,6 +3,8 @@ package httpapi
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"cloudipvalidator/internal/db"
) )
// --- validators --- // --- validators ---
@@ -70,7 +72,10 @@ func (s *Server) handleConfigListSites(w http.ResponseWriter, r *http.Request) {
} }
out := make([]siteDTO, len(sites)) out := make([]siteDTO, len(sites))
for i, site := range sites { for i, site := range sites {
out[i] = siteDTO{Index: site.Index, SiteID: site.SiteID} out[i] = siteDTO{
Index: site.Index, SiteID: site.SiteID, Hostname: site.Hostname,
State: site.State, LastHeartbeatAt: site.LastHeartbeatAt,
}
} }
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
} }
@@ -90,7 +95,9 @@ func (s *Server) handleConfigPutSite(w http.ResponseWriter, r *http.Request) {
writeDBError(w, err) writeDBError(w, err)
return return
} }
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID}) // UpsertSite always resets hostname/state/last_heartbeat_at to their
// "never connected" defaults on write (see its doc comment).
writeJSON(w, http.StatusOK, siteDTO{Index: idx, SiteID: req.SiteID, State: db.SiteUnregistered})
} }
func (s *Server) handleConfigDeleteSite(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConfigDeleteSite(w http.ResponseWriter, r *http.Request) {
+107
View File
@@ -623,3 +623,110 @@ func TestInboundChecksReflectedInProberAssignmentsWithoutRestart(t *testing.T) {
t.Fatalf("expected updated {[8080] false} without restart, got %+v", assignments) t.Fatalf("expected updated {[8080] false} without restart, got %+v", assignments)
} }
} }
// TestProberRegisterSetsHostnameAndIdleState proves POST
// /api/v1/probers/register persists the calling prober's hostname and
// flips the site's state to idle, visible via GET
// /api/v1/admin/config/sites — the prober-availability analog of
// validator registration.
func TestProberRegisterSetsHostnameAndIdleState(t *testing.T) {
fc, _, _, _ := newConfigTestHarness(t)
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "site-1"})
resp, body := fc.do(http.MethodGet, "/api/v1/admin/config/sites", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get sites: status=%d body=%s", resp.StatusCode, body)
}
var sites []siteDTO
if err := json.Unmarshal(body, &sites); err != nil {
t.Fatalf("unmarshal sites: %v", err)
}
if len(sites) != 1 || sites[0].State != "unregistered" {
t.Fatalf("expected freshly-created slot unregistered, got %+v", sites)
}
resp, body = fc.do(http.MethodPost, "/api/v1/probers/register", registerProberRequest{SiteID: "site-1", Hostname: "probe-host-1"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("register prober: status=%d body=%s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, "/api/v1/admin/config/sites", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get sites after register: status=%d body=%s", resp.StatusCode, body)
}
if err := json.Unmarshal(body, &sites); err != nil {
t.Fatalf("unmarshal sites: %v", err)
}
if sites[0].State != "idle" || sites[0].Hostname != "probe-host-1" {
t.Fatalf("expected {state:idle, hostname:probe-host-1}, got %+v", sites[0])
}
if sites[0].LastHeartbeatAt == nil {
t.Fatalf("expected last_heartbeat_at stamped")
}
}
// TestProberHeartbeat404UnknownSite proves the heartbeat endpoint rejects
// an unconfigured site_id, mirroring the validator heartbeat's 404.
func TestProberHeartbeat404UnknownSite(t *testing.T) {
fc, _, _, _ := newConfigTestHarness(t)
resp, body := fc.do(http.MethodPost, "/api/v1/probers/unknown-site/heartbeat", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 for unknown site_id, status=%d body=%s", resp.StatusCode, body)
}
}
// TestSweepStaleSiteHeartbeatsMarksUnreachable is the end-to-end proof that
// a prober that stops heartbeating gets marked unreachable by the sweep,
// visible via the admin API — the prober-side analog of
// TestFIPSettleDelayGatesAssignmentEndpoint's wire-level style.
func TestSweepStaleSiteHeartbeatsMarksUnreachable(t *testing.T) {
fc, d, orch, _ := newConfigTestHarness(t)
ctx := context.Background()
fc.do(http.MethodPut, "/api/v1/admin/config/sites/1", putSiteRequest{SiteID: "site-1"})
resp, body := fc.do(http.MethodPost, "/api/v1/probers/register", registerProberRequest{SiteID: "site-1", Hostname: "probe-host-1"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("register prober: status=%d body=%s", resp.StatusCode, body)
}
// Backdate last_heartbeat_at past HeartbeatTimeoutSeconds (30s, per
// newConfigTestHarness) directly in the DB, rather than sleeping 30+
// real seconds in the test.
old := db.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)
if _, err := d.ExecContext(ctx, `UPDATE sites SET last_heartbeat_at=? WHERE site_id=?`, old, "site-1"); err != nil {
t.Fatalf("backdate heartbeat: %v", err)
}
if err := orch.SweepStaleSiteHeartbeats(ctx); err != nil {
t.Fatalf("sweep stale site heartbeats: %v", err)
}
resp, body = fc.do(http.MethodGet, "/api/v1/admin/config/sites", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get sites: status=%d body=%s", resp.StatusCode, body)
}
var sites []siteDTO
if err := json.Unmarshal(body, &sites); err != nil {
t.Fatalf("unmarshal sites: %v", err)
}
if len(sites) != 1 || sites[0].State != "unreachable" {
t.Fatalf("expected site-1 marked unreachable after sweep, got %+v", sites)
}
// A fresh heartbeat brings it back to idle.
resp, body = fc.do(http.MethodPost, "/api/v1/probers/site-1/heartbeat", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("heartbeat: status=%d body=%s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, "/api/v1/admin/config/sites", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get sites after heartbeat: status=%d body=%s", resp.StatusCode, body)
}
if err := json.Unmarshal(body, &sites); err != nil {
t.Fatalf("unmarshal sites: %v", err)
}
if sites[0].State != "idle" {
t.Fatalf("expected site-1 back to idle after heartbeat, got %+v", sites)
}
}
+15
View File
@@ -22,10 +22,25 @@ func (s *Server) handleProberRegister(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "unknown site_id: "+req.SiteID) writeError(w, http.StatusBadRequest, "unknown site_id: "+req.SiteID)
return return
} }
if err := s.DB.RegisterSiteProber(r.Context(), req.SiteID, req.Hostname); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.Orch.RecordEvent(r.Context(), "prober", req.SiteID, nil, "registered", "") s.Orch.RecordEvent(r.Context(), "prober", req.SiteID, nil, "registered", "")
writeJSON(w, http.StatusOK, registerAgentResponse{OK: true, PollIntervalSeconds: s.Orch.Cfg.PollIntervalSeconds}) writeJSON(w, http.StatusOK, registerAgentResponse{OK: true, PollIntervalSeconds: s.Orch.Cfg.PollIntervalSeconds})
} }
func (s *Server) handleProberHeartbeat(w http.ResponseWriter, r *http.Request) {
siteID := r.PathValue("site_id")
var req heartbeatRequest
_ = readJSON(r, &req) // heartbeat body is informational only; tolerate empty/missing
if err := s.DB.SiteHeartbeat(r.Context(), siteID); err != nil {
writeError(w, http.StatusNotFound, "unknown site_id: "+siteID)
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
// handleProberAssignments returns every IP currently in the checking // handleProberAssignments returns every IP currently in the checking
// state — probers work the whole active set each poll, not one IP at a // state — probers work the whole active set each poll, not one IP at a
// time, since multiple validators run in parallel. // time, since multiple validators run in parallel.
+1
View File
@@ -14,6 +14,7 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/agents/{id}/complete", s.handleAgentComplete) mux.HandleFunc("POST /api/v1/agents/{id}/complete", s.handleAgentComplete)
mux.HandleFunc("POST /api/v1/probers/register", s.handleProberRegister) mux.HandleFunc("POST /api/v1/probers/register", s.handleProberRegister)
mux.HandleFunc("POST /api/v1/probers/{site_id}/heartbeat", s.handleProberHeartbeat)
mux.HandleFunc("GET /api/v1/probers/{site_id}/assignments", s.handleProberAssignments) mux.HandleFunc("GET /api/v1/probers/{site_id}/assignments", s.handleProberAssignments)
mux.HandleFunc("POST /api/v1/probers/{site_id}/results", s.handleProberResults) mux.HandleFunc("POST /api/v1/probers/{site_id}/results", s.handleProberResults)
+38 -22
View File
@@ -401,7 +401,12 @@ func (o *Orchestrator) sweepCheckingWindow(ctx context.Context) error {
return fmt.Errorf("list sites: %w", err) return fmt.Errorf("list sites: %w", err)
} }
for _, item := range checking { for _, item := range checking {
if !o.isReadyToAggregate(item, deadline, sites) { ready, err := o.isReadyToAggregate(ctx, item, deadline, sites)
if err != nil {
o.Log.Error("check ready to aggregate", "ip_id", item.ID, "err", err)
continue
}
if !ready {
continue continue
} }
if err := o.aggregateAndRelease(ctx, item); err != nil { if err := o.aggregateAndRelease(ctx, item); err != nil {
@@ -416,34 +421,26 @@ func (o *Orchestrator) sweepCheckingWindow(ctx context.Context) error {
// checking-window deadline. Which inbound sources it's expecting is driven // checking-window deadline. Which inbound sources it's expecting is driven
// entirely by the currently configured sites — inbound checks are optional: // entirely by the currently configured sites — inbound checks are optional:
// an empty (or partial) sites configuration means this IP is ready as soon // an empty (or partial) sites configuration means this IP is ready as soon
// as egress completes (or after the corresponding subset of siteN_complete // as egress completes (or after the corresponding subset of sites has
// flags), with no need to wait on a prober that will never exist. This is // reported in ip_site_checks), with no need to wait on a prober that will
// what makes inbound checks genuinely opt-in rather than a hardcoded // never exist, and no cap on how many sites can be configured.
// expectation of exactly three sites. func (o *Orchestrator) isReadyToAggregate(ctx context.Context, item db.IPQueueItem, deadline time.Time, sites []db.Site) (bool, error) {
func (o *Orchestrator) isReadyToAggregate(item db.IPQueueItem, deadline time.Time, sites []db.Site) bool {
if item.AssignedAt != nil && item.AssignedAt.Before(deadline) { if item.AssignedAt != nil && item.AssignedAt.Before(deadline) {
return true return true, nil
} }
if !item.EgressComplete { if !item.EgressComplete {
return false return false, nil
}
completed, err := o.DB.ListCompletedSiteIndices(ctx, item.ID, item.AttemptNumber)
if err != nil {
return false, err
} }
for _, s := range sites { for _, s := range sites {
switch s.Index { if !completed[s.Index] {
case 1: return false, nil
if !item.Site1Complete {
return false
}
case 2:
if !item.Site2Complete {
return false
}
case 3:
if !item.Site3Complete {
return false
}
} }
} }
return true return true, nil
} }
func (o *Orchestrator) aggregateAndRelease(ctx context.Context, item db.IPQueueItem) error { func (o *Orchestrator) aggregateAndRelease(ctx context.Context, item db.IPQueueItem) error {
@@ -585,6 +582,25 @@ func (o *Orchestrator) SweepStaleHeartbeats(ctx context.Context) error {
return nil return nil
} }
// SweepStaleSiteHeartbeats marks prober sites unreachable if they haven't
// heartbeated within HeartbeatTimeoutSeconds — mirrors SweepStaleHeartbeats
// exactly, for sites instead of validators.
func (o *Orchestrator) SweepStaleSiteHeartbeats(ctx context.Context) error {
cutoff := db.Now().Add(-time.Duration(o.Cfg.HeartbeatTimeoutSeconds) * time.Second)
stale, err := o.DB.ListStaleSiteHeartbeats(ctx, cutoff)
if err != nil {
return err
}
for _, s := range stale {
if err := o.DB.MarkSiteUnreachable(ctx, s.SiteID); err != nil {
o.Log.Error("mark site unreachable", "site_id", s.SiteID, "err", err)
continue
}
o.event(ctx, "control-api", "", nil, "site_unreachable", fmt.Sprintf(`{"site_id":%q}`, s.SiteID))
}
return nil
}
// RecordEvent is the exported entry point httpapi uses to log // RecordEvent is the exported entry point httpapi uses to log
// agent/prober-reported audit events (config_received, fip_changed, // agent/prober-reported audit events (config_received, fip_changed,
// error, etc.) through the same path as internally generated events. // error, etc.) through the same path as internally generated events.
@@ -624,3 +624,65 @@ func TestExpectedCheckCountReflectsInboundChecksConfigChange(t *testing.T) {
t.Fatalf("expected pass, got %s", ip.OverallResult) t.Fatalf("expected pass, got %s", ip.OverallResult)
} }
} }
// TestFourSitesAllMustReportBeforeAggregation confirms there's no hardcoded
// cap of three sites: with 4 sites configured, aggregation must wait on
// all 4, not silently treat the 4th as always-complete.
func TestFourSitesAllMustReportBeforeAggregation(t *testing.T) {
ctx := context.Background()
sites := []config.SiteConfig{
{SiteID: "site-1", Index: 1}, {SiteID: "site-2", Index: 2},
{SiteID: "site-3", Index: 3}, {SiteID: "site-4", Index: 4},
}
o, d, mock := newTestOrchestratorWithSites(t, 180, sites)
mock.Seed("fip-1", "1.2.3.4", "svc-project")
_ = d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1")
_ = d.SeedQueue(ctx, []string{"1.2.3.4"})
o.Tick(ctx)
ip, _ := d.GetIPByAddress(ctx, "1.2.3.4")
_ = o.SelfCheckResult(ctx, "validator-1", ip.ID, true, "ok")
ip, _ = d.GetIP(ctx, ip.ID)
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
ValidatorID: "validator-1", Source: db.SourceEgress, CheckType: "https",
Target: "https://example.test", Success: true, CheckedAt: db.Now(),
})
_ = o.MarkEgressComplete(ctx, ip.ID)
for _, site := range []int{1, 2, 3} {
for _, ct := range []string{"tcp-22", "tcp-80", "icmp"} {
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: db.InboundSource(site), CheckType: ct, Target: ip.IPAddress, Success: true, CheckedAt: db.Now(),
})
}
_ = o.MarkSiteComplete(ctx, ip.ID, site)
}
// Sites 1-3 reported, site 4 (the case beyond the old fixed-3 cap)
// hasn't — must not aggregate yet.
o.Tick(ctx)
ip, _ = d.GetIP(ctx, ip.ID)
if ip.State != db.IPChecking {
t.Fatalf("expected still checking (site-4 pending), got %s", ip.State)
}
for _, ct := range []string{"tcp-22", "tcp-80", "icmp"} {
_ = o.RecordCheck(ctx, db.Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: db.InboundSource(4), CheckType: ct, Target: ip.IPAddress, Success: true, CheckedAt: db.Now(),
})
}
_ = o.MarkSiteComplete(ctx, ip.ID, 4)
o.Tick(ctx)
ip, _ = d.GetIP(ctx, ip.ID)
if ip.State != db.IPDone {
t.Fatalf("expected done once all 4 sites reported, got %s", ip.State)
}
if ip.OverallResult != db.ResultPass {
t.Fatalf("expected pass, got %s", ip.OverallResult)
}
}
+5
View File
@@ -89,6 +89,11 @@ type resultDTO struct {
} }
func (p *Prober) pollOnce(ctx context.Context) { func (p *Prober) pollOnce(ctx context.Context) {
if _, err := p.client.Do(ctx, "POST", "/api/v1/probers/"+p.cfg.SiteID+"/heartbeat", nil, nil); err != nil {
p.log.Error("heartbeat", "err", err)
return
}
var assignments []assignment var assignments []assignment
ok, err := p.client.Do(ctx, "GET", "/api/v1/probers/"+p.cfg.SiteID+"/assignments", nil, &assignments) ok, err := p.client.Do(ctx, "GET", "/api/v1/probers/"+p.cfg.SiteID+"/assignments", nil, &assignments)
if err != nil { if err != nil {
+96
View File
@@ -0,0 +1,96 @@
package probercore
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"time"
"cloudipvalidator/internal/apiclient"
"cloudipvalidator/internal/config"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
}
// TestPollOnceCallsHeartbeatBeforeAssignments mirrors agentcore's
// heartbeat-then-assignment ordering: the prober must announce it's alive
// on every poll cycle before fetching work, not just once at startup.
func TestPollOnceCallsHeartbeatBeforeAssignments(t *testing.T) {
var mu sync.Mutex
var calls []string
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/probers/site-1/heartbeat", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls = append(calls, "heartbeat")
mu.Unlock()
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
})
mux.HandleFunc("GET /api/v1/probers/site-1/assignments", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls = append(calls, "assignments")
mu.Unlock()
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[]`))
})
ts := httptest.NewServer(mux)
defer ts.Close()
p := &Prober{
cfg: &config.Prober{SiteID: "site-1"},
client: apiclient.New(ts.URL, 5*time.Second),
log: testLogger(),
}
p.pollOnce(context.Background())
mu.Lock()
defer mu.Unlock()
if len(calls) != 2 || calls[0] != "heartbeat" || calls[1] != "assignments" {
t.Fatalf("expected [heartbeat assignments], got %v", calls)
}
}
// TestPollOnceSkipsAssignmentsWhenHeartbeatFails confirms a failed
// heartbeat aborts the poll cycle early — mirrors agentcore.pollOnce's
// same early-return behavior.
func TestPollOnceSkipsAssignmentsWhenHeartbeatFails(t *testing.T) {
var mu sync.Mutex
var calls []string
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/probers/site-1/heartbeat", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls = append(calls, "heartbeat")
mu.Unlock()
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("GET /api/v1/probers/site-1/assignments", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls = append(calls, "assignments")
mu.Unlock()
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[]`))
})
ts := httptest.NewServer(mux)
defer ts.Close()
p := &Prober{
cfg: &config.Prober{SiteID: "site-1"},
client: apiclient.New(ts.URL, 5*time.Second),
log: testLogger(),
}
p.pollOnce(context.Background())
mu.Lock()
defer mu.Unlock()
if len(calls) != 1 || calls[0] != "heartbeat" {
t.Fatalf("expected only [heartbeat] (assignments skipped on heartbeat failure), got %v", calls)
}
}