# 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`. ## Как добавить сервис 1. Описать его в `config.yaml`: ```yaml services: orders: postgres: username: orders name: orders pgvector: true # необязательно, по умолчанию false buckets: orders-files: versioning: true # по умолчанию true noncurrent_days: 30 # сколько хранить старые версии ``` Блоки `postgres` и `buckets` необязательны: сервису можно дать только базу или только бакеты. 2. Применить по очереди, читая `plan` перед каждым `apply`: ```bash mise run tf vault apply # политика и роль AppRole mise run tf storage apply # бакеты и ключи S3 mise run tf database apply # база, роль, пароли ``` 3. Проверить: `mise run vault:app-check orders`. Удаление сервиса — те же шаги в обратном порядке (`database`, `storage`, `vault`) после удаления блока из `config.yaml`. Бакет должен быть пустым. Переименование бакета или базы Terraform воспринимает как удаление старого и создание нового. Имена бакетов должны быть уникальны между сервисами; удобно начинать их с имени сервиса. Опечатка в имени необязательного поля (например, `noncurent_days`) ошибки не вызовет: лишнее поле молча отбрасывается, и берётся значение по умолчанию. После правки `config.yaml` смотрите в `plan`, что изменилось именно то, что вы хотели. ## Что получает сервис Сервис входит в 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 на час и сам её удаляет | Какой пароль базы выбрать: постоянный проще и подходит по умолчанию; временный не нужно ротировать и он не лежит в 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 # первый сервис из config.yaml, постоянный пароль базы 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 │ ├── app-storage/ # бакеты, lifecycle, пользователь и ключи одного сервиса │ └── app-database/ # база, роль и права одного сервиса ├── 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 и бэкапов, раздел `services`. Больше ничего править не нужно. 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/ ``` 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 mise run tf storage apply mise run tf database init && mise run tf database apply ``` 6. Заменить root-токен на токен Terraform и отозвать root: ```bash ROOT_TOKEN= mise run vault:bootstrap --revoke-root ``` 7. Убедиться, что всё на месте: `mise run plan` должен трижды показать `No changes`, `mise run vault:app-check` — пройти. Два проекта из одного шаблона могут работать на одной машине одновременно, если у них разные порты и разные имена каталогов: имя каталога становится именем проекта Compose и его сети. ## Неочевидные места в коде В коде нет комментариев, поэтому причины решений, которые не видны из самого кода, собраны здесь. - **`depends_on` между грантами в `modules/app-database`.** Гранты `public` и `owner` меняют права одной и той же базы; без явного порядка Terraform выполнял бы их параллельно, и Postgres мог бы вернуть `tuple concurrently updated`. - **Грант `owner`.** После отзыва прав у `public` владелец базы не может подключиться к ней без явного `CONNECT`. - **`ignore_changes = [roles]` у роли `vault` в `database/main.tf`.** Членством управляет `postgresql_grant_role`; без этой строки два ресурса по очереди переписывают список ролей. - **`with_admin_option` там же.** Начиная с Postgres 16 роль с `CREATEROLE` добавляет участников только в те роли, где у неё есть `ADMIN OPTION`. - **`ALTER ROLE ... SET role` в `creation_statements`.** Временная роль работает от имени роли сервиса, поэтому созданные ею объекты принадлежат сервису, а саму её можно удалить без ошибок. - **`delete_all_versions` у секретов.** Без него удалённый сервис оставлял бы в Vault восстановимые версии своих ключей. - **`prevent_destroy` у бакета со state.** В нём лежит state той самой конфигурации, которая им управляет. - **Политика `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`.