22 KiB
План доработки: 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
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
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)(проверяет допустимостьidx1..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-вызов, проверка токена при старте)
Проверка (когда план будет реализовываться)
go build ./... && go test ./...— все существующие + новые unit- и httpapi-тесты проходят.scripts/run-local-e2e.sh— офлайн-сценарий по-прежнему проходит от начала до конца без ручного вмешательства (bootstrap из YAML при пустой БД работает как раньше).- Ручная проверка нового контракта: поднять
control-apiс пустой БД иadmin_token_envбез токена → админ-запрос без заголовка проходит; задать токен → запрос безAuthorizationполучает 401; создать валидатора/площадку/группу целей/тип проверки через API без единой строчки в YAML, убедиться, что IP реально проходит полный цикл проверки на этой конфигурации; попытаться удалить валидатора, пока он владеет IP → 409; перезапуститьcontrol-apiи убедиться, что API-изменения пережили рестарт, а YAML их не затёр.