admin control features and admin dashboard

This commit is contained in:
ayurishchev committed 2026-08-23 20:39:22 +03:00
1 parent c630f13c57
commit 37910e410b
69 files changed
+4959 -400

No files matched your search

+130 -244
View File
@@ -1,53 +1,70 @@
# План доработки: API управления конфигурацией
# План доработки: динамическое управление конфигурацией и очередью через API
> Статус: **план на будущее, не реализовано**. Документ фиксирует
> согласованный дизайн доработки Control API, дающей возможность
> управлять `check_types`, `targets`, `validators` и `sites` через HTTP
> API вместо правки YAML + рестарта. Реализация — отдельная задача.
> Статус: **реализовано**. Документ фиксирует дизайн доработки Control
> API, дающей возможность управлять `validators`, `sites`,
> `check_types`/`targets` и очередью IP-адресов через HTTP API вместо
> правки YAML + рестарта, без перезапуска процесса. Актуальная
> спецификация методов — [docs/API.md](API.md#управление-очередью-и-конфигурацией);
> повседневные сценарии — [docs/USAGE.md](USAGE.md).
## Context
Сейчас `control-api` полностью read-only в части конфигурации: типы
проверок (`check_types`), список целей (`targets`), состав валидаторов
(`validators`) и внешних площадок (`sites`) читаются один раз из
`control-api.yaml` при старте процесса и живут дальше только в памяти
(`Orchestrator.Checks`, `Orchestrator.Sites`) либо (для валидаторов) в
таблице `validators`, куда при каждом рестарте они переупорядочиваются из
YAML. Единственный способ что-то поменять — отредактировать YAML и
выполнить `systemctl restart control-api`. Это задокументированное
ограничение (см. `docs/USAGE.md`).
Сейчас `control-api` в части конфигурации и очереди полностью read-only:
`validators`, `sites`, `check_types`/`targets` читаются один раз из YAML при
старте процесса (`cmd/control-api/main.go`) и живут в памяти
(`Orchestrator.Checks`, `Orchestrator.Sites`) либо переприменяются в БД при
каждом рестарте (`RegisterValidator` upsert). Список адресов на проверку
(`ip_addresses`) добавляется в очередь только при старте, и нет способа
принудительно перепроверить уже завершённый адрес или остановить проверку,
которая уже идёт. Единственный способ что-то поменять — отредактировать
YAML и выполнить `systemctl restart control-api`.
Согласованные решения (зафиксированы для будущей реализации):
- **Область доработки** — ровно четыре сущности: `check_types` (типы
проверок + их привязка к группам целей), `targets` (группы целей),
`validators` (состав ВМ-валидаторов), `sites` (состав из ≤3 внешних
площадок). Очередь `ip_addresses` и тайминги оркестратора
(`orchestrator.*`, `aggregation.*`, `inbound_checks.*`) — вне scope,
остаются YAML-only как сейчас.
- **Источник истины после первого изменения — БД.** YAML используется
только для одноразового bootstrap при пустой базе; после первого
запуска (или после первого API-изменения) YAML для этих 4 секций
больше не перечитывается и не переприменяется при рестартах.
- **Добавляется базовая аутентификация** — bearer-токен администратора,
которым закрывается весь namespace `/api/v1/admin/*` (не только новые
write-методы, но и существующие read-методы `status/ips/validators` —
единая политика для всего admin-namespace проще и логичнее половинчатой
защиты). Протокол `/api/v1/agents/*` и `/api/v1/probers/*`
(agent/prober) — вне scope, остаётся как есть.
Целевой набор фич:
## Важное архитектурное ограничение: площадки жёстко капнуты на 3
1. Администратор передаёт через API список IP-адресов на проверку.
2. Администратор передаёт через API список валидаторов.
3. Администратор передаёт через API список целей (`targets`/`check_types`).
4. Администратор может принудительно инициировать проверку адреса, даже
если она уже была выполнена ранее.
5. Администратор может принудительно остановить идущую проверку.
Схема `ip_queue` хранит завершённость площадок как три отдельные колонки
(`site1_complete`, `site2_complete`, `site3_complete`) — это не список
произвольной длины. Поэтому API для `sites` не может быть обычным
CRUD-списком: это управление максимум тремя пронумерованными слотами
(`index` ∈ {1,2,3}), где `site_id` можно назначить, переименовать или
снять со слота. Это ограничение уже описано в `docs/USAGE.md` и явно
закладывается в дизайн API ниже, а не игнорируется.
Все действия — «на лету», без перезапуска процесса. Источник истины после
первого изменения через API — БД, YAML остаётся только bootstrap для пустой
базы.
## Общий план реализации
**Согласованные решения:**
- **Sites (площадки) включены в объём доработки** — тем же CRUD-подходом,
что и validators/targets/check_types.
- **Аутентификация (admin bearer-токен) НЕ входит в этот план** — API
остаётся открытым, как сейчас. Ограничение доступа — на уровне
сети/firewall (см. `docs/SETUP.md`).
- **Фичи 1 и 4 реализуются одним механизмом**, а не двумя разными
эндпоинтами. Администратор передаёт список IP-адресов в
`POST /api/v1/admin/ips`; для каждого адреса в списке:
- если адрес не встречался раньше — добавляется в очередь как новый;
- если адрес уже в терминальном состоянии (`done`/`failed`) —
принудительно перезапускается на проверку (сброс результата, новая
попытка, `attempt_number` увеличивается, `retry_count` обнуляется);
- если адрес уже в очереди (`queued`) — переупорядочивается под порядок
текущего списка (без дублирования);
- если адрес сейчас активно проверяется (`assigning_fip` /
`awaiting_self_check` / `checking` / `aggregating`) — не трогается
вообще (не создаём вторую параллельную проверку одного и того же
адреса).
### 1. Новая схема БД — `internal/db/migrations/0002_dynamic_config.sql`
Порядок обработки в рамках одного вызова соответствует порядку адресов в
переданном списке — повторная отправка того же списка без изменений даёт
тот же порядок прогона.
## 1. Схема БД — новая миграция + обобщение `migrate()`
`internal/db/db.go: migrate()` сейчас гейтится по `PRAGMA user_version`:
`>=1 → no-op`, иначе применяет единственный embedded `migrations/0001_init.sql`
и ставит `user_version=1`. Обобщается на упорядоченный список миграций
(embed `0002_dynamic_config.sql` вторым файлом), применяются по очереди все
версии выше текущей.
Новый файл `internal/db/migrations/0002_dynamic_config.sql`:
```sql
CREATE TABLE sites (
@@ -73,229 +90,98 @@ CREATE TABLE check_types (
);
```
Список целей внутри группы и список групп внутри типа проверки хранятся
как JSON-массив в TEXT-колонке (тот же паттерн, что уже используется для
`events.payload`) — они всегда читаются/пишутся целиком, отдельная
реляционная таблица тут не нужна (не переусложняем).
`validators` — существующая таблица (`0001_init.sql`), новых колонок не
требует.
`validators` — существующая таблица, новых колонок не требует.
## 2. Типизированные ошибки — `internal/db/errors.go`
`internal/db/db.go`: функцию `migrate()` обобщить со списка из одной
миграции (`version >= 1 → return`) на упорядоченный список
`{version, sql}` и применение всех версий выше текущего
`PRAGMA user_version` — понадобится и для этой, и для будущих миграций.
Сентинелы (`errors.New` + `%w`-обёртка), чтобы `httpapi`-хендлеры маппили
их в HTTP-статусы через `errors.Is`: `ErrNotFound` (404), `ErrConflict`
(409), `ErrBusy` (409, валидатор владеет IP), `ErrInUse` (409, группа
целей используется check_type'ом), `ErrValidation` (400), `ErrInvalidState`
(409, попытка отменить уже завершённую проверку).
### 2. Bootstrap-логика — новый файл `internal/db/bootstrap.go`
## 3. Bootstrap — `internal/db/bootstrap.go`
```go
func (d *DB) BootstrapFromConfig(ctx context.Context, cfg *config.ControlAPI) error
```
Переносит и обобщает то, что сейчас разбросано по
`cmd/control-api/main.go` (`RegisterValidator` в цикле + `SeedQueue`):
Заменяет текущий цикл `RegisterValidator` + `SeedQueue` в
`cmd/control-api/main.go`:
- `ip_addresses` → `SeedQueue` — без изменений (всегда доливает новые
адреса при каждом старте; отдельный YAML-only путь, не путать с runtime
`POST /api/v1/admin/ips`).
- `validators`, `sites`, `target_groups`, `check_types` — применяются
только если соответствующая таблица пуста. Если строки уже есть — YAML
для этой секции игнорируется.
- `ip_addresses` → `SeedQueue` — **без изменений**, как сейчас (всегда
доливает новые адреса, это уже вне scope доработки).
- `validators`, `sites`, `target_groups`, `check_types` — **новая
семантика**: применяется, **только если соответствующая таблица
сейчас пуста** (`SELECT COUNT(*) ... == 0`). Если в таблице уже есть
строки — YAML для этой секции полностью игнорируется, ничего не
трогаем. Это и есть «bootstrap один раз, дальше БД главная».
**Осознанное изменение поведения**: сейчас `RegisterValidator` при каждом
рестарте переприменяет `os_port_id` из YAML поверх БД. После доработки —
только на пустой таблице (иначе API-правки не переживали бы рестарт).
`internal/db` уже не будет зависеть от `internal/orchestrator` — только
новая зависимость `internal/db → internal/config` (обратной зависимости
`config → db` нет, циклов не возникает).
## 4. Запросы к БД
`cmd/control-api/main.go`: заменить текущий цикл `RegisterValidator` +
`SeedQueue` одним вызовом `database.BootstrapFromConfig(ctx, cfg)`. Это
же делает функцию тестируемой напрямую (используется в обновлённых
`orchestrator_test.go`/`httpapi_test.go` вместо ручного построения
`Orchestrator.Checks`/`.Sites`).
- `internal/db/queries_sites.go`: `ListSites`, `UpsertSite(idx, siteID)`,
`DeleteSite(idx)`, `GetSiteIndex(ctx, siteID) (int, error)` (0, если не
найден — не ошибка).
- `internal/db/queries_targetgroups.go`: `ListTargetGroups`,
`UpsertTargetGroup(name, targets)`, `DeleteTargetGroup(name)`
(`ErrInUse`, если ссылается check_type), `GetTargetGroup(name)`.
- `internal/db/queries_checktypes.go`: `ListCheckTypes`,
`ListResolvedCheckTypes` (разворачивает группы в плоский список targets),
`UpsertCheckType(name, enabled, targetGroups)` (`ErrValidation`, если
группа не существует), `DeleteCheckType(name)`.
- `internal/db/queries_validators.go` (дополнить): `AdminCreateValidator`,
`AdminUpdateValidatorPort`, `DeleteValidator` (`ErrBusy`, если владеет
IP).
- `internal/db/queries_ipqueue.go` (дополнить): `SubmitIPs(ctx,
addresses []string) (SubmitIPsResult, error)` — единая транзакция,
реализует правило из Context выше; `CancelIP(ctx, ipID int64) error` —
условный `UPDATE ... WHERE state NOT IN ('done','failed')`,
`ErrInvalidState` при гонке/уже завершённой проверке.
**Важное следствие смены семантики валидаторов**: сейчас при каждом
рестарте `control-api` валидаторы из YAML переприменяются (в частности,
может тихо откатить `os_port_id`, изменённый через API/вручную в БД).
После доработки — только на пустой таблице. Это осознанное поведенческое
изменение, требует апдейта `docs/SETUP.md`/`docs/USAGE.md` (шаг 6 плана).
`ResultCancelled = "cancelled"` добавляется в `internal/db/models.go`.
### 3. Запросы к БД для новых сущностей
## 5. Оркестратор
Новые файлы, по аналогии с существующими `queries_*.go`:
`internal/orchestrator/orchestrator.go`: убрать статические поля
`Checks`/`Sites`, читать динамически из БД (`ListResolvedCheckTypes`,
`ListSites`, `GetSiteIndex`) в `AssignmentForValidator`,
`expectedCheckCount()`, `isReadyToAggregate()`. Новый метод `ForceCancel`
для фичи 5: отвязывает FIP (best-effort), помечает IP `cancelled`,
освобождает валидатора.
- **`internal/db/queries_sites.go`**: `ListSites`, `UpsertSite(idx, siteID)`
(проверяет допустимость `idx` 1..3 и уникальность `site_id` до записи,
чтобы вернуть чистую типизированную ошибку, а не сырую SQL), `DeleteSite(idx)`,
`GetSiteIndex(siteID) (int, error)` — заменяет текущий
`Orchestrator.SiteIndexForID`, который сканирует статический слайс.
- **`internal/db/queries_targetgroups.go`**: `ListTargetGroups`,
`UpsertTargetGroup(name, targets)`, `DeleteTargetGroup(name)` —
**перед удалением проверяет**, что ни один `check_types` не ссылается
на эту группу (иначе `ErrInUse`), `GetTargetGroup(name)`.
- **`internal/db/queries_checktypes.go`**: `ListCheckTypes`,
`ListResolvedCheckTypes` (сразу разворачивает имена групп в плоский
список URL — то, что раньше строил `orchestrator.New()` один раз при
старте), `UpsertCheckType(name, enabled, targetGroups)` (**проверяет**,
что все переданные `targetGroups` существуют — иначе `ErrValidation`),
`DeleteCheckType(name)`.
- **`internal/db/queries_validators.go`** (дополнить существующий файл):
`AdminCreateValidator(id, osPortID)` (409/`ErrConflict`, если уже
есть), `AdminUpdateValidatorPort(id, osPortID)` (404/`ErrNotFound`,
если нет), `DeleteValidator(id)` (409/`ErrBusy`, если
`current_ip_id IS NOT NULL` — валидатор сейчас владеет IP). Не путать
с существующим `RegisterValidator` — тот остаётся as-is и продолжает
использоваться только агентом при самостоятельной регистрации
(`handleAgentRegister`), полей `os_port_id` не трогает при
self-registration (это уже так в текущем коде).
**Принятый компромисс**: если конфигурация меняется API-запросом ровно в
момент агрегации уже идущей проверки, эта попытка агрегирует по текущей
(уже изменённой) конфигурации — деградирует безопасно через
`missing_counts_as_fail`, самоисправляется на следующей попытке.
Новый файл **`internal/db/errors.go`** с типизированными сентинелами
(`ErrNotFound`, `ErrConflict`, `ErrBusy`, `ErrValidation`, через `errors.New`
+ `%w`-обёртку в местах возврата) — чтобы `httpapi`-хендлеры мапили их в
404/409/400 через `errors.Is`, а не всё подряд в 500 (как сейчас местами
получается по умолчанию).
## 6. HTTP API
### 4. Оркестратор — переход на динамическое чтение конфигурации
`internal/orchestrator/orchestrator.go`:
- Убрать поля `Checks []CheckConfig` и `Sites []config.SiteConfig` из
`Orchestrator` (сейчас вычисляются один раз в `New()` и застывают на
весь жизненный цикл процесса — это и есть корень проблемы). `Inbound`
остаётся статическим полем как сейчас (вне scope).
- `AssignmentForValidator` — вместо `return item, o.Checks, nil` вызывает
`o.DB.ListResolvedCheckTypes(ctx)` и возвращает актуальный на данный
момент список.
- `SiteIndexForID` — удаляется, вызовы (`handleProberRegister`,
`handleProberAssignments`, `handleProberResults`) переходят на
`o.DB.GetSiteIndex(ctx, siteID)`.
- `expectedCheckCount()` — читает актуальные `ListResolvedCheckTypes` и
`ListSites` из БД на момент агрегации, а не статические поля.
**Принятый компромисс (осознанно, без over-engineering):** если
`check_types`/`targets`/`sites` меняются API-запросом ровно в момент,
когда чей-то IP уже находится в `checking` (self-check уже пройден,
проверки уже назначены агенту), агрегация этой конкретной попытки
посчитает *текущую* (уже изменённую) конфигурацию, а не ту, что была на
момент выдачи задания. На практике это узкое окно в несколько секунд
между админ-изменением и завершением проверки; деградирует безопасно —
через существующий механизм `missing_counts_as_fail` результат в худшем
случае будет `partial` вместо `pass` для одной попытки, самоисправляется
на следующей (после retry/requeue). Полный snapshot-per-attempt (доп.
колонки в `ip_queue` с зафиксированным ожидаемым числом проверок) —
возможное будущее усиление, не требуется для этой доработки.
### 5. HTTP API
Новый файл **`internal/httpapi/handlers_config.go`** и DTO в
`dto.go`. Все — под префиксом `/api/v1/admin/config/*`, JSON в
snake_case (в отличие от существующих `/admin/status|ips|validators`,
которые отдают сырые Go-поля в PascalCase — для новых, «настоящих»
management-эндпоинтов сразу делаем нормальный контракт, старые не
трогаем, чтобы не ломать уже задокументированное поведение).
| Метод | Путь | Тело | Успех | Ошибки |
|---|---|---|---|---|
| GET | `/api/v1/admin/config/validators` | — | `[{validator_id, os_port_id, state}]` | |
| POST | `/api/v1/admin/config/validators` | `{validator_id, os_port_id}` | 201 | 409 если уже есть |
| PUT | `/api/v1/admin/config/validators/{id}` | `{os_port_id}` | 200 | 404 |
| DELETE | `/api/v1/admin/config/validators/{id}` | — | 200 | 404, 409 если владеет IP |
| GET | `/api/v1/admin/config/sites` | — | `[{index, site_id}]` (до 3 строк) | |
| PUT | `/api/v1/admin/config/sites/{index}` | `{site_id}` | 200 | 400 если `index` не 1..3, 409 если `site_id` занят другим слотом |
| DELETE | `/api/v1/admin/config/sites/{index}` | — | 200 | 404 |
| GET | `/api/v1/admin/config/targets` | — | `[{name, targets}]` | |
| PUT | `/api/v1/admin/config/targets/{group}` | `{targets:[...]}` | 200 | 400 пустой список |
| DELETE | `/api/v1/admin/config/targets/{group}` | — | 200 | 404, 409 если используется check_type'ом |
| GET | `/api/v1/admin/config/check-types` | — | `[{name, enabled, targets}]` | |
| PUT | `/api/v1/admin/config/check-types/{name}` | `{enabled, targets:[group,...]}` | 200 | 400 если группа не существует |
| DELETE | `/api/v1/admin/config/check-types/{name}` | — | 200 | 404 |
`routes.go`: все существующие и новые `/api/v1/admin/*`-маршруты
оборачиваются `s.requireAdmin(...)`.
### 6. Аутентификация
- `internal/config/config.go`: в `ServerConfig` добавить
`AdminTokenEnv string \`yaml:"admin_token_env"\`` — по аналогии с
`openstack.*_env` полями (в YAML — только *имя* переменной, не сам
токен).
- `internal/httpapi/server.go`: `Server.AdminToken string` +
`func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc`
— сверяет `Authorization: Bearer <token>` через
`crypto/subtle.ConstantTimeCompare`. Если `s.AdminToken == ""` —
пропускает без проверки (обратная совместимость).
- `cmd/control-api/main.go`: если `cfg.Server.AdminTokenEnv` задан, но
`os.Getenv(...)` пуст — **отказ запуска** с понятной ошибкой
(fail-safe, не запускаемся с «пустым паролем»). Если
`AdminTokenEnv` вообще не задан — запускаемся как сейчас, но пишем
явный `log.Warn` про незащищённый admin API.
- `configs/control-api.example.yaml`, `deploy/systemd/control-api.service`
(добавить пример переменной в `EnvironmentFile`) — обновить.
### 7. Обновление существующих тестов и добавление новых
- `internal/orchestrator/orchestrator_test.go`,
`internal/httpapi/httpapi_test.go`: заменить ручное построение
`cfg.CheckTypes/.Targets/.Sites` + прямые поля `Orchestrator{Checks:...}`
на `db.BootstrapFromConfig(ctx, cfg)` перед `orchestrator.New(...)` —
сами тестовые сценарии (happy path, partial, lease reclaim) не меняются
по сути, меняется только способ засеять конфигурацию.
- Новые unit-тесты: `internal/db/queries_dynconfig_test.go` (CRUD +
граничные случаи: удаление занятого валидатора → `ErrBusy`, удаление
группы целей, на которую ссылается check_type → `ErrInUse`,
upsert check_type с несуществующей группой → `ErrValidation`, upsert
сайта с чужим `site_id` → `ErrConflict`, bootstrap на непустой таблице
→ YAML игнорируется).
- Новый `internal/httpapi/handlers_config_test.go` (или расширение
`httpapi_test.go`): сквозной сценарий — создать валидатора и сайт через
API вместо конфига, убедиться, что IP реально дошёл до `done`; смена
`check_types` между запусками влияет на следующий назначенный IP.
- Обновить `scripts/run-local-e2e.sh` не требуется по сути (bootstrap
из YAML при пустой БД работает как раньше), но стоит добавить один шаг
с `curl -X PUT .../config/check-types/ssh` как живую демонстрацию.
### 8. Документация (после реализации)
- `docs/API.md`: новый раздел «Методы управления конфигурацией» с
таблицей выше + примеры curl (создание валидатора, отключение ssh,
добавление цели, назначение площадки на слот) + раздел про
`Authorization: Bearer`.
- `docs/SETUP.md`: шаг про `server.admin_token_env` в
«Переменные окружения для OpenStack» (переименовать раздел или
добавить рядом «и для admin-токена»); явно описать новую
bootstrap-once семантику `validators`/`sites`/`check_types`/`targets`.
- `docs/USAGE.md`: заменить текущие разделы «Управление валидаторами» /
«Управление площадками» (сейчас там «только через YAML + restart») на
актуальные — через API; убрать утверждение «нет API-метода» там, где
оно перестало быть верным.
- `docs/DIAGRAMS.md`: в диаграмму control plane (раздел 1) добавить
новую стрелку «Оператор → HTTP API → БД (config CRUD)» вместо текущей
«CFG → читается при старте (инициализация)» как единственного пути.
`POST /api/v1/admin/ips` — постановка/принудительный перезапуск (фичи 1 и
4). `POST /api/v1/admin/ips/{ip}/cancel` — остановка (фича 5).
`/api/v1/admin/config/{validators,sites,targets,check-types}` — CRUD
(фичи 2 и 3), snake_case DTO. Подробности — `docs/API.md`.
## Критичные файлы
- `internal/db/migrations/0002_dynamic_config.sql` (новый)
- `internal/db/db.go` (обобщить `migrate()`)
- `internal/db/bootstrap.go` (новый)
- `internal/db/errors.go` (новый)
- `internal/db/migrations/0002_dynamic_config.sql`, `internal/db/db.go`,
`internal/db/bootstrap.go`, `internal/db/errors.go`, `internal/db/models.go`
- `internal/db/queries_sites.go`, `queries_targetgroups.go`,
`queries_checktypes.go` (новые), `queries_validators.go` (дополнить)
- `internal/orchestrator/orchestrator.go` (убрать статические
`Checks`/`Sites`, читать из БД)
- `internal/httpapi/handlers_config.go` (новый), `dto.go`, `routes.go`,
`server.go` (`requireAdmin`)
- `internal/config/config.go` (`AdminTokenEnv`)
- `cmd/control-api/main.go` (bootstrap-вызов, проверка токена при старте)
`queries_checktypes.go`, `queries_validators.go`, `queries_ipqueue.go`
- `internal/orchestrator/orchestrator.go`
- `internal/httpapi/handlers_config.go`, `handlers_admin.go`,
`dto_admin.go`, `routes.go`
- `cmd/control-api/main.go`
## Проверка (когда план будет реализовываться)
## Проверка
1. `go build ./... && go test ./...` — все существующие + новые unit- и
httpapi-тесты проходят.
2. `scripts/run-local-e2e.sh` — офлайн-сценарий по-прежнему проходит от
начала до конца без ручного вмешательства (bootstrap из YAML при
пустой БД работает как раньше).
3. Ручная проверка нового контракта: поднять `control-api` с пустой БД и
`admin_token_env` без токена → админ-запрос без заголовка проходит;
задать токен → запрос без `Authorization` получает 401; создать
валидатора/площадку/группу целей/тип проверки через API без
единой строчки в YAML, убедиться, что IP реально проходит полный цикл
проверки на этой конфигурации; попытаться удалить валидатора, пока он
владеет IP → 409; перезапустить `control-api` и убедиться, что
API-изменения пережили рестарт, а YAML их не затёр.
1. `go build ./... && go test ./...`
2. `scripts/run-local-e2e.sh`
3. Ручная проверка: создать validator/site/target-group/check-type только
через API; `POST /admin/ips` с уже `done`-адресом → повторный полный
цикл; `POST /admin/ips` с адресом в `checking` → не трогается;
`POST /admin/ips/{ip}/cancel` во время `checking` → FIP отвязан,
валидатор свободен, `overall_result=cancelled`; удаление занятого
валидатора → 409; рестарт control-api → все API-изменения сохранились.