diff --git a/cmd/control-api/main.go b/cmd/control-api/main.go index 0988c83..2bb6405 100644 --- a/cmd/control-api/main.go +++ b/cmd/control-api/main.go @@ -113,19 +113,7 @@ func runOrchestratorLoop(ctx context.Context, orch *orchestrator.Orchestrator, c func newOpenStackClient(ctx context.Context, cfg *config.ControlAPI) (openstack.FloatingIPClient, error) { if cfg.OpenStack.Mode == "real" { - clientCfg := openstack.ClientConfig{ - AuthURL: os.Getenv(cfg.OpenStack.AuthURLEnv), - Token: os.Getenv(cfg.OpenStack.TokenEnv), - ProjectID: os.Getenv(cfg.OpenStack.ProjectIDEnv), - ProjectName: os.Getenv(cfg.OpenStack.ProjectNameEnv), - DomainName: os.Getenv(cfg.OpenStack.ProjectDomainEnv), - Region: os.Getenv(cfg.OpenStack.RegionEnv), - } - if clientCfg.AuthURL == "" || clientCfg.Token == "" { - return nil, fmt.Errorf("openstack.mode=real requires %s and %s to be set in the environment", - cfg.OpenStack.AuthURLEnv, cfg.OpenStack.TokenEnv) - } - return openstack.NewClient(ctx, clientCfg) + return newRealOpenStackClient(ctx, cfg) } // Mock mode: pre-seed one synthetic floating-ip resource per @@ -137,3 +125,39 @@ func newOpenStackClient(ctx context.Context, cfg *config.ControlAPI) (openstack. } return mock, nil } + +func newRealOpenStackClient(ctx context.Context, cfg *config.ControlAPI) (openstack.FloatingIPClient, error) { + clientCfg := openstack.ClientConfig{ + AuthURL: os.Getenv(cfg.OpenStack.AuthURLEnv), + ProjectID: os.Getenv(cfg.OpenStack.ProjectIDEnv), + Region: os.Getenv(cfg.OpenStack.RegionEnv), + Interface: os.Getenv(cfg.OpenStack.InterfaceEnv), + } + + switch cfg.OpenStack.AuthMethod { + case "password": + clientCfg.Method = openstack.AuthMethodPassword + clientCfg.Username = os.Getenv(cfg.OpenStack.UsernameEnv) + clientCfg.Password = os.Getenv(cfg.OpenStack.PasswordEnv) + clientCfg.UserDomainName = os.Getenv(cfg.OpenStack.UserDomainNameEnv) + if clientCfg.Username == "" || clientCfg.Password == "" { + return nil, fmt.Errorf("openstack.auth_method=password requires %s and %s to be set in the environment", + cfg.OpenStack.UsernameEnv, cfg.OpenStack.PasswordEnv) + } + case "token", "": + clientCfg.Method = openstack.AuthMethodToken + clientCfg.Token = os.Getenv(cfg.OpenStack.TokenEnv) + if clientCfg.Token == "" { + return nil, fmt.Errorf("openstack.auth_method=token requires %s to be set in the environment", cfg.OpenStack.TokenEnv) + } + default: + return nil, fmt.Errorf("openstack.auth_method: unknown value %q (expected \"token\" or \"password\")", cfg.OpenStack.AuthMethod) + } + + if clientCfg.AuthURL == "" || clientCfg.ProjectID == "" { + return nil, fmt.Errorf("openstack.mode=real requires %s and %s to be set in the environment", + cfg.OpenStack.AuthURLEnv, cfg.OpenStack.ProjectIDEnv) + } + + return openstack.NewClient(ctx, clientCfg) +} diff --git a/configs/control-api.example.yaml b/configs/control-api.example.yaml index b6223e4..f4317ac 100644 --- a/configs/control-api.example.yaml +++ b/configs/control-api.example.yaml @@ -14,12 +14,28 @@ database: openstack: mode: "real" # "mock" | "real" — mock uses an in-memory # OpenStack stand-in for local dev/testing + auth_method: "token" # "token" (default) | "password" — see below + auth_url_env: "OS_AUTH_URL" - token_env: "OS_TOKEN" project_id_env: "OS_PROJECT_ID" - project_name_env: "OS_PROJECT_NAME" - project_domain_env: "OS_PROJECT_DOMAIN_NAME" region_env: "OS_REGION_NAME" + interface_env: "OS_INTERFACE" # optional; empty env value defaults to "public" + + # auth_method: "token" — an admin supplies an already project-scoped + # token directly; it's used as-is for every call, never exchanged for a + # new one. Simplest option, but it can't renew itself: when the token + # expires, control-api starts failing OpenStack calls until the operator + # reissues OS_TOKEN and restarts the process. + token_env: "OS_TOKEN" + + # auth_method: "password" — the client authenticates with a normal + # Keystone username/password and automatically re-authenticates + # (mints a fresh token) whenever the current one is rejected, for as + # long as the process runs. Trade-off: a long-lived password credential + # sits in the environment file instead of a token. + username_env: "OS_USERNAME" + user_domain_name_env: "OS_USER_DOMAIN_NAME" + password_env: "OS_PASSWORD" orchestrator: poll_interval_seconds: 5 diff --git a/deploy/systemd/control-api.service b/deploy/systemd/control-api.service index 5ffbb2e..b5df93c 100644 --- a/deploy/systemd/control-api.service +++ b/deploy/systemd/control-api.service @@ -8,9 +8,12 @@ Type=simple User=cloud-ip-validator Group=cloud-ip-validator ExecStart=/usr/local/bin/control-api -config /etc/cloud-ip-validator/control-api.yaml -# Holds OS_AUTH_URL / OS_TOKEN / OS_PROJECT_ID / etc — the admin credential -# for OpenStack Floating IP management. Keep this file mode 0600, owned by -# the service user; never commit it or put credentials in the YAML config. +# Holds the OpenStack admin credential for Floating IP management: always +# OS_AUTH_URL / OS_PROJECT_ID / OS_REGION_NAME, plus either OS_TOKEN +# (auth_method: token) or OS_USERNAME / OS_USER_DOMAIN_NAME / OS_PASSWORD +# (auth_method: password) — see docs/SETUP.md. Keep this file mode 0600, +# owned by the service user; never commit it or put credentials in the +# YAML config. EnvironmentFile=/etc/cloud-ip-validator/control-api.env WorkingDirectory=/var/lib/cloud-ip-validator Restart=on-failure diff --git a/docs/PLAN_API_CONFIG_MANAGEMENT.md b/docs/PLAN_API_CONFIG_MANAGEMENT.md new file mode 100644 index 0000000..df05092 --- /dev/null +++ b/docs/PLAN_API_CONFIG_MANAGEMENT.md @@ -0,0 +1,301 @@ +# План доработки: 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 их не затёр. diff --git a/docs/PLAN_OPENSTACK_AUTH.md b/docs/PLAN_OPENSTACK_AUTH.md new file mode 100644 index 0000000..6512452 --- /dev/null +++ b/docs/PLAN_OPENSTACK_AUTH.md @@ -0,0 +1,206 @@ +# План доработки: механизм аутентификации OpenStack-клиента + +> Статус: реализуется (см. коммиты после этого документа). Документ +> фиксирует согласованный дизайн доработки `internal/openstack` — +> поддержка двух режимов аутентификации (token passthrough / password +> auto-obtain) и приведение имён переменных окружения к реальному набору +> пользователя. + +## Context + +В ходе разбора выяснилось, что переменные окружения OpenStack нужны не +только «токену действовать», а именно **чтобы получить токен** — +пользователь предоставляет стандартный набор credentials, принятый в +python-openstackclient/RC-файлах: + +``` +OS_AUTH_URL=https://infra.mail.ru:35357/v3/ +OS_PROJECT_ID=366fdd4249984226a023f13ac94e226e +OS_REGION_NAME=RegionOne +OS_USERNAME=a.yurishchev +OS_USER_DOMAIN_NAME=users +OS_PASSWORD=<секрет> +OS_INTERFACE=public +OS_IDENTITY_API_VERSION=3 +``` + +Текущий код (`internal/openstack/client.go`) поддерживает только один +режим: обменять переданный `OS_TOKEN` на **новый** scoped-токен через +`POST /auth/tokens` (identity method `"token"`), потому что всегда +выставляет `authOpts.Scope`. Разбор исходников `gophercloud` показал, что +библиотека уже умеет и второй, более простой режим: если `Scope` **не** +задан, а `TokenID` задан — включается «passthrough»-путь (`v3auth` в +`openstack/client.go` пакета gophercloud): токен валидируется через +`GET /v3/auth/tokens` (`X-Subject-Token: <тот же токен>`) и **используется +как есть** для всех последующих вызовов Neutron — новый токен не +выпускается. Это ровно то поведение, которое просит пользователь: +«для выполнения API-вызовов следует использовать токен, который передал +администратор», без скрытого обмена. + +Итоговое требование: **два режима аутентификации**, выбираемые +конфигурацией: +1. **`token` (по умолчанию)** — админ передаёт уже готовый, заранее + scoped на нужный проект токен через `OS_TOKEN`; код использует его как + есть (passthrough), никогда не переобменивает. Автообновление здесь + невозможно в принципе (нечем) — если токен истёк, `control-api` упадёт + с ошибкой аутентификации, токен нужно перевыпустить и обновить env + вручную (это принимается как компромисс данного режима, не баг). +2. **`password` (опционально)** — админ передаёт + `OS_USERNAME`/`OS_PASSWORD`/`OS_USER_DOMAIN_NAME`; код сам получает + токен через обычную password-аутентификацию Keystone v3, с + `AllowReauth: true` — токен самообновляется автоматически при 401 в + течение всего времени жизни процесса (не ограничен TTL одного токена, + в отличие от режима `token`). + +Набор *имён* переменных окружения также приводится в соответствие с +реальным набором пользователя (стандартные `OS_*`-имена +python-openstackclient), включая ранее не читавшиеся код `OS_USERNAME`, +`OS_USER_DOMAIN_NAME`, `OS_PASSWORD`, `OS_INTERFACE`. `OS_PROJECT_NAME` и +`OS_PROJECT_DOMAIN_NAME` из текущего кода убираются как неиспользуемые — +у пользователя их нет, и они не нужны: в Keystone v3 scope по одному +`OS_PROJECT_ID` достаточен, доменной привязки проекта не требует. +`OS_IDENTITY_API_VERSION` не потребляется кодом — он и так всегда ходит в +Keystone v3 (`AuthenticateV3`/`v3auth`), читать/валидировать эту +переменную незачем. + +## Дизайн + +### 1. `internal/openstack/client.go` — новый `ClientConfig` и `NewClient` + +```go +type AuthMethod string + +const ( + AuthMethodToken AuthMethod = "token" // passthrough, по умолчанию + AuthMethodPassword AuthMethod = "password" // авто-получение, опционально +) + +type ClientConfig struct { + AuthURL string + Method AuthMethod // "" трактуется как AuthMethodToken + Token string // для Method == token + Username string // для Method == password + Password string + UserDomainName string + ProjectID string + Region string + Interface string // "public" | "internal" | "admin" — совпадает по значениям с gophercloud.Availability, маппинг не нужен +} +``` + +Вынести сборку `gophercloud.AuthOptions` в отдельную чистую функцию +**`buildAuthOptions(cfg ClientConfig) (gophercloud.AuthOptions, error)`** +(без сети) — специально, чтобы её можно было покрыть юнит-тестами без +реального OpenStack: + +- `Method == AuthMethodPassword`: требует `Username`+`Password` + (иначе ошибка); `Username`, `Password`, + `DomainName: cfg.UserDomainName`, `Scope: &gophercloud.AuthScope{ProjectID: cfg.ProjectID}`, + `AllowReauth: true`. +- `Method == AuthMethodToken` (или `""`, по умолчанию): требует `Token` + (иначе ошибка); `TokenID: cfg.Token`. **`Scope` намеренно не + выставляется** — это и есть переключатель на passthrough-путь в + `gophercloud`. `AllowReauth` не выставляется (`gophercloud` сам вернёт + ошибку, если включить `AllowReauth` без `Scope` — см. явную проверку в + `v3auth`), с комментарием почему. +- Неизвестный `Method` — явная ошибка на старте, а не тихий фоллбэк. + +`NewClient` дальше: `AuthenticatedClient(ctx, authOpts)` (без изменений в +логике вызова) → резолвинг `networking`-клиента с +`gophercloud.EndpointOpts{Region: cfg.Region, Availability: gophercloud.Availability(cfg.Interface)}` +(пустая строка `Interface` → явно дефолтить `"public"`). + +### 2. `internal/config/config.go` — `OpenStackConfig` + +Обновить набор *_env-полей до реального использования (индирекция +«имя переменной → значение» сохраняется, но список и дефолты приводятся +в соответствие): + +```go +type OpenStackConfig struct { + Mode string `yaml:"mode"` // "mock" | "real" + AuthMethod string `yaml:"auth_method"` // "token" (default) | "password" + + AuthURLEnv string `yaml:"auth_url_env"` // default OS_AUTH_URL + ProjectIDEnv string `yaml:"project_id_env"` // default OS_PROJECT_ID + RegionEnv string `yaml:"region_env"` // default OS_REGION_NAME + InterfaceEnv string `yaml:"interface_env"` // default OS_INTERFACE + + TokenEnv string `yaml:"token_env"` // default OS_TOKEN — auth_method: token + + UsernameEnv string `yaml:"username_env"` // default OS_USERNAME — auth_method: password + UserDomainNameEnv string `yaml:"user_domain_name_env"` // default OS_USER_DOMAIN_NAME + PasswordEnv string `yaml:"password_env"` // default OS_PASSWORD +} +``` + +Убрать `ProjectNameEnv`/`ProjectDomainEnv` (не используются в реальном +наборе, Keystone v3 scope по ID их не требует — упрощение, а не потеря +функциональности). + +### 3. `cmd/control-api/main.go` — `newOpenStackClient` + +- Читает `cfg.OpenStack.AuthMethod` (`""`/`"token"` → `openstack.AuthMethodToken`, + `"password"` → `openstack.AuthMethodPassword`, иначе — отказ старта). +- Для `token`: требует непустой `os.Getenv(TokenEnv)` — иначе явная + ошибка старта с именем недостающей переменной. +- Для `password`: требует непустые `Username`/`Password` — иначе явная + ошибка старта. +- `AuthURL`/`ProjectID`/`Region` — обязательны в обоих режимах, как и + сейчас. +- `Interface` — необязателен (пустая строка = `public` по умолчанию, см. + выше). + +### 4. Конфиг и документация + +- `configs/control-api.example.yaml`: секция `openstack` переписывается + под новый набор полей и под пример из реального использования (два + примера в комментариях — `auth_method: token` и `auth_method: password`). +- `deploy/systemd/control-api.service` / `docs/SETUP.md`: обновить + пример `control-api.env` под оба режима, объяснить компромисс режима + `token` (нет автообновления, нужен ручной перевыпуск при истечении) vs + `password` (самообновляется, но требует хранить пароль в env-файле). +- `docs/API.md`/`docs/DIAGRAMS.md` не затрагиваются — это внутренний + механизм клиента, наружу в HTTP API не выходит. + +### 5. Тесты + +- Новый `internal/openstack/client_test.go`: юнит-тесты **чистой** + функции `buildAuthOptions` — без сети, без `OPENSTACK_LIVE_TEST`: + - `token`: `Scope == nil`, `TokenID` равен переданному, `AllowReauth == false`. + - `token` без `Token` → ошибка. + - `password`: `Scope != nil` и `Scope.ProjectID` верный, `AllowReauth == true`, `Username`/`Password`/`DomainName` прокинуты. + - `password` без `Username`/`Password` → ошибка. + - неизвестный `Method` → ошибка. +- `internal/openstack/client_live_test.go` (уже существует, гейтится + `OPENSTACK_LIVE_TEST=1`): расширить, чтобы читал `OS_AUTH_METHOD` + (`token`/`password`) и гонял `GetFloatingIPByAddress` в обоих режимах, + если для них заданы переменные — ручная проверка соответствия дизайна + реальному Keystone (Mail.ru/VK Cloud), не часть обычного `go test ./...`. + +## Критичные файлы + +- `internal/openstack/client.go` (основная переработка) +- `internal/openstack/client_test.go` (новый) +- `internal/openstack/client_live_test.go` (расширение) +- `internal/config/config.go` (`OpenStackConfig`) +- `cmd/control-api/main.go` (`newOpenStackClient`) +- `configs/control-api.example.yaml`, `docs/SETUP.md`, + `deploy/systemd/control-api.service` + +## Проверка + +1. `go build ./... && go test ./...` — новые юнит-тесты + `buildAuthOptions` проходят офлайн вместе со всем остальным. +2. `scripts/run-local-e2e.sh` не затрагивается (использует + `openstack.mode: mock`, минует `NewClient` целиком) — должен + по-прежнему проходить без изменений в поведении. +3. Ручная проверка с реальными данными пользователя (вне автоматических + тестов, наружу секреты не публикуются): поднять `control-api` с + `openstack.mode: real`, `auth_method: token`, реальным `OS_TOKEN` — + убедиться, что `GetFloatingIPByAddress`/associate/disassociate проходят + без 401; затем повторить с `auth_method: password` и + `OS_USERNAME`/`OS_PASSWORD`/`OS_USER_DOMAIN_NAME` — сверить, что оба + режима реально авторизуются в `https://infra.mail.ru:35357/v3/` и + работают с проектом `366fdd4249984226a023f13ac94e226e` в + `RegionOne`. diff --git a/docs/SETUP.md b/docs/SETUP.md index 3288641..9b64a30 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -184,23 +184,57 @@ cp configs/control-api.example.yaml /etc/cloud-ip-validator/control-api.yaml ### 2. Переменные окружения для OpenStack Учётные данные передаются **только** через переменные окружения — никогда -через YAML. Создайте файл (доступный на чтение только сервисному -пользователю): +через YAML. Есть два режима аутентификации, выбираются полем +`openstack.auth_method` в `control-api.yaml`: + +**`auth_method: "token"` (по умолчанию)** — вы предоставляете уже +готовый, заранее scoped на нужный проект токен (например, полученный +через `openstack --os-project-id= token issue`). Control-api +использует его как есть, ни на что не обменивает. Просто, но токен не +самообновляется: когда он истечёт, `control-api` начнёт получать ошибки +от OpenStack API, пока вы вручную не перевыпустите токен, не обновите +переменную и не перезапустите процесс. ```bash install -m 0600 -o cloud-ip-validator -g cloud-ip-validator /dev/null /etc/cloud-ip-validator/control-api.env cat >> /etc/cloud-ip-validator/control-api.env <<'EOF' OS_AUTH_URL=https://keystone.example.com:5000/v3 -OS_TOKEN=<токен администратора с правами на управление floating IP> +OS_TOKEN=<токен администратора, уже scoped на сервисный проект> OS_PROJECT_ID= OS_REGION_NAME=<регион> EOF ``` +**`auth_method: "password"`** — вы предоставляете обычные логин/пароль; +control-api сам получает токен через Keystone и автоматически +переполучает новый при истечении текущего (весь срок жизни процесса, без +ручного вмешательства). Компромисс — в файле окружения лежит долгоживущий +пароль, а не токен. + +```bash +install -m 0600 -o cloud-ip-validator -g cloud-ip-validator /dev/null /etc/cloud-ip-validator/control-api.env +cat >> /etc/cloud-ip-validator/control-api.env <<'EOF' +OS_AUTH_URL=https://keystone.example.com:5000/v3 +OS_PROJECT_ID= +OS_REGION_NAME=<регион> +OS_USERNAME=<логин> +OS_USER_DOMAIN_NAME=<домен пользователя> +OS_PASSWORD=<пароль> +EOF +``` +и в `control-api.yaml`: `openstack.auth_method: "password"`. + +Опционально в обоих режимах — `OS_INTERFACE` (`public`/`internal`/`admin`, +по умолчанию `public`): выбирает, какой из адресов Neutron в каталоге +сервисов использовать, если у эндпоинта их несколько. + Имена переменных должны совпадать с тем, что указано в `control-api.yaml` в секции `openstack` (`auth_url_env`, `token_env` и -т.д.) — в шаблоне это ровно `OS_AUTH_URL`, `OS_TOKEN`, `OS_PROJECT_ID`, -`OS_PROJECT_NAME`, `OS_PROJECT_DOMAIN_NAME`, `OS_REGION_NAME`. +т.д.) — в шаблоне это ровно `OS_AUTH_URL`, `OS_PROJECT_ID`, +`OS_REGION_NAME`, `OS_INTERFACE`, и, в зависимости от режима, либо +`OS_TOKEN`, либо `OS_USERNAME`/`OS_USER_DOMAIN_NAME`/`OS_PASSWORD` — это +стандартные имена, принятые в python-openstackclient/RC-файлах, менять их +обычно не требуется. ### 3. `validator-agent.yaml` (свой на каждом валидаторе) diff --git a/internal/config/config.go b/internal/config/config.go index 924f7b2..6347ba7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,14 +39,27 @@ type DatabaseConfig struct { // OpenStack admin credential from at startup. Mode "mock" skips all of this // and uses an in-memory FloatingIPClient instead — used for local dev and // the offline end-to-end harness. +// +// AuthMethod selects which credential shape is expected in the process +// environment: "token" (default) reads a pre-issued, already +// project-scoped token from TokenEnv and uses it as-is (no renewal — the +// operator reissues and restarts on expiry). "password" reads +// username/password/domain from UsernameEnv/PasswordEnv/UserDomainNameEnv +// and has the client obtain and auto-renew its own token. type OpenStackConfig struct { - Mode string `yaml:"mode"` // "mock" | "real" - AuthURLEnv string `yaml:"auth_url_env"` - TokenEnv string `yaml:"token_env"` - ProjectIDEnv string `yaml:"project_id_env"` - ProjectNameEnv string `yaml:"project_name_env"` - ProjectDomainEnv string `yaml:"project_domain_env"` - RegionEnv string `yaml:"region_env"` + Mode string `yaml:"mode"` // "mock" | "real" + AuthMethod string `yaml:"auth_method"` // "token" (default) | "password" + + AuthURLEnv string `yaml:"auth_url_env"` // default OS_AUTH_URL + ProjectIDEnv string `yaml:"project_id_env"` // default OS_PROJECT_ID + RegionEnv string `yaml:"region_env"` // default OS_REGION_NAME + InterfaceEnv string `yaml:"interface_env"` // default OS_INTERFACE; "" at runtime defaults to "public" + + TokenEnv string `yaml:"token_env"` // default OS_TOKEN — used when auth_method: token + + UsernameEnv string `yaml:"username_env"` // default OS_USERNAME — used when auth_method: password + UserDomainNameEnv string `yaml:"user_domain_name_env"` // default OS_USER_DOMAIN_NAME + PasswordEnv string `yaml:"password_env"` // default OS_PASSWORD } type OrchestratorConfig struct { @@ -101,6 +114,33 @@ func LoadControlAPI(path string) (*ControlAPI, error) { if c.OpenStack.Mode == "" { c.OpenStack.Mode = "mock" } + if c.OpenStack.AuthMethod == "" { + c.OpenStack.AuthMethod = "token" + } + if c.OpenStack.AuthURLEnv == "" { + c.OpenStack.AuthURLEnv = "OS_AUTH_URL" + } + if c.OpenStack.ProjectIDEnv == "" { + c.OpenStack.ProjectIDEnv = "OS_PROJECT_ID" + } + if c.OpenStack.RegionEnv == "" { + c.OpenStack.RegionEnv = "OS_REGION_NAME" + } + if c.OpenStack.InterfaceEnv == "" { + c.OpenStack.InterfaceEnv = "OS_INTERFACE" + } + if c.OpenStack.TokenEnv == "" { + c.OpenStack.TokenEnv = "OS_TOKEN" + } + if c.OpenStack.UsernameEnv == "" { + c.OpenStack.UsernameEnv = "OS_USERNAME" + } + if c.OpenStack.UserDomainNameEnv == "" { + c.OpenStack.UserDomainNameEnv = "OS_USER_DOMAIN_NAME" + } + if c.OpenStack.PasswordEnv == "" { + c.OpenStack.PasswordEnv = "OS_PASSWORD" + } if c.Orchestrator.PollIntervalSeconds == 0 { c.Orchestrator.PollIntervalSeconds = 5 } diff --git a/internal/openstack/client.go b/internal/openstack/client.go index 6cd7b02..ddf9aec 100644 --- a/internal/openstack/client.go +++ b/internal/openstack/client.go @@ -9,44 +9,109 @@ import ( "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips" ) +// AuthMethod selects how the client obtains the token it uses for Neutron +// calls. +type AuthMethod string + +const ( + // AuthMethodToken uses an admin-supplied token as-is (passthrough): the + // token is validated against Keystone but never exchanged for a freshly + // minted one, and the exact token value the operator provided is what + // every subsequent Neutron call uses. This is the default — it matches + // the deployment constraint that the admin credential is supplied + // directly via the process environment. Its trade-off: there is no way + // to renew the token automatically, since nothing longer-lived than the + // token itself is available to re-authenticate with. When it expires, + // the operator must reissue it and restart control-api. + AuthMethodToken AuthMethod = "token" + + // AuthMethodPassword has the client obtain its own token via ordinary + // Keystone password authentication, and automatically re-authenticates + // (mints a fresh token) whenever the current one is rejected — not + // bounded by any single token's TTL, at the cost of holding a + // long-lived password credential in the process environment instead of + // a token. + AuthMethodPassword AuthMethod = "password" +) + // ClientConfig carries pre-resolved credential values (already read from -// environment variables by the caller — see config.OpenStackAuth). Token -// auth is required per the deployment constraint that the admin credential -// is supplied via the process environment, not a config file. +// environment variables by the caller — see config.OpenStackConfig). Some +// form of admin credential is always required via the process environment, +// never a config file; which fields are required depends on Method. type ClientConfig struct { - AuthURL string - Token string - ProjectID string - ProjectName string - DomainName string - Region string + AuthURL string + Method AuthMethod // "" is treated as AuthMethodToken + + Token string // required when Method == AuthMethodToken + + Username string // required when Method == AuthMethodPassword + Password string + UserDomainName string + + ProjectID string + Region string + Interface string // "public" | "internal" | "admin"; "" defaults to "public" } type Client struct { networking *gophercloud.ServiceClient } -// NewClient authenticates against Keystone using a pre-issued admin token -// and returns a Client scoped to the given project/region, backed by the -// Neutron (networking v2) service catalog entry. -func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) { - if cfg.AuthURL == "" || cfg.Token == "" { - return nil, fmt.Errorf("openstack: auth URL and token are required") +// buildAuthOptions translates ClientConfig into gophercloud.AuthOptions. It +// touches no network and returns only validation errors, so the auth-method +// selection logic is unit-testable without a real OpenStack deployment. +func buildAuthOptions(cfg ClientConfig) (gophercloud.AuthOptions, error) { + if cfg.AuthURL == "" { + return gophercloud.AuthOptions{}, fmt.Errorf("openstack: auth URL is required") + } + if cfg.ProjectID == "" { + return gophercloud.AuthOptions{}, fmt.Errorf("openstack: project ID is required") } - authOpts := gophercloud.AuthOptions{ - IdentityEndpoint: cfg.AuthURL, - TokenID: cfg.Token, - TenantID: cfg.ProjectID, - TenantName: cfg.ProjectName, - DomainName: cfg.DomainName, - } - if cfg.ProjectID != "" || cfg.ProjectName != "" { - authOpts.Scope = &gophercloud.AuthScope{ - ProjectID: cfg.ProjectID, - ProjectName: cfg.ProjectName, - DomainName: cfg.DomainName, + opts := gophercloud.AuthOptions{IdentityEndpoint: cfg.AuthURL} + + switch cfg.Method { + case AuthMethodPassword: + if cfg.Username == "" || cfg.Password == "" { + return gophercloud.AuthOptions{}, fmt.Errorf("openstack: username and password are required for auth_method=password") } + opts.Username = cfg.Username + opts.Password = cfg.Password + opts.DomainName = cfg.UserDomainName + opts.Scope = &gophercloud.AuthScope{ProjectID: cfg.ProjectID} + // Safe and valuable here: a password credential can always mint a + // fresh token, so gophercloud can transparently re-authenticate on + // 401 for the entire lifetime of the process. + opts.AllowReauth = true + + case AuthMethodToken, "": + if cfg.Token == "" { + return gophercloud.AuthOptions{}, fmt.Errorf("openstack: token is required for auth_method=token") + } + opts.TokenID = cfg.Token + // Scope is deliberately left unset: gophercloud's v3auth takes this + // as the signal to "pass through" the given token rather than + // exchange it for a new one (GET /v3/auth/tokens to validate + + // fetch the catalog, using the same token value for every + // subsequent call, never minting a replacement). AllowReauth is + // left false on purpose — gophercloud actively rejects AllowReauth + // when Scope is unset, and there's nothing to reauthenticate with + // beyond the one token we were given anyway. + + default: + return gophercloud.AuthOptions{}, fmt.Errorf("openstack: unknown auth method %q", cfg.Method) + } + + return opts, nil +} + +// NewClient authenticates against Keystone (via the method selected by +// cfg.Method) and returns a Client scoped to the given project/region, +// backed by the Neutron (networking v2) service catalog entry. +func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) { + authOpts, err := buildAuthOptions(cfg) + if err != nil { + return nil, err } provider, err := osauth.AuthenticatedClient(ctx, authOpts) @@ -54,7 +119,14 @@ func NewClient(ctx context.Context, cfg ClientConfig) (*Client, error) { return nil, fmt.Errorf("openstack: authenticate: %w", err) } - networking, err := osauth.NewNetworkV2(provider, gophercloud.EndpointOpts{Region: cfg.Region}) + iface := cfg.Interface + if iface == "" { + iface = string(gophercloud.AvailabilityPublic) + } + networking, err := osauth.NewNetworkV2(provider, gophercloud.EndpointOpts{ + Region: cfg.Region, + Availability: gophercloud.Availability(iface), + }) if err != nil { return nil, fmt.Errorf("openstack: networking client: %w", err) } diff --git a/internal/openstack/client_live_test.go b/internal/openstack/client_live_test.go index 4e4bbee..fac4807 100644 --- a/internal/openstack/client_live_test.go +++ b/internal/openstack/client_live_test.go @@ -12,9 +12,15 @@ import ( // vars are set, so the default `go test ./...` run needs no cloud access. // It only exercises GetFloatingIPByAddress (read-only) against // OS_TEST_FLOATING_IP, to avoid mutating real infrastructure in CI. +// +// OS_AUTH_METHOD selects which credential shape to test: "token" (default) +// reads OS_TOKEN and exercises the passthrough path; "password" reads +// OS_USERNAME/OS_PASSWORD/OS_USER_DOMAIN_NAME and exercises the +// auto-obtain/auto-reauth path. Run once per method to validate both +// against the real deployment. func TestClientLive(t *testing.T) { if os.Getenv("OPENSTACK_LIVE_TEST") != "1" { - t.Skip("set OPENSTACK_LIVE_TEST=1 (and OS_AUTH_URL, OS_TOKEN, OS_TEST_FLOATING_IP) to run") + t.Skip("set OPENSTACK_LIVE_TEST=1 (and OS_AUTH_URL, OS_PROJECT_ID, OS_TEST_FLOATING_IP, plus either OS_TOKEN or OS_USERNAME/OS_PASSWORD/OS_USER_DOMAIN_NAME) to run") } testIP := os.Getenv("OS_TEST_FLOATING_IP") @@ -22,19 +28,30 @@ func TestClientLive(t *testing.T) { t.Fatal("OS_TEST_FLOATING_IP must name a floating IP address that exists in the target project") } + cfg := ClientConfig{ + AuthURL: os.Getenv("OS_AUTH_URL"), + ProjectID: os.Getenv("OS_PROJECT_ID"), + Region: os.Getenv("OS_REGION_NAME"), + Interface: os.Getenv("OS_INTERFACE"), + } + + switch os.Getenv("OS_AUTH_METHOD") { + case "password": + cfg.Method = AuthMethodPassword + cfg.Username = os.Getenv("OS_USERNAME") + cfg.Password = os.Getenv("OS_PASSWORD") + cfg.UserDomainName = os.Getenv("OS_USER_DOMAIN_NAME") + default: + cfg.Method = AuthMethodToken + cfg.Token = os.Getenv("OS_TOKEN") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - client, err := NewClient(ctx, ClientConfig{ - AuthURL: os.Getenv("OS_AUTH_URL"), - Token: os.Getenv("OS_TOKEN"), - ProjectID: os.Getenv("OS_PROJECT_ID"), - ProjectName: os.Getenv("OS_PROJECT_NAME"), - DomainName: os.Getenv("OS_PROJECT_DOMAIN_NAME"), - Region: os.Getenv("OS_REGION_NAME"), - }) + client, err := NewClient(ctx, cfg) if err != nil { - t.Fatalf("new client: %v", err) + t.Fatalf("new client (method=%s): %v", cfg.Method, err) } fip, err := client.GetFloatingIPByAddress(ctx, testIP) @@ -44,5 +61,5 @@ func TestClientLive(t *testing.T) { if fip.Address != testIP { t.Fatalf("expected address %s, got %s", testIP, fip.Address) } - t.Logf("found floating ip %s: id=%s port_id=%q", fip.Address, fip.ID, fip.PortID) + t.Logf("found floating ip %s: id=%s port_id=%q (auth method=%s)", fip.Address, fip.ID, fip.PortID, cfg.Method) } diff --git a/internal/openstack/client_test.go b/internal/openstack/client_test.go new file mode 100644 index 0000000..5b59f94 --- /dev/null +++ b/internal/openstack/client_test.go @@ -0,0 +1,118 @@ +package openstack + +import "testing" + +func TestBuildAuthOptionsToken(t *testing.T) { + opts, err := buildAuthOptions(ClientConfig{ + AuthURL: "https://keystone.example:5000/v3/", + Method: AuthMethodToken, + Token: "tok-123", + ProjectID: "proj-1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.TokenID != "tok-123" { + t.Fatalf("expected TokenID to be passed through, got %q", opts.TokenID) + } + if opts.Scope != nil { + t.Fatalf("expected Scope to be unset for token passthrough, got %+v", opts.Scope) + } + if opts.AllowReauth { + t.Fatalf("expected AllowReauth=false for token passthrough") + } +} + +func TestBuildAuthOptionsTokenDefaultMethod(t *testing.T) { + // Method: "" must behave identically to AuthMethodToken. + opts, err := buildAuthOptions(ClientConfig{ + AuthURL: "https://keystone.example:5000/v3/", + Token: "tok-123", + ProjectID: "proj-1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.TokenID != "tok-123" || opts.Scope != nil { + t.Fatalf("expected default method to behave as token passthrough, got %+v", opts) + } +} + +func TestBuildAuthOptionsTokenMissing(t *testing.T) { + _, err := buildAuthOptions(ClientConfig{ + AuthURL: "https://keystone.example:5000/v3/", + Method: AuthMethodToken, + ProjectID: "proj-1", + }) + if err == nil { + t.Fatal("expected error for missing token") + } +} + +func TestBuildAuthOptionsPassword(t *testing.T) { + opts, err := buildAuthOptions(ClientConfig{ + AuthURL: "https://keystone.example:5000/v3/", + Method: AuthMethodPassword, + Username: "a.yurishchev", + Password: "secret", + UserDomainName: "users", + ProjectID: "proj-1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Username != "a.yurishchev" || opts.Password != "secret" || opts.DomainName != "users" { + t.Fatalf("expected password credentials to be passed through, got %+v", opts) + } + if opts.Scope == nil || opts.Scope.ProjectID != "proj-1" { + t.Fatalf("expected scope to be set to project ID, got %+v", opts.Scope) + } + if !opts.AllowReauth { + t.Fatalf("expected AllowReauth=true for password auth") + } +} + +func TestBuildAuthOptionsPasswordMissingCredentials(t *testing.T) { + _, err := buildAuthOptions(ClientConfig{ + AuthURL: "https://keystone.example:5000/v3/", + Method: AuthMethodPassword, + Username: "a.yurishchev", + ProjectID: "proj-1", + }) + if err == nil { + t.Fatal("expected error for missing password") + } +} + +func TestBuildAuthOptionsUnknownMethod(t *testing.T) { + _, err := buildAuthOptions(ClientConfig{ + AuthURL: "https://keystone.example:5000/v3/", + Method: "totp", + ProjectID: "proj-1", + }) + if err == nil { + t.Fatal("expected error for unknown auth method") + } +} + +func TestBuildAuthOptionsMissingAuthURL(t *testing.T) { + _, err := buildAuthOptions(ClientConfig{ + Method: AuthMethodToken, + Token: "tok-123", + ProjectID: "proj-1", + }) + if err == nil { + t.Fatal("expected error for missing auth URL") + } +} + +func TestBuildAuthOptionsMissingProjectID(t *testing.T) { + _, err := buildAuthOptions(ClientConfig{ + AuthURL: "https://keystone.example:5000/v3/", + Method: AuthMethodToken, + Token: "tok-123", + }) + if err == nil { + t.Fatal("expected error for missing project ID") + } +}