Files
cloud-ip-validator/docs/PLAN_API_CONFIG_MANAGEMENT.md
T
2026-08-21 09:58:08 +03:00

302 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План доработки: 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 <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 → читается при старте (инициализация)» как единственного пути.
## Критичные файлы
- `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 их не затёр.