Initial commit
terraform / check (push) Canceled after 0s

This commit is contained in:
stolzor committed 2026-10-11 20:30:21 +03:00
commit 64be99308b
53 files changed
+1983

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
POSTGRES_PASSWORD=
PGADMIN_PASSWORD=
SILO_PASSWORD=
VAULT_TOKEN=
+26
View File
@@ -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
+16
View File
@@ -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
+290
View File
@@ -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/<state_bucket из config.yaml>
```
4. Временно записать root-токен в `.env` как `VAULT_TOKEN`: первый `apply` создаёт mount и метод входа, а у токена Terraform на это нет прав.
5. Инициализировать и применить по порядку. В `storage` бакет из шага 3 сначала принимается под управление:
```bash
mise run tf vault init && mise run tf vault apply
mise run tf storage init
mise run tf storage import minio_s3_bucket.state_terraform_s3 <state_bucket>
mise run tf storage apply
mise run tf database init && mise run tf database apply
```
6. Заменить root-токен на токен Terraform и отозвать root:
```bash
ROOT_TOKEN=<root-токен> mise run vault:bootstrap --revoke-root
```
7. Убедиться, что всё на месте: `mise run plan` должен трижды показать `No changes`, `mise run vault:app-check` — пройти.
Два проекта из одного шаблона могут работать на одной машине одновременно, если у них разные порты и разные имена каталогов: имя каталога становится именем проекта Compose и его сети.
## Неочевидные места в коде
В коде нет комментариев, поэтому причины решений, которые не видны из самого кода, собраны здесь.
- **`depends_on` между грантами в `modules/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`.
+71
View File
@@ -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
+43
View File
@@ -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
+14
View File
@@ -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
+65
View File
@@ -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",
]
}
+13
View File
@@ -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 }
}
+73
View File
@@ -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]
}
+4
View File
@@ -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"
}
+13
View File
@@ -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
}
+11
View File
@@ -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
}
+19
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
include:
- path: compose.services.yml
env_file:
- .env
- .env.config
+11
View File
@@ -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"]
+22
View File
@@ -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}
+50
View File
@@ -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
)
+116
View File
@@ -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=
+134
View File
@@ -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, &currentUser, &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)
}
+9
View File
@@ -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
+33
View File
@@ -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
+46
View File
@@ -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 <<ENV
# Сгенерировано задачей mise run render из config.yaml, не редактировать
POSTGRES_IMAGE=$(get .postgres.image)
POSTGRES_USER=$(get .postgres.admin_user)
POSTGRES_PORT=$(get .postgres.port)
PGADMIN_IMAGE=$(get .pgadmin.image)
PGADMIN_PORT=$(get .pgadmin.port)
PGADMIN_EMAIL=$(get .pgadmin.email)
SILO_IMAGE=$(get .silo.image)
SILO_USER=$(get .silo.admin_user)
SILO_STORAGE_PORT=$(get .silo.port)
SILO_WEB_PORT=$(get .silo.console_port)
VAULT_IMAGE=$(get .vault.image)
VAULT_PORT=$(get .vault.port)
VAULT_ADDR=http://$(get .vault.host):$(get .vault.port)
VAULT_MOUNT=$(get .vault.mount)
VAULT_DATABASE_MOUNT=$(get .vault.database_mount)
BACKUP_BUCKET=$(get .silo.backup_bucket)
ENV
cat >backend.hcl.tmp <<HCL
# Сгенерировано задачей mise run render из config.yaml, не редактировать
bucket = "$(get .silo.state_bucket)"
region = "us-east-1"
endpoints = {
s3 = "http://$(get .silo.host):$(get .silo.port)"
}
use_path_style = true
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_s3_checksum = true
HCL
mv .env.config.tmp .env.config
mv backend.hcl.tmp backend.hcl
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
#MISE description="Terraform с окружением из .env и config.yaml: mise run tf storage plan"
set -euo pipefail
[[ $# -ge 2 ]] || {
echo "использование: mise run tf <vault|storage|database> <команда 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" "$@"
+136
View File
@@ -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'
+19
View File
@@ -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)'
+108
View File
@@ -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
+47
View File
@@ -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",
]
+5
View File
@@ -0,0 +1,5 @@
locals {
databases = var.service.database == null ? {} : {
(var.service.database.name) = var.service.database
}
}
+42
View File
@@ -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]
}
+9
View File
@@ -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
}
+10
View File
@@ -0,0 +1,10 @@
variable "service" {
type = object({
name = string
database = optional(object({
name = string
username = string
pgvector = optional(bool, false)
}))
})
}
+12
View File
@@ -0,0 +1,12 @@
terraform {
required_providers {
postgresql = {
source = "cyrilgdn/postgresql"
version = ">= 1.27.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.9.1"
}
}
}
+71
View File
@@ -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]
}
+15
View File
@@ -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
}
+13
View File
@@ -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 невалидна."
}
}
+8
View File
@@ -0,0 +1,8 @@
terraform {
required_providers {
minio = {
source = "aminueza/minio"
version = ">= 3.44.0"
}
}
}
+29
View File
@@ -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
}
+60
View File
@@ -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 символа, строчные латинские буквы, цифры, точки и дефисы."
}
}
+44
View File
@@ -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",
]
}
+13
View File
@@ -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 }
}
+66
View File
@@ -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
})
}
+4
View File
@@ -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"
}
+10
View File
@@ -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
}
+11
View File
@@ -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
}
+15
View File
@@ -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"
}
}
+23
View File
@@ -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",
]
}
+9
View File
@@ -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
}
+75
View File
@@ -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"] }
}
})
}
+4
View File
@@ -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"
}
+4
View File
@@ -0,0 +1,4 @@
provider "vault" {
address = module.config.vault.address
token = var.vault_token
}
+5
View File
@@ -0,0 +1,5 @@
variable "vault_token" {
description = "vault token"
type = string
sensitive = true
}
+11
View File
@@ -0,0 +1,11 @@
terraform {
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.12.0"
}
}
backend "s3" {
key = "vault/terraform.tfstate"
}
}