Files
infra-kit/README.md
T
stolzor 5cba142003
terraform / check (push) Waiting to run
Resources and access
2026-10-11 21:04:27 +03:00

317 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# infra
Локальная инфраструктура для проекта: Postgres с pgvector, S3-хранилище Silo, хранилище секретов OpenBao (форк Vault) и pgAdmin. Репозиторий задуман как шаблон: всё, что относится к конкретному проекту, лежит в `config.yaml` и `.env`.
Роли разделены:
- **Docker Compose** запускает сервисы.
- **Terraform** настраивает то, что внутри них: бакеты и ключи S3, базы и роли Postgres, политики и секреты Vault.
- **Vault** хранит сгенерированные секреты. Приложения берут их оттуда, а не из `.env`.
## Требования
| Инструмент | Зачем |
|---|---|
| Docker с Compose 2.20+ | сервисы; нужна поддержка `include` |
| Terraform 1.6+ | проверено на 1.16.5 |
| [mise](https://mise.jdx.dev) | запуск всех команд и сборка окружения |
| `jq`, `curl` | используются задачами |
## Повседневная работа
Все команды запускаются из каталога `infra/`. При первом запуске mise попросит доверить ему конфигурацию: `mise trust`.
| Команда | Что делает |
|---|---|
| `mise run up` | поднять сервисы |
| `mise run down` | остановить сервисы, данные в `data/` остаются |
| `mise run plan` | `terraform plan` во всех трёх конфигурациях |
| `mise run tf <каталог> <команда>` | любая команда Terraform, например `mise run tf storage apply` |
| `mise run fmt` | отформатировать все `.tf` |
| `mise run check` | форматирование и `terraform validate`; то же делает CI в `.github/workflows/terraform.yml` |
| `mise run vault:app-check [сервис]` | проверить, что сервис получает из Vault всё нужное и ничего лишнего |
| `mise run vault:backup` | снапшот Vault в бакет бэкапов |
| `mise run vault:bootstrap` | выпустить токен Terraform, нужен `ROOT_TOKEN` |
| `mise run example:up [сервис] [static\|dynamic]` | запустить пример Go-сервиса, `example:down` — остановить |
`mise tasks` показывает полный список. Обычный `docker compose` (`ps`, `logs`, `exec`) работает как всегда.
Правило: **перед каждым `apply` читать `plan`**, особенно строки `destroy` и `-/+`.
### После перезапуска контейнера Vault
Vault запускается запечатанным, и пока его не распечатали, ни Terraform, ни сервисы секретов не получат:
```bash
docker compose exec vault bao operator unseal # спросит unseal-ключ
docker compose exec vault bao status # Sealed должно быть false
```
## Где лежат настройки
| Что | Где | Кто правит |
|---|---|---|
| Всё несекретное: адреса, порты, образы, логины администраторов, имена бакетов, сервисы | `config.yaml` | вы |
| Пароли администраторов и токен Vault для Terraform | `.env` (образец в `.env.example`) | вы |
| Секреты сервисов: ключи S3, пароли баз | Vault | Terraform |
| `.env.config`, `backend.hcl` | генерируются из `config.yaml` | никто |
Два сгенерированных файла нужны потому, что Compose и блок `backend` в Terraform читать YAML не умеют. mise пересоздаёт их сам, когда `config.yaml` изменился; в git они не попадают.
Секреты из `.env` превращаются в переменные, которые ждут инструменты (`TF_VAR_*` для Terraform, `AWS_*` для backend), в секции `[env]` файла `mise.toml`.
## Бакеты, базы и сервисы
В `config.yaml` отдельно описано, что существует, и отдельно — кто имеет к этому доступ:
```yaml
buckets:
orders-files:
versioning: true
noncurrent_days: 30
reports:
versioning: false
expire_days: 90
databases:
orders:
pgvector: true
analytics: {}
services:
orders:
buckets:
orders-files: rw
reports: ro
databases: [orders, analytics]
```
| Раздел | Поле | Значение |
|---|---|---|
| `buckets` | `versioning` | хранить старые версии объектов, по умолчанию `true` |
| | `noncurrent_days` | сколько дней хранить старые версии, по умолчанию 30 |
| | `expire_days` | удалять объекты через столько дней; по умолчанию не удалять |
| `databases` | `pgvector` | установить расширение `vector`, по умолчанию `false` |
| `services` | `buckets` | бакет и доступ к нему: `rw` или `ro` |
| | `databases` | базы, в которых сервис работает |
Бакет или база без сервиса — это просто запись в `buckets` или `databases`. Один бакет и одну базу можно дать нескольким сервисам. Сервис — это учётная запись: для него создаются ключи S3, роли в Postgres, политика и роль AppRole в Vault.
После правки применить по очереди, читая `plan` перед каждым `apply`:
```bash
mise run tf vault apply # политики и роли AppRole
mise run tf storage apply # бакеты и ключи S3
mise run tf database apply # базы, роли, пароли
mise run vault:app-check # проверить доступы
```
При удалении порядок обратный: `database`, `storage`, `vault`. Что стоит знать:
- Удалить можно только пустой бакет.
- Переименование бакета или базы Terraform воспринимает как удаление старого и создание нового.
- Ссылку сервиса на несуществующий бакет или базу и недопустимое значение доступа ловит проверка на `plan`.
- Опечатка в имени необязательного поля (например, `noncurent_days`) ошибки не вызовет: лишнее поле молча отбрасывается, и берётся значение по умолчанию. Смотрите в `plan`, что изменилось именно то, что вы хотели.
- Пока живы временные роли, выданные Vault, отобрать у сервиса базу не получится: Postgres ответит `dependent privileges exist`. Нужно дождаться истечения их срока (до часа) или отозвать токены сервиса.
## Что получает сервис
Сервис входит в Vault через AppRole по паре `role_id` и `secret_id` и получает токен на час. С ним доступно:
| Путь в Vault | Содержимое |
|---|---|
| `secrets/<сервис>/s3` | `endpoint`, `buckets`, `access_key`, `secret_key` |
| `secrets/<сервис>/postgres/<база>` | постоянные `host`, `port`, `database`, `username`, `password`, `sslmode` |
| `database/creds/<сервис>-<база>` | временные `username` и `password`: Vault создаёт роль в Postgres на час и сам её удаляет |
В Postgres у сервиса отдельная роль на каждую базу, `<сервис>-<база>`. Владеет базой роль `<база>_owner` без права входа, а роли сервисов работают от её имени. Поэтому таблицы, созданные одним сервисом, доступны другому сервису той же базы.
Какой пароль базы выбрать: постоянный проще и подходит по умолчанию; временный не нужно ротировать и он не лежит в KV, но сервис обязан продлевать токен Vault, иначе через час потеряет доступ к базе.
Адреса в секретах указаны для сети Compose (`postgres:5432`, `object-storage:9000`). Чужие пути отдают `403`. В HTTP API движка KV версии 2 после имени mount идёт `data/`: `GET /v1/secrets/data/<сервис>/s3`.
`role_id` лежит в `mise run tf vault output approle_role_ids`. `secret_id` — это пароль сервиса: он действует 30 дней, после чего сервису нужен новый. Выпускается отдельно:
```bash
curl -X POST -H "X-Vault-Token: $VAULT_TOKEN" \
http://127.0.0.1:8200/v1/auth/approle/role/<сервис>/secret-id
```
Готовый пример всех запросов, которые делает сервис, — задача `mise-tasks/vault/app-check`.
## Пример сервиса на Go
В `examples/go-service/` лежит небольшой сервис, который показывает всю цепочку: при запуске он знает только адрес Vault, `role_id` и `secret_id`, а ключи S3 и пароль базы получает из Vault.
```bash
mise run example:up # первый сервис с бакетом и базой, постоянный пароль
mise run example:up <сервис> dynamic # временная роль в Postgres от Vault
curl http://127.0.0.1:8090/ # состояние подключений
mise run example:down
```
Задача `example:up` действует как администратор: берёт `role_id`, выпускает одноразовый `secret_id`, собирает образ и запускает контейнер в сети основного Compose. Дальше работает только код из `main.go`:
1. вход через AppRole клиентом `github.com/hashicorp/vault/api`;
2. чтение `secrets/<сервис>/s3` и `secrets/<сервис>/postgres/<база>`;
3. подключение к Postgres (`pgx`) и S3 (`minio-go`);
4. HTTP-обработчик, который показывает, под кем сервис работает в базе и сколько объектов видит в своих бакетах.
Это учебный пример, и в нём намеренно нет того, что нужно настоящему сервису:
- **Продления токена.** Токен Vault живёт час. С постоянным паролем сервис продолжит работать и после этого, а с временной ролью потеряет доступ к базе: Vault удаляет роль вместе с токеном. Настоящий сервис продлевает токен (`LifetimeWatcher` в клиенте) или входит заново.
- **Повторного входа.** `secret_id` одноразовый, поэтому после перезапуска контейнера сервис в Vault не войдёт; нужен новый `mise run example:up`.
- **Отзыва токена при остановке.** Временная роль остаётся в Postgres до истечения срока, до часа.
## Структура
```
infra/
├── config.yaml # несекретные настройки
├── .env # секреты, не в git
├── mise.toml # окружение и короткие задачи
├── mise-env.sh # следит за актуальностью сгенерированных файлов
├── mise-tasks/ # длинные задачи: tf, render, vault/*, example/*
├── examples/go-service/ # пример сервиса, который берёт секреты из Vault
├── docker-compose.yml # подключает compose.services.yml с обоими env-файлами
├── compose.services.yml # описание сервисов
├── config/vault/vault.hcl # конфигурация OpenBao
├── data/ # данные сервисов, не в git
├── modules/
│ ├── config/ # типы, проверки и значения по умолчанию для config.yaml
│ ├── bucket/ # бакет, versioning и lifecycle
│ ├── bucket-access/ # пользователь, политика и ключи S3 одного сервиса
│ ├── database/ # база, её роль-владелец и права
│ └── database-access/ # роль сервиса в одной базе
├── vault/ # Terraform: mount, политики, AppRole
├── storage/ # Terraform: Silo и запись ключей в Vault
└── database/ # Terraform: Postgres, запись паролей и временные роли
```
`vault/`, `storage/` и `database/` — три отдельные конфигурации, каждая со своим state в бакете из `infra.silo.state_bucket`. Объединить их нельзя: провайдеру нужен уже настроенный сервис во время `plan`. Порядок применения всегда `vault` → `storage` → `database`.
## Vault: что нужно знать
- **Токен Terraform** лежит в `.env` как `VAULT_TOKEN`. Он живёт, пока его продлевают хотя бы раз в 30 дней: `docker compose exec -e BAO_TOKEN=<токен> vault bao token renew`.
- **Его политика** (`vault_policy.terraform` в `vault/main.tf`) перечисляет только реально используемые пути. Новый тип ресурса в Vault сначала требует строки в политике; токен может обновить её сам. Создавать и удалять mount он не может намеренно.
- **Root-токен отозван.** Новый выпускается через `bao operator generate-root` с unseal-ключом.
- **Unseal-ключ** хранить вне машины и отдельно от снапшотов: без него снапшот не расшифровать, вместе они дают полный доступ.
- **Бэкап** делает `mise run vault:backup`: снапшот уходит в бакет из `infra.silo.backup_bucket` и хранится 30 дней. Расписания нет, оно зависит от машины; задачу можно вызывать из cron или таймера systemd.
- **Снапшоты лежат на той же машине**, что и Vault, так что от потери диска они не спасают. Восстановление из снапшота не проверялось.
## Новый проект из шаблона
Шаги проверены на чистой копии репозитория с пустым каталогом данных.
0. Скопировать репозиторий без `.env`, `data/`, `vault_keys.txt` и каталогов `.terraform/` (в git их и так нет). В `config.yaml` заменить под свой проект: логины администраторов, порты, если стандартные заняты, имя бакета для state, разделы `buckets`, `databases` и `services`. Бакет из `infra.silo.backup_bucket` должен быть описан в `buckets`. Больше ничего править не нужно.
1. Скопировать `.env.example` в `.env` и заполнить три пароля; `VAULT_TOKEN` оставить пустым, его запишет шаг 6. Спецсимволы в паролях ломают адреса подключения, поэтому удобно генерировать их командой `openssl rand -hex 32`. Затем `mise trust` и `mise run up`. Поднимать именно этой задачей: она выдаёт Vault и pgAdmin права на их каталоги данных, без чего на чистой машине они не запускаются.
2. Инициализировать Vault, сохранить unseal-ключ и root-токен, распечатать:
```bash
docker compose exec vault bao operator init -key-shares=1 -key-threshold=1
docker compose exec vault bao operator unseal
```
3. Создать бакет для state вручную. Terraform хранит в нём свой state, поэтому создать его сам до `init` не может:
```bash
set -a; source .env; source .env.config; set +a
docker compose exec -e MC_HOST_local="http://${SILO_USER}:${SILO_PASSWORD}@localhost:9000" \
object-storage mc mb local/<state_bucket из config.yaml>
```
4. Временно записать root-токен в `.env` как `VAULT_TOKEN`: первый `apply` создаёт mount и метод входа, а у токена Terraform на это нет прав.
5. Инициализировать и применить по порядку. В `storage` бакет из шага 3 сначала принимается под управление:
```bash
mise run tf vault init && mise run tf vault apply
mise run tf storage init
mise run tf storage import minio_s3_bucket.state_terraform_s3 <state_bucket>
mise run tf storage apply
mise run tf database init && mise run tf database apply
```
6. Заменить root-токен на токен Terraform и отозвать root:
```bash
ROOT_TOKEN=<root-токен> mise run vault:bootstrap --revoke-root
```
7. Убедиться, что всё на месте: `mise run plan` должен трижды показать `No changes`, `mise run vault:app-check` — пройти.
Два проекта из одного шаблона могут работать на одной машине одновременно, если у них разные порты и разные имена каталогов: имя каталога становится именем проекта Compose и его сети.
## Неочевидные места в коде
В коде нет комментариев, поэтому причины решений, которые не видны из самого кода, собраны здесь.
- **`depends_on` между грантами в `modules/database`.** Гранты `public` и `owner` меняют права одной и той же базы; без явного порядка Terraform выполнял бы их параллельно, и Postgres мог бы вернуть `tuple concurrently updated`.
- **Грант `owner`.** После отзыва прав у `public` владелец базы не может подключиться к ней без явного `CONNECT`.
- **`assume_role` в `modules/database-access`.** Роль сервиса при входе переключается на владельца базы, поэтому всё, что она создаёт, принадлежит владельцу, а не ей самой. Иначе второй сервис той же базы не смог бы изменить чужие таблицы.
- **Отдельная роль на пару сервис и база.** `assume_role` задаётся для роли целиком, а владельцы у баз разные.
- **`depends_on` в output `owner` модуля `database`.** Роль сервиса не создаётся, пока владельцу не выданы права на базу.
- **`ignore_changes = [roles]` у роли `vault` в `database/main.tf`.** Членством управляет `postgresql_grant_role`; без этой строки два ресурса по очереди переписывают список ролей.
- **`with_admin_option` там же.** Начиная с Postgres 16 роль с `CREATEROLE` добавляет участников только в те роли, где у неё есть `ADMIN OPTION`.
- **`ALTER ROLE ... SET role` в `creation_statements`.** Тот же приём для временных ролей Vault: созданные ими объекты принадлежат владельцу базы, а саму роль можно удалить без ошибок.
- **`delete_all_versions` у секретов.** Без него удалённый сервис оставлял бы в Vault восстановимые версии своих ключей.
- **`prevent_destroy` у бакета со state.** В нём лежит state той самой конфигурации, которая им управляет. Из-за этого он описан отдельным ресурсом, а не в `buckets`: `prevent_destroy` нельзя включить по условию.
- **Политика `terraform` в `vault/main.tf`.** Каждый путь взят из записи реальных запросов провайдера (`TF_LOG=DEBUG`) при добавлении и удалении сервиса. Создание и удаление mount, метода входа и самой политики в неё не входят намеренно.
- **`host` и `internal_host` в `config.yaml`.** Первый — адрес с хоста, им пользуется Terraform; второй — адрес внутри сети Compose, он попадает в секреты сервисов и в настройки Vault.
- **`include` в `docker-compose.yml`.** Только так Compose читает второй env-файл для подстановки переменных; сам он загружает один `.env`.
- **`chown` в задаче `up`.** Docker создаёт каталоги данных от имени root, а Vault и pgAdmin работают под своими пользователями.
- **`mise-env.sh`.** mise собирает окружение раньше, чем выполняет зависимости задач, поэтому сгенерированные файлы проверяются в момент загрузки окружения, а не отдельной задачей.
- **`#MISE` в начале задач** — не комментарии, а директивы mise с описанием задачи.
## Шпаргалка
Команды выполняются из `infra/`. Первые две строки загружают в shell те же переменные, что получают задачи mise.
```bash
set -a; source .env; source .env.config; set +a
MC="docker compose exec -e MC_HOST_local=http://${SILO_USER}:${SILO_PASSWORD}@localhost:9000 object-storage mc"
BAO="docker compose exec -e BAO_TOKEN=$VAULT_TOKEN vault bao"
# Silo
$MC ls local # бакеты
$MC version info local/<бакет> # включён ли versioning
$MC ls --versions local/<бакет> # объекты со всеми версиями
$MC ilm rule ls local/<бакет> # lifecycle-правила
$MC admin user ls local # пользователи
$MC admin policy ls local # политики
# Vault
docker compose exec vault bao status # запечатан или нет
$BAO kv get -mount=secrets <сервис>/s3 # секрет сервиса
$BAO policy read terraform # политика токена Terraform
$BAO token lookup # срок жизни токена
# Terraform
mise run tf storage state list # чем управляет конфигурация
mise run tf storage state show <адрес> # атрибуты одного ресурса
mise run tf storage plan -refresh-only # только дрейф, без изменений из кода
```
Как обращаться со state:
- **Изменение руками** (дрейф) видно в `plan` и откатывается следующим `apply`.
- **Объект, созданный руками**, берётся под управление блоком `import { to = ..., id = ... }` рядом с описанием ресурса; после `apply` блок можно удалить.
- **Блок `moved`** переименовывает ресурс в state без пересоздания.
- **State содержит секреты в открытом виде**, поэтому он лежит в бакете, а не в git.
## Ссылки
- Язык Terraform: https://developer.hashicorp.com/terraform/language
- Backend S3: https://developer.hashicorp.com/terraform/language/backend/s3
- Провайдер MinIO: https://registry.terraform.io/providers/aminueza/minio/latest/docs
- Провайдер PostgreSQL: https://registry.terraform.io/providers/cyrilgdn/postgresql/latest/docs
- Провайдер Vault: https://registry.terraform.io/providers/hashicorp/vault/latest/docs
- OpenBao: https://openbao.org/docs/
- mise: https://mise.jdx.dev/tasks/
## Ограничения
- Всё рассчитано на одну локальную машину: TLS выключен, порты привязаны к `127.0.0.1`, у Vault один unseal-ключ.
- У state нет блокировки. Два одновременных `apply` в одном каталоге могут его испортить.
- Секреты сервисов и пароль роли `vault` в Postgres лежат в state в открытом виде. Доступ к бакету со state равен доступу ко всем секретам.
- После смены порта Silo в `config.yaml` нужен `mise run tf <каталог> init -reconfigure` в каждом каталоге: адрес state запоминается при `init`.