# План доработки: API управления конфигурацией > Статус: **план на будущее, не реализовано**. Документ фиксирует > согласованный дизайн доработки Control API, дающей возможность > управлять `check_types`, `targets`, `validators` и `sites` через HTTP > API вместо правки YAML + рестарта. Реализация — отдельная задача. ## 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`). Согласованные решения (зафиксированы для будущей реализации): - **Область доработки** — ровно четыре сущности: `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 Схема `ip_queue` хранит завершённость площадок как три отдельные колонки (`site1_complete`, `site2_complete`, `site3_complete`) — это не список произвольной длины. Поэтому API для `sites` не может быть обычным CRUD-списком: это управление максимум тремя пронумерованными слотами (`index` ∈ {1,2,3}), где `site_id` можно назначить, переименовать или снять со слота. Это ограничение уже описано в `docs/USAGE.md` и явно закладывается в дизайн API ниже, а не игнорируется. ## Общий план реализации ### 1. Новая схема БД — `internal/db/migrations/0002_dynamic_config.sql` ```sql CREATE TABLE sites ( idx INTEGER PRIMARY KEY, -- 1, 2 или 3 — фиксированный слот site_id TEXT NOT NULL UNIQUE, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); CREATE TABLE target_groups ( group_name TEXT PRIMARY KEY, targets TEXT NOT NULL, -- JSON-массив строк created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); CREATE TABLE check_types ( name TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT 1, target_groups TEXT NOT NULL, -- JSON-массив имён групп created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); ``` Список целей внутри группы и список групп внутри типа проверки хранятся как JSON-массив в TEXT-колонке (тот же паттерн, что уже используется для `events.payload`) — они всегда читаются/пишутся целиком, отдельная реляционная таблица тут не нужна (не переусложняем). `validators` — существующая таблица, новых колонок не требует. `internal/db/db.go`: функцию `migrate()` обобщить со списка из одной миграции (`version >= 1 → return`) на упорядоченный список `{version, sql}` и применение всех версий выше текущего `PRAGMA user_version` — понадобится и для этой, и для будущих миграций. ### 2. Bootstrap-логика — новый файл `internal/db/bootstrap.go` ```go func (d *DB) BootstrapFromConfig(ctx context.Context, cfg *config.ControlAPI) error ``` Переносит и обобщает то, что сейчас разбросано по `cmd/control-api/main.go` (`RegisterValidator` в цикле + `SeedQueue`): - `ip_addresses` → `SeedQueue` — **без изменений**, как сейчас (всегда доливает новые адреса, это уже вне scope доработки). - `validators`, `sites`, `target_groups`, `check_types` — **новая семантика**: применяется, **только если соответствующая таблица сейчас пуста** (`SELECT COUNT(*) ... == 0`). Если в таблице уже есть строки — YAML для этой секции полностью игнорируется, ничего не трогаем. Это и есть «bootstrap один раз, дальше БД главная». `internal/db` уже не будет зависеть от `internal/orchestrator` — только новая зависимость `internal/db → internal/config` (обратной зависимости `config → db` нет, циклов не возникает). `cmd/control-api/main.go`: заменить текущий цикл `RegisterValidator` + `SeedQueue` одним вызовом `database.BootstrapFromConfig(ctx, cfg)`. Это же делает функцию тестируемой напрямую (используется в обновлённых `orchestrator_test.go`/`httpapi_test.go` вместо ручного построения `Orchestrator.Checks`/`.Sites`). **Важное следствие смены семантики валидаторов**: сейчас при каждом рестарте `control-api` валидаторы из YAML переприменяются (в частности, может тихо откатить `os_port_id`, изменённый через API/вручную в БД). После доработки — только на пустой таблице. Это осознанное поведенческое изменение, требует апдейта `docs/SETUP.md`/`docs/USAGE.md` (шаг 6 плана). ### 3. Запросы к БД для новых сущностей Новые файлы, по аналогии с существующими `queries_*.go`: - **`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 (это уже так в текущем коде). Новый файл **`internal/db/errors.go`** с типизированными сентинелами (`ErrNotFound`, `ErrConflict`, `ErrBusy`, `ErrValidation`, через `errors.New` + `%w`-обёртку в местах возврата) — чтобы `httpapi`-хендлеры мапили их в 404/409/400 через `errors.Is`, а не всё подряд в 500 (как сейчас местами получается по умолчанию). ### 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 ` через `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 → читается при старте (инициализация)» как единственного пути. ## Критичные файлы - `internal/db/migrations/0002_dynamic_config.sql` (новый) - `internal/db/db.go` (обобщить `migrate()`) - `internal/db/bootstrap.go` (новый) - `internal/db/errors.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-вызов, проверка токена при старте) ## Проверка (когда план будет реализовываться) 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 их не затёр.