fix auth model

This commit is contained in:
ayurishchev committed 2026-08-21 09:58:08 +03:00
1 parent f81285b151
commit 5e3800e9cb
10 files changed
+896 -65

No files matched your search

+37 -13
View File
@@ -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)
}
+19 -3
View File
@@ -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
+6 -3
View File
@@ -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
+301
View File
@@ -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 <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 их не затёр.
+206
View File
@@ -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`.
+39 -5
View File
@@ -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=<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=<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=<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` (свой на каждом валидаторе)
+46 -6
View File
@@ -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"`
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
}
+96 -24
View File
@@ -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
Method AuthMethod // "" is treated as AuthMethodToken
Token string // required when Method == AuthMethodToken
Username string // required when Method == AuthMethodPassword
Password string
UserDomainName string
ProjectID string
ProjectName string
DomainName 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,
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")
}
if cfg.ProjectID != "" || cfg.ProjectName != "" {
authOpts.Scope = &gophercloud.AuthScope{
ProjectID: cfg.ProjectID,
ProjectName: cfg.ProjectName,
DomainName: cfg.DomainName,
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)
}
+28 -11
View File
@@ -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)
}
+118
View File
@@ -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")
}
}