From 64be99308b6c57c6c5f214b310c2a35cef246164 Mon Sep 17 00:00:00 2001 From: stolzor Date: Sun, 11 Oct 2026 20:30:21 +0300 Subject: [PATCH] Initial commit --- .env.example | 5 + .github/workflows/terraform.yml | 26 +++ .gitignore | 16 ++ README.md | 290 ++++++++++++++++++++++++++++++ compose.services.yml | 71 ++++++++ config.yaml | 43 +++++ config/vault/vault.hcl | 14 ++ database/.terraform.lock.hcl | 65 +++++++ database/locals.tf | 13 ++ database/main.tf | 73 ++++++++ database/output.tf | 4 + database/provider.tf | 13 ++ database/variables.tf | 11 ++ database/versions.tf | 19 ++ docker-compose.yml | 5 + examples/go-service/Dockerfile | 11 ++ examples/go-service/compose.yml | 22 +++ examples/go-service/go.mod | 50 ++++++ examples/go-service/go.sum | 116 ++++++++++++ examples/go-service/main.go | 134 ++++++++++++++ mise-env.sh | 9 + mise-tasks/example/up | 33 ++++ mise-tasks/render | 46 +++++ mise-tasks/tf | 16 ++ mise-tasks/vault/app-check | 136 ++++++++++++++ mise-tasks/vault/backup | 19 ++ mise-tasks/vault/bootstrap | 108 +++++++++++ mise.toml | 47 +++++ modules/app-database/locals.tf | 5 + modules/app-database/main.tf | 42 +++++ modules/app-database/output.tf | 9 + modules/app-database/variables.tf | 10 ++ modules/app-database/versions.tf | 12 ++ modules/app-storage/main.tf | 71 ++++++++ modules/app-storage/output.tf | 15 ++ modules/app-storage/variables.tf | 13 ++ modules/app-storage/versions.tf | 8 + modules/config/output.tf | 29 +++ modules/config/variables.tf | 60 +++++++ storage/.terraform.lock.hcl | 44 +++++ storage/locals.tf | 13 ++ storage/main.tf | 66 +++++++ storage/output.tf | 4 + storage/provider.tf | 10 ++ storage/variables.tf | 11 ++ storage/versions.tf | 15 ++ vault/.terraform.lock.hcl | 23 +++ vault/locals.tf | 9 + vault/main.tf | 75 ++++++++ vault/output.tf | 4 + vault/provider.tf | 4 + vault/variables.tf | 5 + vault/versions.tf | 11 ++ 53 files changed, 1983 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/terraform.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 compose.services.yml create mode 100644 config.yaml create mode 100644 config/vault/vault.hcl create mode 100644 database/.terraform.lock.hcl create mode 100644 database/locals.tf create mode 100644 database/main.tf create mode 100644 database/output.tf create mode 100644 database/provider.tf create mode 100644 database/variables.tf create mode 100644 database/versions.tf create mode 100644 docker-compose.yml create mode 100644 examples/go-service/Dockerfile create mode 100644 examples/go-service/compose.yml create mode 100644 examples/go-service/go.mod create mode 100644 examples/go-service/go.sum create mode 100644 examples/go-service/main.go create mode 100644 mise-env.sh create mode 100755 mise-tasks/example/up create mode 100755 mise-tasks/render create mode 100755 mise-tasks/tf create mode 100755 mise-tasks/vault/app-check create mode 100755 mise-tasks/vault/backup create mode 100755 mise-tasks/vault/bootstrap create mode 100644 mise.toml create mode 100644 modules/app-database/locals.tf create mode 100644 modules/app-database/main.tf create mode 100644 modules/app-database/output.tf create mode 100644 modules/app-database/variables.tf create mode 100644 modules/app-database/versions.tf create mode 100644 modules/app-storage/main.tf create mode 100644 modules/app-storage/output.tf create mode 100644 modules/app-storage/variables.tf create mode 100644 modules/app-storage/versions.tf create mode 100644 modules/config/output.tf create mode 100644 modules/config/variables.tf create mode 100644 storage/.terraform.lock.hcl create mode 100644 storage/locals.tf create mode 100644 storage/main.tf create mode 100644 storage/output.tf create mode 100644 storage/provider.tf create mode 100644 storage/variables.tf create mode 100644 storage/versions.tf create mode 100644 vault/.terraform.lock.hcl create mode 100644 vault/locals.tf create mode 100644 vault/main.tf create mode 100644 vault/output.tf create mode 100644 vault/provider.tf create mode 100644 vault/variables.tf create mode 100644 vault/versions.tf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..64bafbe --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +POSTGRES_PASSWORD= +PGADMIN_PASSWORD= +SILO_PASSWORD= + +VAULT_TOKEN= diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 0000000..444c75d --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,26 @@ +name: terraform + +on: + pull_request: + push: + branches: [master] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.16.5 + + - name: fmt + run: terraform fmt -check -recursive modules vault storage database + + - name: validate + run: | + for dir in vault storage database; do + terraform -chdir=$dir init -backend=false -input=false + terraform -chdir=$dir validate + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdf522e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# some data +data/* +vault-deploy/local-config/* + +# secrets +.env +vault_keys.txt + +# generated from config.yaml by mise run render +.env.config +backend.hcl + +# terraform +.terraform/ +*.tfstate* +*.tfvars diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d97554 --- /dev/null +++ b/README.md @@ -0,0 +1,290 @@ +# 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`. diff --git a/compose.services.yml b/compose.services.yml new file mode 100644 index 0000000..8f0ddf1 --- /dev/null +++ b/compose.services.yml @@ -0,0 +1,71 @@ +services: + postgres: + image: ${POSTGRES_IMAGE} + restart: always + ports: + - "127.0.0.1:${POSTGRES_PORT}:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - ./data/postgres:/var/lib/postgresql/data/pgdata + networks: + - infra + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 5s + timeout: 3s + retries: 10 + + pgadmin: + image: ${PGADMIN_IMAGE} + restart: always + ports: + - "127.0.0.1:${PGADMIN_PORT}:80" + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} + PGADMIN_CONFIG_SERVER_MODE: 'False' + volumes: + - ./data/pgadmin:/var/lib/pgadmin + networks: + - infra + depends_on: + postgres: + condition: service_healthy + + object-storage: + image: ${SILO_IMAGE} + restart: always + ports: + - "127.0.0.1:${SILO_STORAGE_PORT}:9000" + - "127.0.0.1:${SILO_WEB_PORT}:9001" + environment: + MINIO_ROOT_USER: ${SILO_USER} + MINIO_ROOT_PASSWORD: ${SILO_PASSWORD} + command: server /data --console-address ":9001" + volumes: + - ./data/s3:/data + networks: + - infra + + vault: + image: ${VAULT_IMAGE} + restart: always + command: server + cap_add: + - IPC_LOCK + ports: + - "127.0.0.1:${VAULT_PORT}:8200" + environment: + BAO_ADDR: http://127.0.0.1:8200 + volumes: + - ./config/vault:/openbao/config:ro + - ./data/vault:/openbao/data + networks: + - infra + +networks: + infra: + driver: bridge diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..7fc379b --- /dev/null +++ b/config.yaml @@ -0,0 +1,43 @@ +infra: + postgres: + image: pgvector/pgvector:pg18 + admin_user: admin + host: localhost + port: 5455 + internal_host: postgres + internal_port: 5432 + pgadmin: + image: dpage/pgadmin4:9.18.0 + port: 5050 + email: admin@example.com + silo: + image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z + admin_user: admin + host: localhost + port: 9000 + console_port: 9001 + internal_host: object-storage + internal_port: 9000 + state_bucket: terraform-state + backup_bucket: infra-backups + vault: + image: openbao/openbao:2.7 + host: 127.0.0.1 + port: 8200 + mount: secrets + database_mount: database + +services: + app: + postgres: + username: app + name: app + pgvector: true + buckets: + app-files: + versioning: true + noncurrent_days: 30 + worker: + buckets: + worker-tmp: + versioning: false diff --git a/config/vault/vault.hcl b/config/vault/vault.hcl new file mode 100644 index 0000000..9d55c6b --- /dev/null +++ b/config/vault/vault.hcl @@ -0,0 +1,14 @@ +storage "raft" { + path = "/openbao/data" + node_id = "node1" +} + +listener "tcp" { + address = "0.0.0.0:8200" + tls_disable = true +} + +api_addr = "http://127.0.0.1:8200" +cluster_addr = "http://127.0.0.1:8201" +ui = true +disable_mlock = true diff --git a/database/.terraform.lock.hcl b/database/.terraform.lock.hcl new file mode 100644 index 0000000..930fbf3 --- /dev/null +++ b/database/.terraform.lock.hcl @@ -0,0 +1,65 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cyrilgdn/postgresql" { + version = "1.27.0" + constraints = "1.27.0" + hashes = [ + "h1:OLbc15TOdKw2afn+M5H8MjQQRbYbEpiWDSj7yHg6iEw=", + "zh:0d6fa6f12393f19b95652932c1a53984096bca2dfe27ce8c50f2f7881bae1b75", + "zh:1c962ce9e73d96b368354607fd30dc55313c39f03f7ca5f3dea64e143b4f7c06", + "zh:289b4a93b310d698914025bd0990f5c7e1eef21ce1db171ae3bd2970e69ea302", + "zh:36960ffa44073d0fd71ed37d0d5e65e981d9ac009e69791c874a8903f06d7cfc", + "zh:3936aa067c45bf8c2867bd5753f918c64b4d4214d29c999422343a8848ef25df", + "zh:69c15b2dfa6e013fd9317a6edb3e5fa85f5bd32d581174ff8c6ec9ebbb88a31e", + "zh:74b990f51f92035a6a5cd5f1ba8a427c5781bd5d2531f5dffb704325364459a2", + "zh:91d45dbd0593a1f53c4cf4198a999545961937893cf16e2347b4e64bccbb626a", + "zh:a50ed48b61d1745f9c185174a56c849c88413ea1adc7409d07947853468d8663", + "zh:bc7cbe107bfcf9b57ba3f2ef5e64b205117d09b50528834250772b8c548fdfe3", + "zh:c91ee6aa986bb89f69737354ea422292d39fd18410da92ba951e1203fbd9c3ef", + "zh:f8cfdb72240d759d7a20f05cd098edbdd44f3b97a6067d7ea2ce29910356d27a", + "zh:fa08178b78d23158945483fbdadee90aab7a741089600de27ec19b457d8c6928", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.1" + constraints = "3.9.1" + hashes = [ + "h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=", + "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7", + "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26", + "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2", + "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1", + "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb", + "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f", + "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa", + "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097", + "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b", + "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc", + "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c", + ] +} + +provider "registry.terraform.io/hashicorp/vault" { + version = "5.12.0" + constraints = "5.12.0" + hashes = [ + "h1:NXnrOFre4t8nj7+ZQ3MKIECpDohjMBtHWm2aen75nfU=", + "zh:0683ab1870c6ffe78463465fc0954ff18fbbfe16d79dc121e20efb27cd2be850", + "zh:0ade50207774b544995066be02cf7e108abe828e089eb646da4c04863cbee702", + "zh:2b3360bec8d457a7d19df86bf4576711035785928a9fa307affce10cbeae89fd", + "zh:5317888b0e3547341b8434f42bbb8df2d30bdd95cc1bde9a4c7a80d266fe9f6b", + "zh:6c0271736ceb08774bebedf424735d92c4fe33b8e4db371cc027cbded0439a2e", + "zh:70bb990d22d1ce1ceac2209aee025e0f7deab48d95002aa2b8360d9c86343ea8", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7f33d7bbf145e15542cfc6b1dbeed639d08204802c239c098770c5f507d1e59c", + "zh:8ae8ca31d3c9e4b530c80eed12d8debe6fc46b407acb469b33247227aaafbf51", + "zh:c61fcc4d714cefee0696e2f8bf715b5826099e405f66acc859e496de688b6a8e", + "zh:ceca8db980ae3bd986142903487d75d143edfc5c7cf467da38f3698f717db046", + "zh:e1bba3cb6a728e9bf2951139f1e7bae5bec37a37cd4bfebe282e306212a62c4c", + "zh:f9765b4aa90e9e4eae03626ebfdfeda54954ec2552267aedce1fb2643f6eb2de", + ] +} diff --git a/database/locals.tf b/database/locals.tf new file mode 100644 index 0000000..a6af063 --- /dev/null +++ b/database/locals.tf @@ -0,0 +1,13 @@ +locals { + config = yamldecode(file("${path.root}/../config.yaml")) +} + +module "config" { + source = "../modules/config" + infra = local.config.infra + services = local.config.services +} + +locals { + databases = { for name, svc in module.config.services : name => svc.postgres if svc.postgres != null } +} diff --git a/database/main.tf b/database/main.tf new file mode 100644 index 0000000..5980a61 --- /dev/null +++ b/database/main.tf @@ -0,0 +1,73 @@ +module "app_database" { + source = "../modules/app-database" + for_each = local.databases + service = { name = each.key, database = each.value } +} + +resource "vault_kv_secret_v2" "postgres" { + for_each = local.databases + mount = module.config.vault.mount + name = "${each.key}/postgres" + delete_all_versions = true + data_json = jsonencode(merge(module.app_database[each.key].credentials, { + host = module.config.postgres.internal_host + port = module.config.postgres.internal_port + sslmode = each.value.sslmode + })) +} + +resource "random_password" "vault" { + length = 32 + special = false +} + +resource "postgresql_role" "vault" { + name = "vault" + login = true + create_role = true + password = random_password.vault.result + + lifecycle { + ignore_changes = [roles] + } +} + +resource "postgresql_grant_role" "vault" { + for_each = local.databases + role = postgresql_role.vault.name + grant_role = each.value.username + with_admin_option = true + + depends_on = [module.app_database] +} + +resource "vault_database_secrets_mount" "postgres" { + path = module.config.vault.database_mount + + postgresql { + name = "postgres" + connection_url = "postgresql://{{username}}:{{password}}@${module.config.postgres.internal_host}:${module.config.postgres.internal_port}/postgres?sslmode=disable" + username = postgresql_role.vault.name + password = random_password.vault.result + allowed_roles = keys(local.databases) + } +} + +resource "vault_database_secret_backend_role" "service" { + for_each = local.databases + backend = vault_database_secrets_mount.postgres.path + name = each.key + db_name = vault_database_secrets_mount.postgres.postgresql[0].name + + creation_statements = [ + "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' IN ROLE \"${each.value.username}\";", + "ALTER ROLE \"{{name}}\" SET role = \"${each.value.username}\";", + ] + revocation_statements = [ + "DROP ROLE IF EXISTS \"{{name}}\";", + ] + default_ttl = 3600 + max_ttl = 86400 + + depends_on = [postgresql_grant_role.vault] +} diff --git a/database/output.tf b/database/output.tf new file mode 100644 index 0000000..d21e651 --- /dev/null +++ b/database/output.tf @@ -0,0 +1,4 @@ +output "vault_secrets" { + value = { for name, secret in vault_kv_secret_v2.postgres : name => secret.path } + description = "vault paths of the postgres credentials of the services" +} diff --git a/database/provider.tf b/database/provider.tf new file mode 100644 index 0000000..a7c1df3 --- /dev/null +++ b/database/provider.tf @@ -0,0 +1,13 @@ +provider "vault" { + address = module.config.vault.address + token = var.vault_token +} + +provider "postgresql" { + host = module.config.postgres.host + port = module.config.postgres.port + username = module.config.postgres.admin_user + password = var.postgres_password + sslmode = "disable" + connect_timeout = 15 +} diff --git a/database/variables.tf b/database/variables.tf new file mode 100644 index 0000000..c6a17e4 --- /dev/null +++ b/database/variables.tf @@ -0,0 +1,11 @@ +variable "postgres_password" { + description = "password of the postgres administrator" + type = string + sensitive = true +} + +variable "vault_token" { + description = "vault token" + type = string + sensitive = true +} diff --git a/database/versions.tf b/database/versions.tf new file mode 100644 index 0000000..617e990 --- /dev/null +++ b/database/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_providers { + postgresql = { + source = "cyrilgdn/postgresql" + version = "1.27.0" + } + random = { + source = "hashicorp/random" + version = "3.9.1" + } + vault = { + source = "hashicorp/vault" + version = "5.12.0" + } + } + backend "s3" { + key = "database/terraform.tfstate" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0cf842b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,5 @@ +include: + - path: compose.services.yml + env_file: + - .env + - .env.config diff --git a/examples/go-service/Dockerfile b/examples/go-service/Dockerfile new file mode 100644 index 0000000..87b6164 --- /dev/null +++ b/examples/go-service/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.27-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /service . + +FROM alpine:3 +COPY --from=build /service /service +USER nobody +ENTRYPOINT ["/service"] diff --git a/examples/go-service/compose.yml b/examples/go-service/compose.yml new file mode 100644 index 0000000..80136b2 --- /dev/null +++ b/examples/go-service/compose.yml @@ -0,0 +1,22 @@ +name: infra-example + +services: + example: + build: . + environment: + VAULT_ADDR: http://vault:8200 + VAULT_ROLE_ID: ${VAULT_ROLE_ID:-} + VAULT_SECRET_ID: ${VAULT_SECRET_ID:-} + VAULT_KV_MOUNT: ${VAULT_MOUNT:-secrets} + VAULT_DB_MOUNT: ${VAULT_DATABASE_MOUNT:-database} + SERVICE_NAME: ${SERVICE_NAME:-} + POSTGRES_CREDS: ${POSTGRES_CREDS:-static} + ports: + - "127.0.0.1:${EXAMPLE_PORT:-8090}:8080" + networks: + - infra + +networks: + infra: + external: true + name: ${INFRA_NETWORK:-infra_infra} diff --git a/examples/go-service/go.mod b/examples/go-service/go.mod new file mode 100644 index 0000000..e7185a7 --- /dev/null +++ b/examples/go-service/go.mod @@ -0,0 +1,50 @@ +module example/go-service + +go 1.25.0 + +require ( + github.com/hashicorp/vault/api v1.23.0 + github.com/hashicorp/vault/api/auth/approle v0.12.0 + github.com/jackc/pgx/v5 v5.11.0 + github.com/minio/minio-go/v7 v7.3.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.12.0 // indirect + gopkg.in/ini.v1 v1.67.3 // indirect +) diff --git a/examples/go-service/go.sum b/examples/go-service/go.sum new file mode 100644 index 0000000..e457c40 --- /dev/null +++ b/examples/go-service/go.sum @@ -0,0 +1,116 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= +github.com/hashicorp/vault/api/auth/approle v0.12.0 h1:PhF7jrQjydK1DC05EboosXmZg31GDUIKL8bjyilsJ+E= +github.com/hashicorp/vault/api/auth/approle v0.12.0/go.mod h1:J7BJLpXeQXhuMAWi31Puunu5QOeCoRAgLh2iDti7OLA= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.3.0 h1:HM4pFCSQq/TK+j0/zmorSh5ddh81iDgRgU0BG0Vz/YU= +github.com/minio/minio-go/v7 v7.3.0/go.mod h1:KUPWdecEO1LWyUz+sTGXAuf2jZHrPh5fCsRH86QbPfk= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= +gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/go-service/main.go b/examples/go-service/main.go new file mode 100644 index 0000000..739eace --- /dev/null +++ b/examples/go-service/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" + + vault "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/api/auth/approle" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +func main() { + ctx := context.Background() + service := mustEnv("SERVICE_NAME") + kvMount := mustEnv("VAULT_KV_MOUNT") + + client, err := vault.NewClient(vault.DefaultConfig()) + check(err, "клиент Vault") + auth, err := approle.NewAppRoleAuth(mustEnv("VAULT_ROLE_ID"), &approle.SecretID{FromEnv: "VAULT_SECRET_ID"}) + check(err, "AppRole") + login, err := client.Auth().Login(ctx, auth) + check(err, "вход в Vault") + log.Printf("vault: вошёл, политики %v, токен на %dс", login.Auth.Policies, login.Auth.LeaseDuration) + + kv := client.KVv2(kvMount) + s3Secret, err := kv.Get(ctx, service+"/s3") + check(err, "секрет s3") + pgSecret, err := kv.Get(ctx, service+"/postgres") + check(err, "секрет postgres") + + pgUser, pgPassword := str(pgSecret.Data, "username"), str(pgSecret.Data, "password") + if os.Getenv("POSTGRES_CREDS") == "dynamic" { + creds, err := client.Logical().ReadWithContext(ctx, mustEnv("VAULT_DB_MOUNT")+"/creds/"+service) + check(err, "временные учётные данные") + pgUser, pgPassword = str(creds.Data, "username"), str(creds.Data, "password") + log.Printf("postgres: временная роль %s на %dс", pgUser, creds.LeaseDuration) + } + dsn := url.URL{ + Scheme: "postgres", + User: url.UserPassword(pgUser, pgPassword), + Host: fmt.Sprintf("%s:%s", str(pgSecret.Data, "host"), str(pgSecret.Data, "port")), + Path: str(pgSecret.Data, "database"), + RawQuery: "sslmode=" + str(pgSecret.Data, "sslmode"), + } + db, err := pgxpool.New(ctx, dsn.String()) + check(err, "пул postgres") + defer db.Close() + check(db.Ping(ctx), "подключение к postgres") + log.Printf("postgres: подключился к %s как %s", dsn.Path, pgUser) + + endpoint, err := url.Parse(str(s3Secret.Data, "endpoint")) + check(err, "endpoint s3") + s3, err := minio.New(endpoint.Host, &minio.Options{ + Creds: credentials.NewStaticV4(str(s3Secret.Data, "access_key"), str(s3Secret.Data, "secret_key"), ""), + Secure: endpoint.Scheme == "https", + }) + check(err, "клиент s3") + buckets := strings.Split(str(s3Secret.Data, "buckets"), ",") + log.Printf("s3: бакеты %v на %s", buckets, endpoint.Host) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + status := map[string]any{"service": service} + + var sessionUser, currentUser, database string + err := db.QueryRow(ctx, "select session_user, current_user, current_database()"). + Scan(&sessionUser, ¤tUser, &database) + if err != nil { + status["postgres"] = map[string]string{"error": err.Error()} + } else { + status["postgres"] = map[string]string{ + "database": database, "session_user": sessionUser, "current_user": currentUser, + } + } + + objects := map[string]any{} + for _, bucket := range buckets { + count := 0 + var listErr error + for object := range s3.ListObjects(ctx, bucket, minio.ListObjectsOptions{Recursive: true}) { + if object.Err != nil { + listErr = object.Err + break + } + count++ + } + if listErr != nil { + objects[bucket] = map[string]string{"error": listErr.Error()} + } else { + objects[bucket] = map[string]int{"objects": count} + } + } + status["s3"] = objects + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) + }) + + log.Print("http: слушаю :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func mustEnv(name string) string { + value := os.Getenv(name) + if value == "" { + log.Fatalf("не задана переменная окружения %s", name) + } + return value +} + +func check(err error, what string) { + if err != nil { + log.Fatalf("%s: %v", what, err) + } +} + +func str(data map[string]any, key string) string { + value, ok := data[key] + if !ok { + log.Fatalf("в секрете нет поля %s", key) + } + return fmt.Sprint(value) +} diff --git a/mise-env.sh b/mise-env.sh new file mode 100644 index 0000000..eea6de8 --- /dev/null +++ b/mise-env.sh @@ -0,0 +1,9 @@ +root=$(dirname "${BASH_SOURCE[0]}") + +if [[ ! -f $root/.env.config || ! -f $root/backend.hcl || $root/config.yaml -nt $root/.env.config ]]; then + "$root/mise-tasks/render" >&2 +fi + +set -a +. "$root/.env.config" +set +a diff --git a/mise-tasks/example/up b/mise-tasks/example/up new file mode 100755 index 0000000..881eb33 --- /dev/null +++ b/mise-tasks/example/up @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +#MISE description="Запустить пример Go-сервиса: mise run example:up [сервис] [static|dynamic]" +set -euo pipefail + +roles=$(terraform -chdir=vault output -json approle_role_ids) +service=${1:-$(jq -r 'keys[0]' <<<"$roles")} +creds=${2:-static} +compose=(docker compose -f examples/go-service/compose.yml) +url=http://127.0.0.1:${EXAMPLE_PORT:-8090}/ + +role_id=$(jq -er --arg s "$service" '.[$s]' <<<"$roles") || + { echo "нет роли AppRole для сервиса $service" >&2; exit 1; } +secret_id=$(curl -sS -X POST -H "X-Vault-Token: $VAULT_TOKEN" -d '{"ttl": "5m", "num_uses": 1}' \ + "$VAULT_ADDR/v1/auth/approle/role/$service/secret-id" | jq -er .data.secret_id) + +export SERVICE_NAME=$service VAULT_ROLE_ID=$role_id VAULT_SECRET_ID=$secret_id POSTGRES_CREDS=$creds +INFRA_NETWORK=$(docker compose config --format json | jq -er .networks.infra.name) +export INFRA_NETWORK +"${compose[@]}" up -d --build --force-recreate --quiet-pull 2>&1 | tail -3 + +for _ in $(seq 1 30); do + if response=$(curl -fsS "$url" 2>/dev/null); then + "${compose[@]}" logs --no-log-prefix example + printf '\nGET %s\n' "$url" + jq . <<<"$response" + exit 0 + fi + sleep 1 +done + +"${compose[@]}" logs --no-log-prefix example +echo "сервис не ответил на $url" >&2 +exit 1 diff --git a/mise-tasks/render b/mise-tasks/render new file mode 100755 index 0000000..e632d18 --- /dev/null +++ b/mise-tasks/render @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +#MISE description="Сгенерировать .env.config и backend.hcl из config.yaml" +set -euo pipefail + +cd "$(dirname "$0")/.." + +INFRA=$(echo 'jsonencode(yamldecode(file("config.yaml")).infra)' | terraform console | jq -r .) +get() { jq -er "$1" <<<"$INFRA" || { echo "в config.yaml нет infra$1" >&2; exit 1; }; } + +cat >.env.config.tmp <backend.hcl.tmp < <команда terraform> [аргументы]" >&2 + exit 1 +} +dir=$1 +shift + +if [[ $1 == init ]]; then + shift + exec terraform -chdir="$dir" init -backend-config=../backend.hcl "$@" +fi +exec terraform -chdir="$dir" "$@" diff --git a/mise-tasks/vault/app-check b/mise-tasks/vault/app-check new file mode 100755 index 0000000..84aa330 --- /dev/null +++ b/mise-tasks/vault/app-check @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +#MISE description="Проверка глазами сервиса: вход по AppRole, секреты из Vault, доступ к S3 и Postgres" +set -euo pipefail + +KV=$VAULT_MOUNT +DB=$VAULT_DATABASE_MOUNT + +step() { printf '\n== %s\n' "$*"; } +ok() { printf ' ok: %s\n' "$*"; } +fail() { + printf ' ОШИБКА: %s\n' "$*" >&2 + exit 1 +} + +vault_api() { + local method=$1 path=$2 token=${3:-} body=${4:-} + curl -sS -X "$method" -w '\n%{http_code}' \ + ${token:+-H "X-Vault-Token: $token"} \ + ${body:+-d "$body"} \ + "$VAULT_ADDR/v1/$path" +} +code() { tail -n1 <<<"$1"; } +body() { sed '$d' <<<"$1"; } + +psql_as() { + docker compose exec -T -e PGPASSWORD="$2" postgres \ + psql -h postgres -U "$1" -d "$3" -Atc "select session_user || ' работает как ' || current_user" 2>&1 | tail -1 || true +} + +token="" +revoke_token() { + [[ -z "$token" ]] || vault_api POST auth/token/revoke-self "$token" >/dev/null 2>&1 || true + token="" +} +trap revoke_token EXIT + +admin_token=$VAULT_TOKEN +ROLES=$(terraform -chdir=vault output -json approle_role_ids) +mapfile -t ALL < <(jq -r 'keys[]' <<<"$ROLES") +if [[ $# -gt 0 ]]; then SERVICES=("$@"); else SERVICES=("${ALL[@]}"); fi + +for svc in "${SERVICES[@]}"; do + printf '\n################ %s\n' "$svc" + + role_id=$(jq -r --arg s "$svc" '.[$s] // empty' <<<"$ROLES") + [[ -n "$role_id" ]] || fail "нет роли AppRole для $svc" + secret_id=$(vault_api POST "auth/approle/role/$svc/secret-id" "$admin_token" '{"ttl": "5m", "num_uses": 1}' | + sed '$d' | jq -r .data.secret_id) + + step "1. Вход через AppRole" + r=$(vault_api POST auth/approle/login "" "$(jq -n --arg r "$role_id" --arg s "$secret_id" '{role_id: $r, secret_id: $s}')") + [[ $(code "$r") == 200 ]] || fail "вход не удался: $(body "$r")" + token=$(body "$r" | jq -r .auth.client_token) + ok "токен получен, политики: $(body "$r" | jq -r '.auth.policies | join(", ")'), ttl: $(body "$r" | jq -r .auth.lease_duration)s" + + step "2. Ключи S3 из $KV/$svc/s3" + r=$(vault_api GET "$KV/data/$svc/s3" "$token") + if [[ $(code "$r") == 200 ]]; then + s3=$(body "$r" | jq .data.data) + ok "поля: $(jq -r 'keys | join(", ")' <<<"$s3")" + cfg=/tmp/app-check-$$ + docker compose exec -T object-storage mc --config-dir "$cfg" alias set app http://localhost:9000 \ + "$(jq -r .access_key <<<"$s3")" "$(jq -r .secret_key <<<"$s3")" >/dev/null + IFS=, read -ra own <<<"$(jq -r .buckets <<<"$s3")" + for b in "${own[@]}"; do + docker compose exec -T object-storage mc --config-dir "$cfg" ls "app/$b" >/dev/null 2>&1 || fail "свой бакет $b недоступен" + ok "бакет $b доступен" + done + docker compose exec -T object-storage rm -rf "$cfg" + else + ok "секрета нет (у сервиса нет бакетов), HTTP $(code "$r")" + fi + + step "3. Постоянные учётные данные Postgres из $KV/$svc/postgres" + r=$(vault_api GET "$KV/data/$svc/postgres" "$token") + own_db="" + if [[ $(code "$r") == 200 ]]; then + pg=$(body "$r" | jq .data.data) + own_db=$(jq -r .database <<<"$pg") + ok "поля: $(jq -r 'keys | join(", ")' <<<"$pg")" + out=$(psql_as "$(jq -r .username <<<"$pg")" "$(jq -r .password <<<"$pg")" "$own_db") + [[ "$out" == *"работает как"* ]] || fail "не подключился к $own_db: $out" + ok "$own_db: $out" + else + ok "секрета нет (у сервиса нет базы), HTTP $(code "$r")" + fi + + step "4. Временные учётные данные Postgres из $DB/creds/$svc" + if [[ -n "$own_db" ]]; then + r=$(vault_api GET "$DB/creds/$svc" "$token") + [[ $(code "$r") == 200 ]] || fail "Vault не выдал временную роль: $(body "$r")" + dyn_user=$(body "$r" | jq -r .data.username) + dyn_pass=$(body "$r" | jq -r .data.password) + ok "выдана роль $dyn_user на $(body "$r" | jq -r .lease_duration)s" + out=$(psql_as "$dyn_user" "$dyn_pass" "$own_db") + [[ "$out" == *"работает как"* ]] || fail "временная роль не подключилась к $own_db: $out" + ok "$own_db: $out" + for other in $(docker compose exec -T postgres psql -U "$POSTGRES_USER" -d postgres -Atc \ + "select datname from pg_database where datdba <> 10 and datname <> '$own_db'"); do + out=$(psql_as "$dyn_user" "$dyn_pass" "$other") + [[ "$out" == *"CONNECT privilege"* ]] || fail "временная роль попала в чужую базу $other: $out" + ok "$other: отказ, нет права CONNECT" + done + else + ok "пропущено, у сервиса нет базы" + fi + + step "5. Чужие секреты" + for path in "$KV/data/infra/postgres" "$KV/data/infra/silo"; do + [[ $(code "$(vault_api GET "$path" "$token")") == 403 ]] || fail "сервис прочитал $path" + ok "$path: 403" + done + for other in "${ALL[@]}"; do + [[ "$other" == "$svc" ]] && continue + for path in "$KV/data/$other/s3" "$KV/data/$other/postgres" "$DB/creds/$other"; do + [[ $(code "$(vault_api GET "$path" "$token")") == 403 ]] || fail "сервис прочитал $path" + ok "$path: 403" + done + done + + step "6. Отзыв токена" + old_token=$token + revoke_token + [[ $(code "$(vault_api GET "$KV/data/$svc/s3" "$old_token")") == 403 ]] || fail "токен всё ещё работает" + ok "токен больше не принимается" + if [[ -n "${dyn_user:-}" ]]; then + sleep 2 + left=$(docker compose exec -T postgres psql -U "$POSTGRES_USER" -d postgres -Atc \ + "select count(*) from pg_roles where rolname = '$dyn_user'") + [[ "$left" == 0 ]] || fail "временная роль $dyn_user осталась в Postgres" + ok "временная роль $dyn_user удалена вместе с токеном" + dyn_user="" + fi +done + +printf '\nВсе проверки пройдены.\n' diff --git a/mise-tasks/vault/backup b/mise-tasks/vault/backup new file mode 100755 index 0000000..1855a5d --- /dev/null +++ b/mise-tasks/vault/backup @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +#MISE description="Снапшот Vault в бакет бэкапов" +set -euo pipefail + +BUCKET=$BACKUP_BUCKET + +NAME="vault/$(date -u +%Y-%m-%dT%H-%M-%SZ).snap" +TMP=/tmp/vault-backup.snap + +docker compose exec -T -e BAO_TOKEN="$VAULT_TOKEN" vault \ + bao operator raft snapshot save "$TMP" >/dev/null + +docker compose exec -T vault cat "$TMP" | + docker compose exec -T -e MC_HOST_local="http://${SILO_USER}:${SILO_PASSWORD}@localhost:9000" \ + object-storage mc pipe --quiet "local/$BUCKET/$NAME" >/dev/null +docker compose exec -T vault rm -f "$TMP" + +docker compose exec -T -e MC_HOST_local="http://${SILO_USER}:${SILO_PASSWORD}@localhost:9000" \ + object-storage mc stat "local/$BUCKET/$NAME" | grep -E '^(Name|Size)' diff --git a/mise-tasks/vault/bootstrap b/mise-tasks/vault/bootstrap new file mode 100755 index 0000000..eb9f684 --- /dev/null +++ b/mise-tasks/vault/bootstrap @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +#MISE description="Токен Terraform для Vault, проверка политик, отзыв root-токена (нужен ROOT_TOKEN)" +set -euo pipefail + +MOUNT=$VAULT_MOUNT +ENV_FILE=.env +KEYS_FILE=vault_keys.txt +REVOKE_ROOT=false +[[ "${1:-}" == "--revoke-root" ]] && REVOKE_ROOT=true + +bao_as() { + local token=$1 + shift + docker compose exec -T -e BAO_TOKEN="$token" vault bao "$@" +} + +step() { printf '\n== %s\n' "$*"; } +ok() { printf ' ok: %s\n' "$*"; } +fail() { + printf ' ОШИБКА: %s\n' "$*" >&2 + exit 1 +} + +ROOT_TOKEN=${ROOT_TOKEN:-$(sed -n 's/^Initial Root Token: *//p' "$KEYS_FILE" 2>/dev/null || true)} +[[ -n "$ROOT_TOKEN" ]] || fail "нет root-токена: задайте ROOT_TOKEN или положите $KEYS_FILE" +bao_as "$ROOT_TOKEN" token lookup >/dev/null 2>&1 || fail "root-токен не принят (отозван или Vault запечатан)" + +step "Шаг 1. Токен для Terraform" +TF_TOKEN=${VAULT_TOKEN:-} +if [[ -n "$TF_TOKEN" ]] && bao_as "$TF_TOKEN" token lookup -format=json 2>/dev/null | + jq -e '.data.policies | index("terraform")' >/dev/null; then + ok "в $ENV_FILE уже есть рабочий токен, новый не создаю" +else + TF_TOKEN=$(bao_as "$ROOT_TOKEN" token create -orphan -policy=terraform -period=720h \ + -display-name=terraform -format=json | jq -r .auth.client_token) + [[ -n "$TF_TOKEN" && "$TF_TOKEN" != null ]] || fail "токен не создан" + sed -i '/^VAULT_TOKEN=/d' "$ENV_FILE" + printf 'VAULT_TOKEN=%s\n' "$TF_TOKEN" >>"$ENV_FILE" + ok "токен создан и записан в $ENV_FILE как VAULT_TOKEN" +fi +bao_as "$TF_TOKEN" token lookup -format=json | + jq -r '.data | " политики: \(.policies | join(", ")); orphan: \(.orphan); period: \(.period)s"' + +step "Шаг 2. terraform plan в vault/ с новым токеном" +set +e +TF_VAR_vault_token=$TF_TOKEN terraform -chdir=vault plan -input=false -no-color -detailed-exitcode >/tmp/vault-plan.$$ 2>&1 +PLAN_EXIT=$? +set -e +case $PLAN_EXIT in +0) ok "No changes: политики terraform хватает" ;; +2) + grep -E '^ # |^Plan:' /tmp/vault-plan.$$ || true + rm -f /tmp/vault-plan.$$ + fail "plan хочет что-то изменить, прочитайте его: terraform -chdir=vault plan" + ;; +*) + grep -vE 'Refreshing state' /tmp/vault-plan.$$ | tail -30 + rm -f /tmp/vault-plan.$$ + fail "plan упал, скорее всего в политике terraform не хватает пути из ошибки выше" + ;; +esac +rm -f /tmp/vault-plan.$$ + +step "Шаг 3. Границы сервисов через AppRole" +ROLES_JSON=$(terraform -chdir=vault output -json approle_role_ids) +mapfile -t SERVICES < <(jq -r 'keys[]' <<<"$ROLES_JSON") + +cleanup() { + for svc in "${SERVICES[@]}"; do + bao_as "$ROOT_TOKEN" kv metadata delete -mount="$MOUNT" "$svc/bootstrap-check" >/dev/null 2>&1 || true + done +} +trap cleanup EXIT + +for svc in "${SERVICES[@]}"; do + bao_as "$ROOT_TOKEN" kv put -mount="$MOUNT" "$svc/bootstrap-check" ok=1 >/dev/null +done + +for svc in "${SERVICES[@]}"; do + role_id=$(jq -r --arg s "$svc" '.[$s]' <<<"$ROLES_JSON") + secret_id=$(bao_as "$ROOT_TOKEN" write -f -field=secret_id "auth/approle/role/$svc/secret-id") + svc_token=$(bao_as "$ROOT_TOKEN" write -field=token auth/approle/login role_id="$role_id" secret_id="$secret_id") + + for target in "${SERVICES[@]}"; do + set +e + out=$(bao_as "$svc_token" kv get -mount="$MOUNT" "$target/bootstrap-check" 2>&1) + rc=$? + set -e + if [[ "$target" == "$svc" ]]; then + [[ $rc -eq 0 ]] || fail "$svc не читает свой путь $MOUNT/$target: $out" + ok "$svc читает $MOUNT/$target/*" + else + [[ $rc -ne 0 && "$out" == *"permission denied"* ]] || fail "$svc прочитал чужой путь $MOUNT/$target" + ok "$svc получает permission denied на $MOUNT/$target/*" + fi + done + bao_as "$svc_token" token revoke -self >/dev/null +done + +step "Шаг 4. Root-токен" +if $REVOKE_ROOT; then + cleanup + trap - EXIT + bao_as "$ROOT_TOKEN" token revoke -self >/dev/null + ok "root-токен отозван; новый выпускается через bao operator generate-root с unseal-ключом" +else + ok "не отозван (запустите с --revoke-root, когда будете готовы)" +fi diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..0c7a30a --- /dev/null +++ b/mise.toml @@ -0,0 +1,47 @@ +[env] +_.file = ".env" +_.source = "mise-env.sh" + +TF_VAR_vault_token = "{{ env.VAULT_TOKEN | default(value='') }}" +TF_VAR_s3_password = "{{ env.SILO_PASSWORD | default(value='') }}" +TF_VAR_postgres_password = "{{ env.POSTGRES_PASSWORD | default(value='') }}" +AWS_ACCESS_KEY_ID = "{{ env.SILO_USER | default(value='') }}" +AWS_SECRET_ACCESS_KEY = "{{ env.SILO_PASSWORD | default(value='') }}" + +[tasks.up] +description = "Поднять сервисы: Postgres, pgAdmin, Silo, Vault" +run = [ + "mkdir -p data/vault data/pgadmin", + "docker run --rm --user root --entrypoint chown -v ./data/vault:/openbao/data $VAULT_IMAGE openbao /openbao/data", + "docker run --rm --user root --entrypoint chown -v ./data/pgadmin:/var/lib/pgadmin $PGADMIN_IMAGE pgadmin /var/lib/pgadmin", + "docker compose up -d --wait", +] + +[tasks.down] +description = "Остановить сервисы (данные в data/ остаются, Vault после запуска будет запечатан)" +run = "docker compose down" + +[tasks.plan] +description = "terraform plan во всех трёх конфигурациях" +run = [ + "terraform -chdir=vault plan", + "terraform -chdir=storage plan", + "terraform -chdir=database plan", +] + +[tasks.fmt] +description = "Отформатировать все .tf" +run = "terraform fmt -recursive modules vault storage database" + +[tasks."example:down"] +description = "Остановить пример Go-сервиса" +run = "docker compose -f examples/go-service/compose.yml down" + +[tasks.check] +description = "Проверки без обращения к сервисам: форматирование и terraform validate" +run = [ + "terraform fmt -check -recursive modules vault storage database", + "terraform -chdir=vault validate", + "terraform -chdir=storage validate", + "terraform -chdir=database validate", +] diff --git a/modules/app-database/locals.tf b/modules/app-database/locals.tf new file mode 100644 index 0000000..e4d601e --- /dev/null +++ b/modules/app-database/locals.tf @@ -0,0 +1,5 @@ +locals { + databases = var.service.database == null ? {} : { + (var.service.database.name) = var.service.database + } +} diff --git a/modules/app-database/main.tf b/modules/app-database/main.tf new file mode 100644 index 0000000..4fcb248 --- /dev/null +++ b/modules/app-database/main.tf @@ -0,0 +1,42 @@ +resource "random_password" "this" { + for_each = local.databases + length = 32 + special = false +} + +resource "postgresql_role" "this" { + for_each = local.databases + name = each.value.username + login = true + password = random_password.this[each.key].result +} + +resource "postgresql_database" "this" { + for_each = local.databases + name = each.key + owner = postgresql_role.this[each.key].name +} + +resource "postgresql_extension" "vector" { + for_each = { for key, value in local.databases : key => value if value.pgvector } + name = "vector" + database = postgresql_database.this[each.key].name +} + +resource "postgresql_grant" "public" { + for_each = postgresql_database.this + database = each.value.name + role = "public" + object_type = "database" + privileges = [] +} + +resource "postgresql_grant" "owner" { + for_each = postgresql_database.this + database = each.value.name + role = postgresql_role.this[each.key].name + object_type = "database" + privileges = ["CONNECT", "CREATE", "TEMPORARY"] + + depends_on = [postgresql_grant.public] +} diff --git a/modules/app-database/output.tf b/modules/app-database/output.tf new file mode 100644 index 0000000..5e61a19 --- /dev/null +++ b/modules/app-database/output.tf @@ -0,0 +1,9 @@ +output "credentials" { + value = one([for name, db in postgresql_database.this : { + database = db.name + username = postgresql_role.this[name].name + password = random_password.this[name].result + }]) + description = "credentials of the service database, null if the service has no database" + sensitive = true +} diff --git a/modules/app-database/variables.tf b/modules/app-database/variables.tf new file mode 100644 index 0000000..0eff0b6 --- /dev/null +++ b/modules/app-database/variables.tf @@ -0,0 +1,10 @@ +variable "service" { + type = object({ + name = string + database = optional(object({ + name = string + username = string + pgvector = optional(bool, false) + })) + }) +} diff --git a/modules/app-database/versions.tf b/modules/app-database/versions.tf new file mode 100644 index 0000000..fc2bad5 --- /dev/null +++ b/modules/app-database/versions.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + postgresql = { + source = "cyrilgdn/postgresql" + version = ">= 1.27.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.9.1" + } + } +} diff --git a/modules/app-storage/main.tf b/modules/app-storage/main.tf new file mode 100644 index 0000000..1b427db --- /dev/null +++ b/modules/app-storage/main.tf @@ -0,0 +1,71 @@ +resource "minio_s3_bucket" "this" { + for_each = var.service.buckets + bucket = each.key + acl = "private" +} + +resource "minio_s3_bucket_versioning" "this" { + for_each = { for name, b in var.service.buckets : name => b if b.versioning } + bucket = minio_s3_bucket.this[each.key].bucket + + versioning_configuration { + status = "Enabled" + } +} + +resource "minio_ilm_policy" "this" { + for_each = var.service.buckets + bucket = minio_s3_bucket.this[each.key].bucket + + rule { + id = "clean-rule-1" + status = "Enabled" + dynamic "noncurrent_expiration" { + for_each = each.value.versioning ? [1] : [] + content { + days = "${each.value.noncurrent_days}d" + } + } + expired_object_delete_marker = each.value.versioning + abort_incomplete_multipart_upload { + days_after_initiation = "3d" + } + } +} + +resource "minio_iam_user" "this" { + name = var.service.name +} + +resource "minio_iam_policy" "get_upd_del_policy" { + name = var.service.name + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow", + Action = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], + Resource = [ + for bucket in minio_s3_bucket.this : "arn:aws:s3:::${bucket.bucket}/*" + ] + }, + { + Effect = "Allow", + Action = ["s3:ListBucket"], + Resource = [ + for bucket in minio_s3_bucket.this : "arn:aws:s3:::${bucket.bucket}" + ] + }] + }) +} + +resource "minio_iam_user_policy_attachment" "this" { + user_name = minio_iam_user.this.name + policy_name = minio_iam_policy.get_upd_del_policy.name +} + +resource "minio_iam_service_account" "this" { + target_user = minio_iam_user.this.name + description = "keys of ${var.service.name}" + + depends_on = [minio_iam_user_policy_attachment.this] +} diff --git a/modules/app-storage/output.tf b/modules/app-storage/output.tf new file mode 100644 index 0000000..a693ed6 --- /dev/null +++ b/modules/app-storage/output.tf @@ -0,0 +1,15 @@ +output "buckets" { + value = [for bucket in minio_s3_bucket.this : bucket.bucket] + description = "buckets of the service" +} + +output "access_key" { + value = minio_iam_service_account.this.access_key + description = "access key of the service" +} + +output "secret_key" { + value = minio_iam_service_account.this.secret_key + description = "secret key of the service" + sensitive = true +} diff --git a/modules/app-storage/variables.tf b/modules/app-storage/variables.tf new file mode 100644 index 0000000..e7c1f38 --- /dev/null +++ b/modules/app-storage/variables.tf @@ -0,0 +1,13 @@ +variable "service" { + type = object({ + name = string + buckets = map(object({ + versioning = optional(bool, true) + noncurrent_days = optional(number, 30) + })) + }) + validation { + condition = length(var.service.buckets) > 0 + error_message = "У сервиса должен быть хотя бы один бакет: IAM-политика с пустым списком Resource невалидна." + } +} diff --git a/modules/app-storage/versions.tf b/modules/app-storage/versions.tf new file mode 100644 index 0000000..760cf23 --- /dev/null +++ b/modules/app-storage/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + minio = { + source = "aminueza/minio" + version = ">= 3.44.0" + } + } +} diff --git a/modules/config/output.tf b/modules/config/output.tf new file mode 100644 index 0000000..026a951 --- /dev/null +++ b/modules/config/output.tf @@ -0,0 +1,29 @@ +output "infra" { + value = var.infra +} + +output "services" { + value = var.services +} + +output "vault" { + value = { + address = "http://${var.infra.vault.host}:${var.infra.vault.port}" + mount = var.infra.vault.mount + database_mount = var.infra.vault.database_mount + } +} + +output "silo" { + value = { + server = "${var.infra.silo.host}:${var.infra.silo.port}" + internal_endpoint = "http://${var.infra.silo.internal_host}:${var.infra.silo.internal_port}" + admin_user = var.infra.silo.admin_user + state_bucket = var.infra.silo.state_bucket + backup_bucket = var.infra.silo.backup_bucket + } +} + +output "postgres" { + value = var.infra.postgres +} diff --git a/modules/config/variables.tf b/modules/config/variables.tf new file mode 100644 index 0000000..ca9f599 --- /dev/null +++ b/modules/config/variables.tf @@ -0,0 +1,60 @@ +variable "infra" { + type = object({ + postgres = object({ + image = string + admin_user = string + host = string + port = number + internal_host = string + internal_port = number + }) + pgadmin = object({ + image = string + port = number + email = string + }) + silo = object({ + image = string + admin_user = string + host = string + port = number + console_port = number + internal_host = string + internal_port = number + state_bucket = string + backup_bucket = string + }) + vault = object({ + image = string + host = string + port = number + mount = string + database_mount = string + }) + }) +} + +variable "services" { + type = map(object({ + postgres = optional(object({ + username = string + name = string + sslmode = optional(string, "disable") + pgvector = optional(bool, false) + })) + buckets = optional(map(object({ + versioning = optional(bool, true) + noncurrent_days = optional(number, 30) + })), {}) + })) + + validation { + condition = alltrue(flatten([ + for svc in values(var.services) : [ + for name in keys(svc.buckets) : + can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", name)) + ] + ])) + error_message = "Имя бакета: 3–63 символа, строчные латинские буквы, цифры, точки и дефисы." + } +} diff --git a/storage/.terraform.lock.hcl b/storage/.terraform.lock.hcl new file mode 100644 index 0000000..7b1cad2 --- /dev/null +++ b/storage/.terraform.lock.hcl @@ -0,0 +1,44 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/aminueza/minio" { + version = "3.44.0" + constraints = "~> 3.44.0" + hashes = [ + "h1:kNYbcRnleLjMG5M3D9x5Edzvm4KZgNbItsRV19nP+pY=", + "zh:25d3b705f6dfcec6144d0d2e1535eaac06045f4e592311c8b13ccb4644855ca1", + "zh:2845cd40624a5fbf35cc71e709b817281887c65eb05c68da77e2e7b60ad2f379", + "zh:3d4bf825b616dfb01c4a0a9a48066a1d292dbbaf28bb2943a978b20339ed131c", + "zh:4346b7c5194621bed62a7ca66ef7ab1f28332aea45c96b7e66abe6ed74db8148", + "zh:452ea3862ff67b9ee6c626ce0bfd81e7af46bdcf7d187358edd8e0f262aa1337", + "zh:62649e83fd99cbb3ecbe33ea450fd5993b465fc241b607f865c64e2237f1dec8", + "zh:674380f7fc61b2aa96690811b352e4c9e410ad2417380782ead57984d673925f", + "zh:6d136207a60567c29d9669aee1784acb6ba7249791b37b015acac5881656c804", + "zh:7bc076b707f5222a8397a4fe3673feedf6caa1d32ef59b8ea7726c809ec3ba7f", + "zh:c5f003ee66a9698a09d5ee2ec6f9df213f230fc06f5803853b6712f3a995b018", + "zh:e46d7ccd969f1e5480aa0a2d7cfb7f49d0575696bcceeb69625fe11b8d704338", + "zh:e52a57394f23661a236e3578a28bd0656adddc336c2dafe2e7b573703c2b538e", + "zh:f40393f8fd1a7e05f39331c7ad9c63b59a3ccc5151598aecb2ea56eb36bc98c4", + ] +} + +provider "registry.terraform.io/hashicorp/vault" { + version = "5.12.0" + constraints = "5.12.0" + hashes = [ + "h1:NXnrOFre4t8nj7+ZQ3MKIECpDohjMBtHWm2aen75nfU=", + "zh:0683ab1870c6ffe78463465fc0954ff18fbbfe16d79dc121e20efb27cd2be850", + "zh:0ade50207774b544995066be02cf7e108abe828e089eb646da4c04863cbee702", + "zh:2b3360bec8d457a7d19df86bf4576711035785928a9fa307affce10cbeae89fd", + "zh:5317888b0e3547341b8434f42bbb8df2d30bdd95cc1bde9a4c7a80d266fe9f6b", + "zh:6c0271736ceb08774bebedf424735d92c4fe33b8e4db371cc027cbded0439a2e", + "zh:70bb990d22d1ce1ceac2209aee025e0f7deab48d95002aa2b8360d9c86343ea8", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7f33d7bbf145e15542cfc6b1dbeed639d08204802c239c098770c5f507d1e59c", + "zh:8ae8ca31d3c9e4b530c80eed12d8debe6fc46b407acb469b33247227aaafbf51", + "zh:c61fcc4d714cefee0696e2f8bf715b5826099e405f66acc859e496de688b6a8e", + "zh:ceca8db980ae3bd986142903487d75d143edfc5c7cf467da38f3698f717db046", + "zh:e1bba3cb6a728e9bf2951139f1e7bae5bec37a37cd4bfebe282e306212a62c4c", + "zh:f9765b4aa90e9e4eae03626ebfdfeda54954ec2552267aedce1fb2643f6eb2de", + ] +} diff --git a/storage/locals.tf b/storage/locals.tf new file mode 100644 index 0000000..cfb1ee9 --- /dev/null +++ b/storage/locals.tf @@ -0,0 +1,13 @@ +locals { + config = yamldecode(file("${path.root}/../config.yaml")) +} + +module "config" { + source = "../modules/config" + infra = local.config.infra + services = local.config.services +} + +locals { + services_with_buckets = { for name, svc in module.config.services : name => svc if length(svc.buckets) > 0 } +} diff --git a/storage/main.tf b/storage/main.tf new file mode 100644 index 0000000..3945289 --- /dev/null +++ b/storage/main.tf @@ -0,0 +1,66 @@ +resource "minio_s3_bucket" "state_terraform_s3" { + bucket = module.config.silo.state_bucket + acl = "private" + + lifecycle { + prevent_destroy = true + } +} + +resource "minio_s3_bucket_versioning" "state_terraform_s3" { + bucket = minio_s3_bucket.state_terraform_s3.bucket + + versioning_configuration { + status = "Enabled" + } +} + +resource "minio_ilm_policy" "admin_policy" { + bucket = minio_s3_bucket.state_terraform_s3.bucket + + rule { + id = "clean-rule-1" + status = "Enabled" + noncurrent_expiration { + days = "90d" + } + expired_object_delete_marker = true + abort_incomplete_multipart_upload { + days_after_initiation = "6d" + } + } +} + +resource "minio_s3_bucket" "backups" { + bucket = module.config.silo.backup_bucket + acl = "private" +} + +resource "minio_ilm_policy" "backups" { + bucket = minio_s3_bucket.backups.bucket + + rule { + id = "expire-old-backups" + status = "Enabled" + expiration = "30d" + } +} + +module "app_storage" { + source = "../modules/app-storage" + for_each = local.services_with_buckets + service = { name = each.key, buckets = each.value.buckets } +} + +resource "vault_kv_secret_v2" "s3" { + for_each = module.app_storage + mount = module.config.vault.mount + name = "${each.key}/s3" + delete_all_versions = true + data_json = jsonencode({ + endpoint = module.config.silo.internal_endpoint + buckets = join(",", each.value.buckets) + access_key = each.value.access_key + secret_key = each.value.secret_key + }) +} diff --git a/storage/output.tf b/storage/output.tf new file mode 100644 index 0000000..c0e4ded --- /dev/null +++ b/storage/output.tf @@ -0,0 +1,4 @@ +output "vault_secrets" { + value = { for name, secret in vault_kv_secret_v2.s3 : name => secret.path } + description = "vault paths of the s3 keys of the services" +} diff --git a/storage/provider.tf b/storage/provider.tf new file mode 100644 index 0000000..e713733 --- /dev/null +++ b/storage/provider.tf @@ -0,0 +1,10 @@ +provider "vault" { + address = module.config.vault.address + token = var.vault_token +} + +provider "minio" { + minio_server = module.config.silo.server + minio_user = module.config.silo.admin_user + minio_password = var.s3_password +} diff --git a/storage/variables.tf b/storage/variables.tf new file mode 100644 index 0000000..d18e867 --- /dev/null +++ b/storage/variables.tf @@ -0,0 +1,11 @@ +variable "s3_password" { + description = "password of the silo administrator" + type = string + sensitive = true +} + +variable "vault_token" { + description = "vault token" + type = string + sensitive = true +} diff --git a/storage/versions.tf b/storage/versions.tf new file mode 100644 index 0000000..b4ed73d --- /dev/null +++ b/storage/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + minio = { + source = "aminueza/minio" + version = "3.44.0" + } + vault = { + source = "hashicorp/vault" + version = "5.12.0" + } + } + backend "s3" { + key = "storage/terraform.tfstate" + } +} diff --git a/vault/.terraform.lock.hcl b/vault/.terraform.lock.hcl new file mode 100644 index 0000000..a14c375 --- /dev/null +++ b/vault/.terraform.lock.hcl @@ -0,0 +1,23 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/vault" { + version = "5.12.0" + constraints = "5.12.0" + hashes = [ + "h1:NXnrOFre4t8nj7+ZQ3MKIECpDohjMBtHWm2aen75nfU=", + "zh:0683ab1870c6ffe78463465fc0954ff18fbbfe16d79dc121e20efb27cd2be850", + "zh:0ade50207774b544995066be02cf7e108abe828e089eb646da4c04863cbee702", + "zh:2b3360bec8d457a7d19df86bf4576711035785928a9fa307affce10cbeae89fd", + "zh:5317888b0e3547341b8434f42bbb8df2d30bdd95cc1bde9a4c7a80d266fe9f6b", + "zh:6c0271736ceb08774bebedf424735d92c4fe33b8e4db371cc027cbded0439a2e", + "zh:70bb990d22d1ce1ceac2209aee025e0f7deab48d95002aa2b8360d9c86343ea8", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7f33d7bbf145e15542cfc6b1dbeed639d08204802c239c098770c5f507d1e59c", + "zh:8ae8ca31d3c9e4b530c80eed12d8debe6fc46b407acb469b33247227aaafbf51", + "zh:c61fcc4d714cefee0696e2f8bf715b5826099e405f66acc859e496de688b6a8e", + "zh:ceca8db980ae3bd986142903487d75d143edfc5c7cf467da38f3698f717db046", + "zh:e1bba3cb6a728e9bf2951139f1e7bae5bec37a37cd4bfebe282e306212a62c4c", + "zh:f9765b4aa90e9e4eae03626ebfdfeda54954ec2552267aedce1fb2643f6eb2de", + ] +} diff --git a/vault/locals.tf b/vault/locals.tf new file mode 100644 index 0000000..2929ed4 --- /dev/null +++ b/vault/locals.tf @@ -0,0 +1,9 @@ +locals { + config = yamldecode(file("${path.root}/../config.yaml")) +} + +module "config" { + source = "../modules/config" + infra = local.config.infra + services = local.config.services +} diff --git a/vault/main.tf b/vault/main.tf new file mode 100644 index 0000000..c63d0e3 --- /dev/null +++ b/vault/main.tf @@ -0,0 +1,75 @@ +resource "vault_mount" "secret" { + path = module.config.vault.mount + type = "kv" + options = { + version = "2" + } +} + +resource "vault_policy" "service" { + for_each = module.config.services + name = "service-${each.key}" + policy = jsonencode({ + path = { + "${vault_mount.secret.path}/data/${each.key}/*" = { + capabilities = ["read"] + } + "${module.config.vault.database_mount}/creds/${each.key}" = { + capabilities = ["read"] + } + } + }) +} + +resource "vault_auth_backend" "approle" { + type = "approle" +} + +resource "vault_approle_auth_backend_role" "service" { + for_each = module.config.services + backend = vault_auth_backend.approle.path + role_name = each.key + token_policies = [vault_policy.service[each.key].name] + token_ttl = 3600 + token_max_ttl = 14400 + secret_id_ttl = 2592000 +} + +locals { + kv = vault_mount.secret.path + db = module.config.vault.database_mount + ar = vault_auth_backend.approle.path + + manage = ["create", "read", "update", "delete"] +} + +resource "vault_policy" "terraform" { + name = "terraform" + policy = jsonencode({ + path = { + "auth/token/create" = { capabilities = ["update"] } + + "sys/mounts/${local.kv}" = { capabilities = ["read"] } + "sys/mounts/auth/${local.ar}" = { capabilities = ["read"] } + "sys/mounts/auth/${local.ar}/tune" = { capabilities = ["read"] } + "sys/policies/acl/service-*" = { capabilities = local.manage } + "sys/policies/acl/terraform" = { capabilities = ["read", "update"] } + "auth/${local.ar}/role/+" = { capabilities = local.manage } + "auth/${local.ar}/role/+/role-id" = { capabilities = ["read"] } + "auth/${local.ar}/role/+/secret-id" = { capabilities = ["update"] } + + "${local.kv}/data/+/s3" = { capabilities = local.manage } + "${local.kv}/data/+/postgres" = { capabilities = local.manage } + "${local.kv}/metadata/+/s3" = { capabilities = ["read", "update", "delete"] } + "${local.kv}/metadata/+/postgres" = { capabilities = ["read", "update", "delete"] } + + "sys/mounts/${local.db}" = { capabilities = ["read"] } + "sys/mounts/${local.db}/tune" = { capabilities = ["update"] } + "${local.db}/config" = { capabilities = ["list"] } + "${local.db}/config/postgres" = { capabilities = ["read", "update"] } + "${local.db}/roles/+" = { capabilities = local.manage } + + "sys/storage/raft/snapshot" = { capabilities = ["read"] } + } + }) +} diff --git a/vault/output.tf b/vault/output.tf new file mode 100644 index 0000000..0f9b673 --- /dev/null +++ b/vault/output.tf @@ -0,0 +1,4 @@ +output "approle_role_ids" { + value = { for name, role in vault_approle_auth_backend_role.service : name => role.role_id } + description = "role_id of the services, secret_id is issued separately" +} diff --git a/vault/provider.tf b/vault/provider.tf new file mode 100644 index 0000000..b4c4463 --- /dev/null +++ b/vault/provider.tf @@ -0,0 +1,4 @@ +provider "vault" { + address = module.config.vault.address + token = var.vault_token +} diff --git a/vault/variables.tf b/vault/variables.tf new file mode 100644 index 0000000..58470ca --- /dev/null +++ b/vault/variables.tf @@ -0,0 +1,5 @@ +variable "vault_token" { + description = "vault token" + type = string + sensitive = true +} diff --git a/vault/versions.tf b/vault/versions.tf new file mode 100644 index 0000000..de996e7 --- /dev/null +++ b/vault/versions.tf @@ -0,0 +1,11 @@ +terraform { + required_providers { + vault = { + source = "hashicorp/vault" + version = "5.12.0" + } + } + backend "s3" { + key = "vault/terraform.tfstate" + } +}