mirror of
https://gitlab.sarex.io/infra/terraform-contour-mirror.git
synced 2026-08-05 18:31:00 +03:00
++ declarative single-file secrets, drop resource_key, sync kafka with cloud
This commit is contained in:
parent
8d12527af4
commit
e075bc12ac
@ -1,3 +1,4 @@
|
|||||||
creation_rules:
|
creation_rules:
|
||||||
- path_regex: infrastructure-secrets\.yaml$
|
- path_regex: infrastructure-secrets\.yaml$
|
||||||
|
encrypted_regex: "^(data)$"
|
||||||
age: age198ea80z7gan2ruetn288ln6rl2qh7xtsqqpwrjtyn8maya3364fsvpdd63
|
age: age198ea80z7gan2ruetn288ln6rl2qh7xtsqqpwrjtyn8maya3364fsvpdd63
|
||||||
|
|||||||
557
README.md
557
README.md
@ -1,125 +1,138 @@
|
|||||||
# Terraform Infrastructure (stage)
|
# Terraform Infrastructure
|
||||||
|
|
||||||
Infrastructure as Code для управления ресурсами **Yandex Cloud** и **Kubernetes** окружения `stage`.
|
Infrastructure as Code для управления ресурсами Yandex Cloud и Kubernetes.
|
||||||
Всё описывается декларативно в одном файле `infrastructure.yaml`; секретные значения — в зашифрованном `infrastructure-secrets.yaml` (sops). Terragrunt читает конфиг и раскладывает по модулям.
|
|
||||||
|
|
||||||
> TL;DR для «мне нужно задеплоить X»: см. раздел [Рецепты](#рецепты--как-завести-новую-сущность).
|
## Структура
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Содержание
|
|
||||||
- [Структура репозитория](#структура-репозитория)
|
|
||||||
- [Как это работает](#как-это-работает)
|
|
||||||
- [Сущности `infrastructure.yaml`](#сущности-infrastructureyaml)
|
|
||||||
- [namespaces](#namespaces) · [buckets (S3)](#buckets-s3) · [yc_service_accounts](#yc_service_accounts) · [databases](#databases) · [kafka](#kafka) · [rabbitmq](#rabbitmq) · [secrets](#secrets)
|
|
||||||
- [Секреты подробно](#секреты-подробно)
|
|
||||||
- [Секретные значения (sops)](#секретные-значения-sops)
|
|
||||||
- [Рецепты](#рецепты--как-завести-новую-сущность)
|
|
||||||
- [Запуск](#запуск)
|
|
||||||
- [CI/CD](#cicd)
|
|
||||||
- [Бэкап и откат](#бэкап-и-откат)
|
|
||||||
- [Траблшутинг](#траблшутинг)
|
|
||||||
- [Требования](#требования)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Структура репозитория
|
|
||||||
|
|
||||||
```
|
```
|
||||||
terraform/
|
terraform/
|
||||||
├── infrastructure.yaml # ЕДИНЫЙ источник правды: все несекретные описания сущностей
|
├── infrastructure.yaml # Конфигурация всех ресурсов (единый источник правды)
|
||||||
├── infrastructure-secrets.yaml # sops-зашифрованные секретные значения (secret_values)
|
├── infrastructure-secrets.yaml # sops-зашифрованные секретные значения
|
||||||
├── .sops.yaml # правило шифрования (age-recipient)
|
├── .sops.yaml # правило шифрования
|
||||||
├── SOPS.md # как работать с sops-ключом и secret-values
|
├── SOPS.md # как работать с sops и infrastructure-secrets.yaml
|
||||||
├── Dockerfile # образ раннера: terraform + terragrunt + sops
|
├── live/ # Terragrunt конфигурации по окружениям
|
||||||
├── .gitlab-ci.yml # родительский pipeline (генерит дочерний и триггерит)
|
│ ├── terragrunt.hcl # Общие настройки (backend, providers)
|
||||||
├── scripts/generate-pipeline.sh# генератор дочернего pipeline по юнитам live/
|
│ └── stage/ # Stage окружение
|
||||||
├── live/
|
│ ├── env.hcl
|
||||||
│ ├── terragrunt.hcl # root: S3 backend + генерация provider'ов
|
│ ├── namespace/
|
||||||
│ ├── provider.tf, backend.tf # сгенерированные (не редактировать руками)
|
│ ├── database/
|
||||||
│ └── stage/
|
│ ├── s3/
|
||||||
│ ├── env.hcl # параметры окружения stage
|
│ └── secrets/
|
||||||
│ ├── namespace/ # юнит: k8s namespaces
|
└── modules/ # Terraform модули
|
||||||
│ ├── s3/ # юнит: S3 бакеты + YC service accounts
|
├── k8s-namespace/ # Создание Kubernetes namespaces
|
||||||
│ ├── database/ # юнит: PostgreSQL users/databases
|
├── k8s-secret/ # Создание Kubernetes secrets (с зависимостями)
|
||||||
│ ├── kafka-topics/ # юнит: Kafka topics/users/permissions
|
├── k8s-secrets/ # Простое создание secrets
|
||||||
│ ├── rabbitmq/ # юнит: RabbitMQ vhosts/users/permissions/routing
|
├── yc-database/ # PostgreSQL users и databases в Yandex Cloud
|
||||||
│ └── secrets/ # юнит: k8s secrets (зависит от всех выше)
|
├── yc-valkey-user/ # Valkey/Redis users в Yandex Cloud
|
||||||
└── modules/
|
|
||||||
├── k8s-namespace/ # Kubernetes namespaces
|
|
||||||
├── k8s-secret/ # Kubernetes secrets (основной, из outputs/random/явных значений)
|
|
||||||
├── kafka-topics-yc/ # Kafka topics/users/permissions в YC Managed Kafka
|
|
||||||
├── rabbitmq/ # RabbitMQ сущности через Management API
|
|
||||||
├── yc-database/ # PostgreSQL users/databases
|
|
||||||
└── yc-s3/ # S3 бакеты с изолированным доступом
|
└── yc-s3/ # S3 бакеты с изолированным доступом
|
||||||
```
|
```
|
||||||
|
|
||||||
Один «юнит» = одна папка в `live/stage/` со своим `terragrunt.hcl` и своим terraform-state
|
## Принцип работы
|
||||||
(`s3://tfstate-terragrunt-stage/stage/<unit>/terraform.tfstate`).
|
|
||||||
|
|
||||||
---
|
### infrastructure.yaml
|
||||||
|
|
||||||
## Как это работает
|
Единый файл конфигурации для всех ресурсов. Структура:
|
||||||
|
|
||||||
1. Ты правишь `infrastructure.yaml` (и при необходимости `infrastructure-secrets.yaml` через sops).
|
|
||||||
2. Terragrunt в нужном юните читает конфиг: `yamldecode(file(".../infrastructure.yaml")).environments.stage`.
|
|
||||||
3. Передаёт срез в соответствующий модуль как `inputs`.
|
|
||||||
4. `terragrunt plan/apply` создаёт/меняет ресурсы. State — в S3 (versioning включён).
|
|
||||||
|
|
||||||
### Порядок применения (зависимости)
|
|
||||||
|
|
||||||
```
|
|
||||||
namespace ──▶ s3 ─┐
|
|
||||||
database ─┤
|
|
||||||
kafka-topics ─┼──▶ secrets
|
|
||||||
rabbitmq ─┘
|
|
||||||
```
|
|
||||||
|
|
||||||
`secrets` зависит от outputs всех инфраструктурных юнитов (берёт оттуда хосты/пароли/ключи).
|
|
||||||
CI сам выстраивает `needs`; при ручном прогоне соблюдай порядок.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Сущности `infrastructure.yaml`
|
|
||||||
|
|
||||||
Всё живёт под `environments.stage.*`. Ниже — по одному разделу на тип.
|
|
||||||
|
|
||||||
### namespaces
|
|
||||||
Kubernetes namespaces (юнит `namespace`).
|
|
||||||
```yaml
|
```yaml
|
||||||
namespaces:
|
environments:
|
||||||
|
stage: # Имя окружения
|
||||||
|
namespaces: # Kubernetes namespaces
|
||||||
- name: pulse
|
- name: pulse
|
||||||
labels: { environment: stage }
|
labels: {}
|
||||||
annotations: { managed-by: terraform }
|
annotations: {}
|
||||||
```
|
|
||||||
|
|
||||||
### buckets (S3)
|
buckets: # S3 бакеты
|
||||||
S3-бакеты Yandex Object Storage с **изолированным** доступом (юнит `s3`, модуль `yc-s3`).
|
|
||||||
Каждый бакет получает отдельный SA с доступом только к нему (через `yandex_storage_bucket_iam_binding`).
|
|
||||||
```yaml
|
|
||||||
buckets:
|
|
||||||
- name: pulse-stage
|
- name: pulse-stage
|
||||||
acl: private
|
acl: private
|
||||||
role: storage.uploader # storage.uploader(def) | storage.viewer | storage.editor
|
role: storage.uploader # опционально
|
||||||
versioning: { enabled: false }
|
versioning:
|
||||||
|
enabled: false
|
||||||
cors:
|
cors:
|
||||||
enabled: true
|
enabled: true
|
||||||
allowed_methods: [GET, PUT]
|
allowed_methods: [GET, PUT]
|
||||||
|
|
||||||
yc_service_accounts: # доп. YC SA (не привязанные к бакету), опционально
|
databases: # PostgreSQL databases
|
||||||
- name: app-external-sa
|
- cluster_id: "xxx"
|
||||||
description: External integration SA
|
database:
|
||||||
folder_roles: [storage.viewer]
|
name: mydb
|
||||||
create_static_access_key: true
|
extensions: [pg_stat_statements]
|
||||||
|
user:
|
||||||
|
name: myuser
|
||||||
|
password_length: 32
|
||||||
|
conn_limit: 10
|
||||||
|
|
||||||
|
valkey_users: # Valkey/Redis users
|
||||||
|
- cluster_id: "xxx"
|
||||||
|
host: rc1a-example.mdb.yandexcloud.net
|
||||||
|
user:
|
||||||
|
name: myuser
|
||||||
|
permissions:
|
||||||
|
patterns: allkeys
|
||||||
|
pubSubChannels: allchannels
|
||||||
|
|
||||||
|
# k8s-секретов здесь НЕТ — они описываются целиком в infrastructure-secrets.yaml
|
||||||
|
# (форма + значения, шифруется только data). См. раздел «k8s-secret» ниже.
|
||||||
```
|
```
|
||||||
|
|
||||||
### yc_service_accounts
|
### Terragrunt
|
||||||
Дополнительные YC Service Accounts с folder-ролями и (опц.) статическим ключом. Создаются в юните `s3`,
|
|
||||||
их данные доступны секретам типа `yc_sa`.
|
|
||||||
|
|
||||||
### databases
|
Terragrunt читает `infrastructure.yaml` и передаёт данные в модули:
|
||||||
PostgreSQL users и databases в существующем кластере YC (юнит `database`, модуль `yc-database`).
|
|
||||||
Пароль генерируется автоматически, на password стоит `ignore_changes`.
|
```hcl
|
||||||
|
locals {
|
||||||
|
infra_config = yamldecode(file("${local.repo_root}/infrastructure.yaml"))
|
||||||
|
env_config = local.infra_config.environments[local.env_name]
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
buckets = local.env_config.buckets
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Порядок применения
|
||||||
|
|
||||||
|
Модули имеют зависимости и применяются в порядке:
|
||||||
|
|
||||||
|
```
|
||||||
|
namespace → database / s3 / valkey-users → secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
## Модули
|
||||||
|
|
||||||
|
### yc-s3
|
||||||
|
|
||||||
|
Создание S3 бакетов с изолированным доступом.
|
||||||
|
|
||||||
|
**Особенности:**
|
||||||
|
- Каждый бакет получает отдельный Service Account
|
||||||
|
- SA не имеет глобальных IAM ролей на storage на уровне folder
|
||||||
|
- Доступ к бакету через IAM binding (`yandex_storage_bucket_iam_binding`)
|
||||||
|
- SA может работать только со своим бакетом
|
||||||
|
|
||||||
|
**Роли (Yandex Cloud IAM):**
|
||||||
|
- `storage.uploader` (default) — загрузка объектов
|
||||||
|
- `storage.viewer` — чтение объектов
|
||||||
|
- `storage.editor` — полный доступ
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
|
```yaml
|
||||||
|
buckets:
|
||||||
|
- name: my-bucket
|
||||||
|
acl: private
|
||||||
|
role: storage.uploader # опционально
|
||||||
|
```
|
||||||
|
|
||||||
|
### yc-database
|
||||||
|
|
||||||
|
Создание PostgreSQL users и databases в существующем кластере.
|
||||||
|
|
||||||
|
**Особенности:**
|
||||||
|
- Пароль генерируется автоматически
|
||||||
|
- Поддержка extensions
|
||||||
|
- Поддержка permissions на другие БД
|
||||||
|
- `ignore_changes` на password
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
```yaml
|
```yaml
|
||||||
databases:
|
databases:
|
||||||
- cluster_id: "c9qa2coo5ukgcg93fldm"
|
- cluster_id: "c9qa2coo5ukgcg93fldm"
|
||||||
@ -130,271 +143,157 @@ databases:
|
|||||||
name: myuser
|
name: myuser
|
||||||
password_length: 32
|
password_length: 32
|
||||||
conn_limit: 10
|
conn_limit: 10
|
||||||
permissions: [other_db] # доступ к другим БД, опционально
|
permissions: [other_db]
|
||||||
```
|
```
|
||||||
|
|
||||||
### kafka
|
### yc-valkey-user
|
||||||
YC Managed Kafka: **topics, users, permissions** (юнит `kafka-topics`, модуль `kafka-topics-yc`).
|
|
||||||
Кластеры описаны в `kafka_cluster_refs` (id/host/port/sasl/…); в сущностях ссылаешься по `clusterRef`.
|
|
||||||
|
|
||||||
|
Создание Valkey/Redis users в существующем кластере Yandex Cloud.
|
||||||
|
|
||||||
|
**Особенности:**
|
||||||
|
- Пароль генерируется автоматически
|
||||||
|
- Поддержка ACL permissions
|
||||||
|
- `ignore_changes` на password
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
```yaml
|
```yaml
|
||||||
kafka_cluster_refs:
|
valkey_users:
|
||||||
stage:
|
- cluster_id: "c9qa2coo5ukgcg93fldm"
|
||||||
cluster_id: c9qo2dr244cohj4amaia
|
host: rc1a-example.mdb.yandexcloud.net
|
||||||
host: rc1a-....mdb.yandexcloud.net
|
port: "6380"
|
||||||
port: 9091
|
user:
|
||||||
sasl_mechanism: SCRAM-SHA-512
|
name: myuser
|
||||||
security_protocol: SASL_SSL
|
password_length: 32
|
||||||
default_partitions: 3
|
|
||||||
default_replication_factor: 1
|
|
||||||
|
|
||||||
kafka:
|
|
||||||
topics:
|
|
||||||
- name: sarex.stage.myservice.events.v1
|
|
||||||
owner: myservice # kafka-user, которому принадлежит топик
|
|
||||||
clusterRef: stage
|
|
||||||
partitions: 1
|
|
||||||
replicationFactor: 1
|
|
||||||
config: { cleanup.policy: delete, retention.ms: "604800000", min.insync.replicas: "1" }
|
|
||||||
deletionPolicy: orphan # orphan = не удалять топик при удалении из конфига
|
|
||||||
users:
|
|
||||||
# (A) НОВЫЙ юзер — пароль генерирует terraform (random_password):
|
|
||||||
- name: myservice
|
|
||||||
clusterRef: stage
|
|
||||||
permissions:
|
permissions:
|
||||||
- topic: sarex.stage.myservice.events.v1
|
patterns: allkeys
|
||||||
roles: ["ACCESS_ROLE_PRODUCER"] # PRODUCER | CONSUMER | ADMIN
|
pubSubChannels: allchannels
|
||||||
# (B) юзер, чей пароль берётся из существующего k8s-секрета (adopt/переезд):
|
categories: "+@read +@write"
|
||||||
- name: checklists
|
commands: "+GET -FLUSHALL"
|
||||||
clusterRef: stage
|
sanitizePayload: sanitize-payload
|
||||||
passwordSource: { namespace: proc, secret: checklists-kafka-secret, key: password }
|
|
||||||
permissions: []
|
|
||||||
```
|
|
||||||
На kafka-юзере стоит `prevent_destroy` и `ignore_changes = [password]` (реальный пароль в кластере
|
|
||||||
не перезаписывается сгенерированным).
|
|
||||||
|
|
||||||
### rabbitmq
|
|
||||||
RabbitMQ через провайдер `cyrilgdn/rabbitmq` (юнит `rabbitmq`, модуль `rabbitmq`): vhosts, users,
|
|
||||||
permissions, topic_permissions, exchanges, queues, bindings, policies. Требует доступ к Management API
|
|
||||||
кластера (обычно только с CI-раннера).
|
|
||||||
```yaml
|
|
||||||
rabbitmq:
|
|
||||||
policy: { allow_delete: false }
|
|
||||||
vhosts: [ { name: app_stage } ]
|
|
||||||
users: [ { name: app, tags: [], password_length: 32 } ]
|
|
||||||
permissions:
|
|
||||||
- { vhost: app_stage, user: app, configure: ".*", write: ".*", read: ".*" }
|
|
||||||
exchanges: [ { vhost: app_stage, name: app.events, type: topic, durable: true } ]
|
|
||||||
queues: [ { vhost: app_stage, name: app.events.q, durable: true } ]
|
|
||||||
bindings:
|
|
||||||
- { vhost: app_stage, source: app.events, destination: app.events.q, destination_type: queue, routing_key: "#" }
|
|
||||||
policies:
|
|
||||||
- { vhost: app_stage, name: ttl, pattern: ".*", apply_to: queues, priority: 0, definition: { message-ttl: 86400000 } }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### secrets
|
### k8s-secret
|
||||||
Kubernetes secrets (юнит `secrets`, модуль `k8s-secret`). Подробно — ниже.
|
|
||||||
|
|
||||||
---
|
Создаёт Kubernetes secrets. **Секреты описываются целиком в одном файле — зашифрованном
|
||||||
|
`infrastructure-secrets.yaml`** (форма + значения). В `infrastructure.yaml` секретов нет.
|
||||||
|
Ресурс в state адресуется парой `namespace/name` (поле `resource_key` удалено).
|
||||||
|
|
||||||
## Секреты подробно
|
Каждый секрет описывается **в одном из двух режимов** (не смешивать):
|
||||||
|
|
||||||
Секрет описывается в `secrets:` и собирает `data` из одного или нескольких источников.
|
**Режим A — adopt / статический: явная `data`.**
|
||||||
Ключ секрета для оверрайдов/импорта = `resource_key` (если задан) иначе `name`.
|
Значения заданы прямо (base64, как в k8s `.data`), шифруется только `data`. Модуль кладёт их
|
||||||
|
в секрет 1:1 и ничего не вычисляет. Используется для существующих секретов, взятых под terraform,
|
||||||
**Типы (`type`)** и их «дефолтные» поля (когда `credential_keys` не заданы — берутся все):
|
и для секретов со статическими значениями.
|
||||||
| type | откуда data |
|
|
||||||
|--------------------|-------------|
|
|
||||||
| `database` | host, port, database, username, password, ca.crt (из database-модуля) |
|
|
||||||
| `rabbitmq` | host, hostname, port, vhost, user, username, password, uri, management_endpoint |
|
|
||||||
| `kafka` | host, hostname, port, username, user, password, sasl_mechanism, security_protocol, bootstrap_server(s), bootstrap_servers_json |
|
|
||||||
| `s3` | access_key, secret_key, bucket, endpoint |
|
|
||||||
| `yc_sa` | access_key, secret_key, service_account_id, endpoint |
|
|
||||||
| `dockerconfigjson` | `.dockerconfigjson` из `DOCKER_REGISTRY_USERNAME/PASSWORD` |
|
|
||||||
| `opaque` | только из custom/constant/random/secret_values |
|
|
||||||
|
|
||||||
**Способы наполнить `data`** (можно комбинировать, если не используется `secret_values`):
|
|
||||||
- `dependencies` + (опц.) `credential_keys` — тянуть значения из outputs kafka/rabbitmq/database/s3.
|
|
||||||
`credential_keys` = `{ имя_ключа_в_секрете: имя_поля_источника }`, позволяет переименовать/выбрать подмножество.
|
|
||||||
- `random_keys` — сгенерировать значения (random_password), напр. токены/сессии.
|
|
||||||
- `custom_keys` — **несекретные** статические значения прямо в yaml (host, url, флаги).
|
|
||||||
- `constant_keys` — значения из общих `constants` (`kafka_ca`, `postgres_ca`, `postgres_port`, `s3_endpoint`).
|
|
||||||
- `secret_values` (sops) — **секретные явные значения** 1:1 (см. ниже). Оверрайд на весь секрет.
|
|
||||||
|
|
||||||
**Пример (kafka-секрет с выбором и переименованием полей + CA из constants):**
|
|
||||||
```yaml
|
|
||||||
- name: cde-api-kafka-secret
|
|
||||||
namespace: documentations
|
|
||||||
type: kafka
|
|
||||||
dependencies: { kafka_ref: stage, kafka_user: pdm-outbox-relay }
|
|
||||||
credential_keys:
|
|
||||||
host: host
|
|
||||||
port: port
|
|
||||||
username: username
|
|
||||||
password: password
|
|
||||||
sasl_mechanism: sasl_mechanism
|
|
||||||
security_protocol: security_protocol
|
|
||||||
constant_keys: { ssl_cafile: kafka_ca }
|
|
||||||
```
|
|
||||||
|
|
||||||
**`lifecycle.ignore_changes: true`** — терраформ управляет ресурсом, но не сверяет `data`
|
|
||||||
(используется для секретов, которые наполняет кто-то ещё, напр. helm).
|
|
||||||
|
|
||||||
**`adopt: true`** — существующий, созданный руками секрет заводится под управление terraform
|
|
||||||
**1:1 как есть**: его текущая `.data` берётся из `infrastructure-secrets.yaml` (secret_values) и
|
|
||||||
не меняется. См. [Рецепты](#взять-существующий-секрет-под-terraform-adopt-11).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Секретные значения (sops)
|
|
||||||
|
|
||||||
Секретные значения **не хранятся** в `infrastructure.yaml`. Они лежат в зашифрованном
|
|
||||||
`infrastructure-secrets.yaml`:
|
|
||||||
```yaml
|
|
||||||
environments:
|
|
||||||
stage:
|
|
||||||
secret_values:
|
|
||||||
<resource_key>:
|
|
||||||
<data_key>: <base64(raw_value)> # base64 = как в k8s .data
|
|
||||||
```
|
|
||||||
- Шифрование: `.sops.yaml` → age-recipient (тот же, что в `terraform-contour`). Приватный ключ —
|
|
||||||
в CI-переменной `SOPS_AGE_KEY` (в репу не коммитится). Подробности и команды — в [SOPS.md](SOPS.md).
|
|
||||||
- В CI job'ы `*-secrets` делают `sops --decrypt` во временный файл и передают путь через
|
|
||||||
`INFRA_SECRET_VALUES_FILE`. Локально terragrunt расшифровывает сам, если доступен `SOPS_AGE_KEY`.
|
|
||||||
- Если для `resource_key` есть запись в `secret_values` — вся `data` секрета берётся оттуда
|
|
||||||
(computed/random по этому секрету игнорируется). Это и «adopt как есть», и «полностью явный секрет».
|
|
||||||
|
|
||||||
Редактировать: `sops infrastructure-secrets.yaml`. Зашифровать заново после ручной правки:
|
|
||||||
`sops -e -i infrastructure-secrets.yaml`. **Расшифрованный файл никогда не коммитить** (в `.gitignore`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Рецепты — как завести новую сущность
|
|
||||||
|
|
||||||
### Новый namespace / bucket / database / kafka-topic / rabbitmq-vhost
|
|
||||||
1. Добавь запись в соответствующий раздел `infrastructure.yaml` (см. примеры выше).
|
|
||||||
2. `plan` → `apply` соответствующего юнита (или через CI).
|
|
||||||
|
|
||||||
### Новый kafka/rabbitmq пользователь (пароль генерит terraform)
|
|
||||||
1. Добавь в `kafka.users` / `rabbitmq.users` **без** `passwordSource` → terraform создаст `random_password`.
|
|
||||||
2. Заведи секрет с этим юзером, чтобы приложение получило креды (см. следующий рецепт).
|
|
||||||
|
|
||||||
### Секрет с динамически сгенерированными паролями
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: my-secret
|
- name: my-secret
|
||||||
namespace: myns
|
namespace: myns
|
||||||
type: opaque
|
type: opaque # kafka|rabbitmq|database|database_url|valkey|s3|yc_sa|dockerconfigjson|opaque
|
||||||
random_keys:
|
lifecycle: { ignore_changes: false }
|
||||||
password: { length: 32, special: false }
|
data:
|
||||||
token: { length: 48 }
|
username: <base64>
|
||||||
|
password: <base64>
|
||||||
```
|
```
|
||||||
`adopt` не нужен. Значения сгенерируются и сохранятся в state.
|
|
||||||
|
|
||||||
### Секрет из инфраструктуры (kafka/rabbitmq/db/s3)
|
**Режим B — динамический: `depends_on` + маппинг ключей.**
|
||||||
|
Значения собираются из outputs других юнитов (kafka/rabbitmq/database/valkey/s3/yc_sa),
|
||||||
|
которые создал terragrunt. **`data` НЕ указывается.**
|
||||||
```yaml
|
```yaml
|
||||||
- name: my-kafka-secret
|
- name: my-kafka-secret
|
||||||
namespace: myns
|
namespace: myns
|
||||||
type: kafka
|
type: kafka
|
||||||
dependencies: { kafka_ref: stage, kafka_user: myservice }
|
depends_on: { kafka_ref: prod, kafka_user: myservice } # какой облачный ресурс читать
|
||||||
# credential_keys опционально — если нужно подмножество/переименование
|
credential_keys: # какие поля источника и под каким ключом положить в секрет
|
||||||
|
username: username
|
||||||
|
password: password
|
||||||
|
host: host
|
||||||
|
custom_keys: { ssl: "true" } # статические несекретные литералы
|
||||||
|
constant_keys: { ca.crt: kafka_ca } # значения из общих constants (CA и т.п.)
|
||||||
|
random_keys: { token: { length: 32, special: false } } # сгенерировать random_password
|
||||||
```
|
```
|
||||||
|
|
||||||
### Секрет с чётко указанными полями
|
Что означают поля режима B:
|
||||||
- Несекретные поля → `custom_keys` в `infrastructure.yaml`.
|
- **`depends_on`** — указатель на облачный ресурс-источник кредов. Для kafka —
|
||||||
- Секретные поля → в `infrastructure-secrets.yaml`:
|
`kafka_ref:kafka_user`, для rabbitmq — `rabbitmq_ref:rabbitmq_vhost:rabbitmq_user`, для
|
||||||
1. `sops infrastructure-secrets.yaml`
|
database — `cluster:db:user`, для valkey — `valkey_cluster:valkey_user`, для s3 — `bucket`,
|
||||||
2. добавь `environments.stage.secret_values.<resource_key>.<key>: <base64>`
|
для yc_sa — `service_account`. Модуль берёт host/port/user/password/… из outputs этого юнита.
|
||||||
(`printf '%s' 'значение' | base64`)
|
- **`credential_keys`** — `{ ключ_в_секрете: имя_поля_источника }`; выбирает/переименовывает поля.
|
||||||
3. заведи сам секрет в `infrastructure.yaml` (`type: opaque`, тот же `resource_key`/`name`).
|
Если не задан — берутся все дефолтные поля типа.
|
||||||
> Нюанс: `secret_values` перекрывает **всю** `data` секрета. Смешать «часть из outputs + одно явное
|
- **`custom_keys`** — статические несекретные литералы прямо в секрет.
|
||||||
> секретное поле» одним `secret_values` нельзя — нужен либо `custom_keys` (если не секрет),
|
- **`constant_keys`** — значения из общих `constants` (`kafka_ca`, `postgres_ca`, …).
|
||||||
> либо доработка модуля (merge-режим).
|
- **`random_keys`** — генерируемые `random_password` (токены/сессии).
|
||||||
|
|
||||||
### Взять существующий секрет под terraform (adopt 1:1)
|
**Типы (`type`)** и дефолтные поля: `database` (host/port/database/username/password/ca.crt),
|
||||||
1. Захвати текущую `.data` из кластера (base64 берётся как есть):
|
`database_url` (postgresql+asyncpg URL), `valkey` (cert/host/login/password/port/url),
|
||||||
```bash
|
`kafka`/`rabbitmq` (host/port/user/password/…), `s3` (access_key/secret_key/bucket/endpoint),
|
||||||
kubectl --context yc-sarex-stage -n <ns> get secret <name> -o jsonpath='{.data}'
|
`yc_sa` (access_key/secret_key/service_account_id/endpoint), `dockerconfigjson`, `opaque`.
|
||||||
```
|
|
||||||
2. Впиши значения в `infrastructure-secrets.yaml` под `secret_values.<resource_key>` и `sops -e -i`.
|
|
||||||
3. В `infrastructure.yaml` добавь секрет с `adopt: true` (генерация import-блоков — в
|
|
||||||
`live/stage/secrets/terragrunt.hcl`).
|
|
||||||
4. `plan` → ожидаемо `import, 0 change` по data.
|
|
||||||
|
|
||||||
---
|
**Как расшифровать/отредактировать секрет:** `sops infrastructure-secrets.yaml`. После правки
|
||||||
|
руками — `sops -e -i infrastructure-secrets.yaml`. Расшифрованный файл не коммитить.
|
||||||
|
|
||||||
## Запуск
|
**Как завести секрет после создания kafka-юзера/топиков (или rmq vhost/user):** добавить запись
|
||||||
|
в режиме B — `type`, `depends_on` на созданный ресурс, `credential_keys` для нужных полей; `data`
|
||||||
|
не указывать. terragrunt подставит значения из outputs соответствующего юнита.
|
||||||
|
|
||||||
|
Подробнее про sops: [SOPS.md](SOPS.md).
|
||||||
|
|
||||||
|
### k8s-namespace
|
||||||
|
|
||||||
|
Создание Kubernetes namespaces.
|
||||||
|
|
||||||
|
**Пример:**
|
||||||
|
```yaml
|
||||||
|
namespaces:
|
||||||
|
- name: pulse
|
||||||
|
labels:
|
||||||
|
environment: stage
|
||||||
|
annotations:
|
||||||
|
managed-by: terraform
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Локальный запуск
|
||||||
|
|
||||||
### Локально
|
|
||||||
```bash
|
```bash
|
||||||
# окружение (см. также «Траблшутинг» про формат .env со встроенным SA-ключом)
|
cd terraform/live/stage/s3
|
||||||
export S3_ACCESS_KEY=... S3_SECRET_KEY=...
|
|
||||||
export YC_SERVICE_ACCOUNT_KEY_FILE=/path/to/sa-key.json # ключ SA (файл или JSON-содержимое)
|
|
||||||
export YC_STAGE_FOLDER_ID=...
|
|
||||||
export KUBECONFIG=$HOME/.kube/config
|
|
||||||
export KUBE_CONTEXT=yc-sarex-stage # ВАЖНО: иначе возьмётся текущий контекст (часто prod!)
|
|
||||||
export SOPS_AGE_KEY='AGE-SECRET-KEY-...' # для расшифровки secret_values
|
|
||||||
|
|
||||||
cd live/stage/<unit>
|
|
||||||
terragrunt init
|
terragrunt init
|
||||||
terragrunt plan
|
terragrunt plan
|
||||||
terragrunt apply
|
terragrunt apply
|
||||||
```
|
```
|
||||||
> `rabbitmq` и `secrets` требуют сетевого доступа к кластеру/RabbitMQ Management API — с локальной
|
|
||||||
> машины план этих юнитов может не подняться (нет доступа к in-cluster RabbitMQ). Прогоняй в CI.
|
|
||||||
|
|
||||||
### Провайдеры/бэкенд
|
### Применение всех модулей
|
||||||
- State: S3 `tfstate-terragrunt-stage`, ключ `stage/<unit>/terraform.tfstate` (versioning включён).
|
|
||||||
- Бакет без SSE и не поддерживает часть AWS-проверок → в `live/terragrunt.hcl` стоят
|
|
||||||
`skip_bucket_ssencryption`/`skip_bucket_versioning`/… (не убирать `skip_bucket_ssencryption`).
|
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
cd terraform/live/stage
|
||||||
|
terragrunt run-all plan
|
||||||
|
terragrunt run-all apply
|
||||||
|
```
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export YC_TOKEN="..." # IAM токен (или YC_SERVICE_ACCOUNT_KEY_FILE)
|
||||||
|
export YC_CLOUD_ID="..."
|
||||||
|
export YC_FOLDER_ID="..." # Default folder
|
||||||
|
export YC_STAGE_FOLDER_ID="..." # Stage folder (опционально)
|
||||||
|
export KUBECONFIG="~/.kube/config"
|
||||||
|
export KUBE_CONTEXT="stage"
|
||||||
|
export S3_ACCESS_KEY="..." # Для terraform state
|
||||||
|
export S3_SECRET_KEY="..."
|
||||||
|
export DOCKER_REGISTRY_USERNAME="..." # Для dockerconfigjson secrets
|
||||||
|
export DOCKER_REGISTRY_PASSWORD="..."
|
||||||
|
```
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
- `.gitlab-ci.yml` (родительский): job `generate-pipeline` вызывает `scripts/generate-pipeline.sh`,
|
Pipeline автоматически:
|
||||||
который по папкам `live/**/terragrunt.hcl` генерит дочерний pipeline (validate → plan → apply на юнит),
|
1. Читает изменённые файлы
|
||||||
затем `trigger-downstream` его запускает.
|
2. Определяет затронутые модули
|
||||||
- Образ раннера: `cr.yandex/.../terraform/terragrunt:v9.11` (terraform 1.5.7 + terragrunt 0.93.11 + sops),
|
3. Запускает `terragrunt plan` / `terragrunt apply`
|
||||||
собирается из `Dockerfile`. **Сейчас пересобирается вручную** (`docker build --platform linux/amd64 …`
|
|
||||||
+ `docker push`); тег задаётся в `.gitlab-ci.yml`.
|
|
||||||
- Секреты в CI (переменные): `S3_ACCESS_KEY/SECRET`, `YC_*_FOLDER_ID`, `YC_SERVICE_ACCOUNT_KEY_FILE`,
|
|
||||||
`SOPS_AGE_KEY`, `DOCKER_REGISTRY_*`, `SAREX_REGISTRY_KEY` (для пуша образа).
|
|
||||||
- `apply` — ручной (manual) в pipeline.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Бэкап и откат
|
|
||||||
|
|
||||||
Перед массовым `apply` (особенно adopt) делай бэкап:
|
|
||||||
- **k8s-секреты**: `kubectl ... get secret <name> -o yaml` → файл (для отката `kubectl apply -f`).
|
|
||||||
- **state**: `aws --endpoint-url=https://storage.yandexcloud.net s3 cp s3://tfstate-terragrunt-stage/stage/<unit>/terraform.tfstate ./backup/`.
|
|
||||||
- **versioning бакета включён** → предыдущую версию state можно достать через
|
|
||||||
`aws s3api list-object-versions … / get-object --version-id …`.
|
|
||||||
|
|
||||||
Откат данных секрета: `kubectl --context yc-sarex-stage apply -f <backup>.yaml`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Траблшутинг
|
|
||||||
|
|
||||||
- **`error checking if SSE is enabled for AWS S3 bucket … 404`** — свежий terragrunt проверяет SSE
|
|
||||||
бакета стейта, которого у Object Storage нет. Лечится флагами `skip_bucket_ssencryption`/`…` в
|
|
||||||
`live/terragrunt.hcl` (уже стоят). Не удаляй их.
|
|
||||||
- **secrets plan показывает `N change` с `~ data`** — не подгрузились `secret_values`
|
|
||||||
(нет `SOPS_AGE_KEY`/`INFRA_SECRET_VALUES_FILE`) → модуль ушёл в computed. Проверь ключ sops.
|
|
||||||
- **`+ wait_for_service_account_token = true`** на импортируемых секретах — служебный флаг провайдера,
|
|
||||||
к данным отношения не имеет; в модуле выставлен `false`, чтобы не шуметь.
|
|
||||||
- **kubectl берёт не тот кластер** — задай `KUBE_CONTEXT=yc-sarex-stage` (дефолтный контекст часто prod).
|
|
||||||
- **`.env` со встроенным SA-ключом** — если локальный `.env` держит JSON SA-ключа инлайном после
|
|
||||||
`YC_SERVICE_ACCOUNT_KEY_FILE=`, `source .env` сломается: вынеси JSON в отдельный файл и укажи путь.
|
|
||||||
- **Дрейф версий образа** — версия terragrunt/terraform в `Dockerfile` должна совпадать с тем, что
|
|
||||||
реально в теге образа; расхождение уже ломало init (SSE-проверка). Держи их синхронно.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- Terraform 1.5.7 / Terragrunt 0.93.x (в образе); OpenTofu локально не резолвит провайдер `yandex`.
|
- Terraform >= 1.0
|
||||||
- Провайдеры: `yandex-cloud/yandex`, `hashicorp/kubernetes`, `hashicorp/random`, `cyrilgdn/rabbitmq`.
|
- Terragrunt >= 0.45
|
||||||
- `sops` + age-ключ (`SOPS_AGE_KEY`) для secret_values.
|
- Yandex Cloud провайдер ~> 0.100
|
||||||
- Доступ: S3 (state), YC SA-ключ, kubeconfig на `yc-sarex-stage`, RabbitMQ Management API (для юнита rabbitmq).
|
- IAM токен с правами `storage.admin` для создания бакетов и bucket policy
|
||||||
|
|||||||
43
SOPS.md
Normal file
43
SOPS.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# sops и infrastructure-secrets.yaml
|
||||||
|
|
||||||
|
`infrastructure-secrets.yaml` хранит секретную часть k8s-секретов. В `infrastructure.yaml` остаётся публичное описание: имя секрета, неймспейс, тип, зависимости и набор ключей.
|
||||||
|
|
||||||
|
Формат файла:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environments:
|
||||||
|
prod:
|
||||||
|
secrets:
|
||||||
|
- name: yc-kafka-secret
|
||||||
|
namespace: eav-prod
|
||||||
|
data:
|
||||||
|
username: <base64>
|
||||||
|
password: <base64>
|
||||||
|
host: <base64>
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` и `namespace` — реальные имя и неймспейс k8s-секрета. `data` — значения в том же виде, что в k8s `.data`, то есть base64.
|
||||||
|
|
||||||
|
`resource_key` в этот файл обычно не пишется. Это внутренний ключ terraform для state, а не имя секрета в кластере. Если совпадение по `namespace/name` невозможно, `resource_key` можно указать явно, но это исключение.
|
||||||
|
|
||||||
|
Редактирование:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sops infrastructure-secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
После ручной правки расшифрованного файла:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sops -e -i infrastructure-secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
В ci `scripts/generate-pipeline.sh` расшифровывает файл во временный путь и передаёт его в terragrunt через `INFRA_SECRET_VALUES_FILE`. Расшифрованный файл не должен попадать в git и артефакты.
|
||||||
|
|
||||||
|
Для adopt существующего секрета:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl --context yc-sarex-prod -n <namespace> get secret <name> -o jsonpath='{.data}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Полученную `.data` нужно перенести в `environments.prod.secrets[]` без декодирования.
|
||||||
File diff suppressed because one or more lines are too long
1452
infrastructure.yaml
1452
infrastructure.yaml
File diff suppressed because it is too large
Load Diff
@ -131,89 +131,62 @@ LpuQKbSbIERsmR+QqQ==
|
|||||||
EOT
|
EOT
|
||||||
|
|
||||||
repo_root = try(get_repo_root(), "${get_terragrunt_dir()}/../../..")
|
repo_root = try(get_repo_root(), "${get_terragrunt_dir()}/../../..")
|
||||||
infra_config = yamldecode(file("${local.repo_root}/infrastructure.yaml"))
|
|
||||||
env_name = basename(dirname(get_terragrunt_dir()))
|
env_name = basename(dirname(get_terragrunt_dir()))
|
||||||
env_config = local.infra_config.environments[local.env_name]
|
|
||||||
|
|
||||||
# Live-значения adopt-секретов (переезд 1:1) из sops-файла infrastructure-secrets.yaml.
|
# Единый декларативный источник: форма + данные секретов в одном sops-файле
|
||||||
# В CI расшифровывается заранее (INFRA_SECRET_VALUES_FILE), локально - sops_decrypt_file.
|
# infrastructure-secrets.yaml (шифруется только data). В CI файл расшифровывается
|
||||||
|
# заранее и путь передаётся через INFRA_SECRET_VALUES_FILE; локально - fallback
|
||||||
|
# на прямую расшифровку через sops_decrypt_file. Значения передаются в модуль
|
||||||
|
# только через inputs (эфемерно), чтобы расшифрованные данные не оседали на диске.
|
||||||
secret_values_env_file = get_env("INFRA_SECRET_VALUES_FILE", "")
|
secret_values_env_file = get_env("INFRA_SECRET_VALUES_FILE", "")
|
||||||
secret_values_raw = local.secret_values_env_file != "" ? file(local.secret_values_env_file) : try(sops_decrypt_file("${local.repo_root}/infrastructure-secrets.yaml"), "")
|
secret_values_raw = local.secret_values_env_file != "" ? file(local.secret_values_env_file) : try(sops_decrypt_file("${local.repo_root}/infrastructure-secrets.yaml"), "")
|
||||||
secret_values_config = try(yamldecode(local.secret_values_raw), {})
|
secret_values_config = try(yamldecode(local.secret_values_raw), {})
|
||||||
secret_values_map = try(local.secret_values_config.environments[local.env_name].secret_values, {})
|
secret_values_env = try(local.secret_values_config.environments[local.env_name], {})
|
||||||
}
|
|
||||||
|
|
||||||
terraform {
|
secrets = try(local.secret_values_env.secrets, [])
|
||||||
source = "${get_terragrunt_dir()}/../../../modules//k8s-secret"
|
|
||||||
|
|
||||||
extra_arguments "infrastructure_tfvars" {
|
# Нормализованный список для модуля. Ключ ресурса теперь namespace/name,
|
||||||
commands = get_terraform_commands_that_need_vars()
|
# resource_key больше не используется.
|
||||||
arguments = ["-var-file=terragrunt.auto.tfvars.json"]
|
secrets_input = [
|
||||||
|
for secret in local.secrets : {
|
||||||
|
name = secret.name
|
||||||
|
namespace = secret.namespace
|
||||||
|
type = try(secret.type, "opaque")
|
||||||
|
registry_url = try(secret.registry_url, "")
|
||||||
|
data = try(secret.data, {})
|
||||||
|
depends_on = try(secret.depends_on, {})
|
||||||
|
yc_sa_key_names = try(secret.yc_sa_key_names, {})
|
||||||
|
custom_keys = try(secret.custom_keys, {})
|
||||||
|
constant_keys = try(secret.constant_keys, {})
|
||||||
|
random_keys = try(secret.random_keys, {})
|
||||||
|
credential_keys = try(secret.credential_keys, {})
|
||||||
|
labels = try(secret.labels, {})
|
||||||
|
annotations = try(secret.annotations, {})
|
||||||
|
ignore_changes = try(secret.lifecycle.ignore_changes, false)
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
generate "import_existing" {
|
generate "import_existing" {
|
||||||
path = "import_existing.tf"
|
path = "import_existing.tf"
|
||||||
if_exists = "overwrite"
|
if_exists = "overwrite"
|
||||||
contents = join("\n\n", [
|
contents = join("\n\n", [
|
||||||
for secret in local.env_config.secrets : <<-EOF
|
for secret in local.secrets : <<-EOF
|
||||||
import {
|
import {
|
||||||
to = kubernetes_secret.without_ignore["${try(secret.resource_key, "") != "" ? secret.resource_key : secret.name}"]
|
to = kubernetes_secret.without_ignore["${secret.namespace}/${secret.name}"]
|
||||||
id = "${secret.namespace}/${secret.name}"
|
id = "${secret.namespace}/${secret.name}"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
if try(secret.adopt, false)
|
if try(secret.adopt, false) && try(secret.lifecycle.ignore_changes, false) == false
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
generate "tfvars" {
|
terraform {
|
||||||
path = "terragrunt.auto.tfvars.json"
|
source = "${get_terragrunt_dir()}/../../../modules//k8s-secret"
|
||||||
if_exists = "overwrite"
|
|
||||||
disable_signature = true
|
|
||||||
contents = jsonencode({
|
|
||||||
secrets = [
|
|
||||||
for secret in local.env_config.secrets : {
|
|
||||||
resource_key = try(secret.resource_key, "")
|
|
||||||
name = secret.name
|
|
||||||
namespace = secret.namespace
|
|
||||||
secret_type = secret.type
|
|
||||||
registry_url = try(secret.registry_url, "")
|
|
||||||
dependencies = try(secret.dependencies, {})
|
|
||||||
yc_sa_key_names = try(secret.yc_sa_key_names, {})
|
|
||||||
custom_keys = try(secret.custom_keys, {})
|
|
||||||
constant_keys = try(secret.constant_keys, {})
|
|
||||||
random_keys = try(secret.random_keys, {})
|
|
||||||
credential_keys = try(secret.credential_keys, {})
|
|
||||||
labels = try(secret.labels, {})
|
|
||||||
annotations = try(secret.annotations, {})
|
|
||||||
ignore_changes = try(secret.lifecycle.ignore_changes, false)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
secrets = [
|
secrets = local.secrets_input
|
||||||
for secret in local.env_config.secrets : {
|
|
||||||
resource_key = try(secret.resource_key, "")
|
|
||||||
name = secret.name
|
|
||||||
namespace = secret.namespace
|
|
||||||
secret_type = secret.type
|
|
||||||
registry_url = try(secret.registry_url, "")
|
|
||||||
dependencies = try(secret.dependencies, {})
|
|
||||||
yc_sa_key_names = try(secret.yc_sa_key_names, {})
|
|
||||||
custom_keys = try(secret.custom_keys, {})
|
|
||||||
constant_keys = try(secret.constant_keys, {})
|
|
||||||
random_keys = try(secret.random_keys, {})
|
|
||||||
credential_keys = try(secret.credential_keys, {})
|
|
||||||
labels = try(secret.labels, {})
|
|
||||||
annotations = try(secret.annotations, {})
|
|
||||||
ignore_changes = try(secret.lifecycle.ignore_changes, false)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Live-значения adopt-секретов (1:1) из расшифрованного sops secret-values.
|
|
||||||
secret_values = local.secret_values_map
|
|
||||||
|
|
||||||
s3_outputs = {
|
s3_outputs = {
|
||||||
bucket_name = dependency.s3.outputs.bucket_name
|
bucket_name = dependency.s3.outputs.bucket_name
|
||||||
@ -233,7 +206,6 @@ inputs = {
|
|||||||
s3_endpoint = "https://storage.yandexcloud.net"
|
s3_endpoint = "https://storage.yandexcloud.net"
|
||||||
postgres_port = "6432"
|
postgres_port = "6432"
|
||||||
postgres_ca = local.yc_postgresql_ca_cert
|
postgres_ca = local.yc_postgresql_ca_cert
|
||||||
kafka_ca = local.yc_postgresql_ca_cert
|
|
||||||
valkey_port = "6380"
|
valkey_port = "6380"
|
||||||
valkey_ca = local.yc_postgresql_ca_cert
|
valkey_ca = local.yc_postgresql_ca_cert
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,9 @@ locals {
|
|||||||
k8s_secret_type_map = {
|
k8s_secret_type_map = {
|
||||||
"dockerconfigjson" = "kubernetes.io/dockerconfigjson"
|
"dockerconfigjson" = "kubernetes.io/dockerconfigjson"
|
||||||
"database" = "Opaque"
|
"database" = "Opaque"
|
||||||
|
"database_url" = "Opaque"
|
||||||
"s3" = "Opaque"
|
"s3" = "Opaque"
|
||||||
|
"valkey" = "Opaque"
|
||||||
"yc_sa" = "Opaque"
|
"yc_sa" = "Opaque"
|
||||||
"rabbitmq" = "Opaque"
|
"rabbitmq" = "Opaque"
|
||||||
"kafka" = "Opaque"
|
"kafka" = "Opaque"
|
||||||
@ -19,7 +21,7 @@ locals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
secrets_map = {
|
secrets_map = {
|
||||||
for idx, secret in var.secrets : (try(secret.resource_key, "") != "" ? secret.resource_key : secret.name) => secret
|
for secret in var.secrets : "${secret.namespace}/${secret.name}" => secret
|
||||||
}
|
}
|
||||||
|
|
||||||
random_keys_flat = merge([
|
random_keys_flat = merge([
|
||||||
@ -35,7 +37,21 @@ locals {
|
|||||||
|
|
||||||
database_outputs_by_secret = {
|
database_outputs_by_secret = {
|
||||||
for name, secret in local.secrets_map : name => try(
|
for name, secret in local.secrets_map : name => try(
|
||||||
var.database_outputs_map["${try(secret.dependencies.cluster, "")}:${try(secret.dependencies.db, "")}:${try(secret.dependencies.user, "")}"],
|
var.database_outputs_map["${try(secret.depends_on.cluster, "")}:${try(secret.depends_on.db, "")}:${try(secret.depends_on.user, "")}"],
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
valkey_dependency_keys = {
|
||||||
|
for name, secret in local.secrets_map : name => {
|
||||||
|
cluster = try(secret.depends_on.valkey_cluster, "") != "" ? try(secret.depends_on.valkey_cluster, "") : try(secret.depends_on.cluster, "")
|
||||||
|
user = try(secret.depends_on.valkey_user, "") != "" ? try(secret.depends_on.valkey_user, "") : try(secret.depends_on.user, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
valkey_outputs_by_secret = {
|
||||||
|
for name, secret in local.secrets_map : name => try(
|
||||||
|
var.valkey_outputs_map["${local.valkey_dependency_keys[name].cluster}:${local.valkey_dependency_keys[name].user}"],
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -45,9 +61,9 @@ locals {
|
|||||||
var.rabbitmq_outputs_map[
|
var.rabbitmq_outputs_map[
|
||||||
format(
|
format(
|
||||||
"%s:%s:%s",
|
"%s:%s:%s",
|
||||||
try(secret.dependencies.rabbitmq_ref, "default"),
|
try(secret.depends_on.rabbitmq_ref, "default"),
|
||||||
try(secret.dependencies.rabbitmq_vhost, try(secret.dependencies.vhost, "")),
|
try(secret.depends_on.rabbitmq_vhost, try(secret.depends_on.vhost, "")),
|
||||||
try(secret.dependencies.rabbitmq_user, try(secret.dependencies.user, ""))
|
try(secret.depends_on.rabbitmq_user, try(secret.depends_on.user, ""))
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
null
|
null
|
||||||
@ -59,8 +75,8 @@ locals {
|
|||||||
var.kafka_outputs_map[
|
var.kafka_outputs_map[
|
||||||
format(
|
format(
|
||||||
"%s:%s",
|
"%s:%s",
|
||||||
try(secret.dependencies.kafka_ref, try(secret.dependencies.cluster_ref, try(secret.dependencies.cluster, ""))),
|
try(secret.depends_on.kafka_ref, try(secret.depends_on.cluster_ref, try(secret.depends_on.cluster, ""))),
|
||||||
try(secret.dependencies.kafka_user, try(secret.dependencies.user, ""))
|
try(secret.depends_on.kafka_user, try(secret.depends_on.user, ""))
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
null
|
null
|
||||||
@ -79,14 +95,14 @@ locals {
|
|||||||
uri = local.rabbitmq_outputs_by_secret[name].uri
|
uri = local.rabbitmq_outputs_by_secret[name].uri
|
||||||
management_endpoint = local.rabbitmq_outputs_by_secret[name].management_endpoint
|
management_endpoint = local.rabbitmq_outputs_by_secret[name].management_endpoint
|
||||||
}
|
}
|
||||||
if secret.secret_type == "rabbitmq"
|
if secret.type == "rabbitmq"
|
||||||
}
|
}
|
||||||
|
|
||||||
rabbitmq_credentials_by_secret = {
|
rabbitmq_credentials_by_secret = {
|
||||||
for name, secret in local.secrets_map : name => length(try(secret.credential_keys, {})) == 0 ? local.rabbitmq_default_credentials_by_secret[name] : {
|
for name, secret in local.secrets_map : name => length(try(secret.credential_keys, {})) == 0 ? local.rabbitmq_default_credentials_by_secret[name] : {
|
||||||
for key, source in try(secret.credential_keys, {}) : key => try(local.rabbitmq_default_credentials_by_secret[name][source], "")
|
for key, source in try(secret.credential_keys, {}) : key => try(local.rabbitmq_default_credentials_by_secret[name][source], "")
|
||||||
}
|
}
|
||||||
if secret.secret_type == "rabbitmq" && local.rabbitmq_outputs_by_secret[name] != null
|
if secret.type == "rabbitmq" && local.rabbitmq_outputs_by_secret[name] != null
|
||||||
}
|
}
|
||||||
|
|
||||||
kafka_default_credentials_by_secret = {
|
kafka_default_credentials_by_secret = {
|
||||||
@ -103,22 +119,22 @@ locals {
|
|||||||
bootstrap_servers = local.kafka_outputs_by_secret[name].bootstrap_servers
|
bootstrap_servers = local.kafka_outputs_by_secret[name].bootstrap_servers
|
||||||
bootstrap_servers_json = local.kafka_outputs_by_secret[name].bootstrap_servers_json
|
bootstrap_servers_json = local.kafka_outputs_by_secret[name].bootstrap_servers_json
|
||||||
}
|
}
|
||||||
if secret.secret_type == "kafka"
|
if secret.type == "kafka"
|
||||||
}
|
}
|
||||||
|
|
||||||
kafka_credentials_by_secret = {
|
kafka_credentials_by_secret = {
|
||||||
for name, secret in local.secrets_map : name => length(try(secret.credential_keys, {})) == 0 ? local.kafka_default_credentials_by_secret[name] : {
|
for name, secret in local.secrets_map : name => length(try(secret.credential_keys, {})) == 0 ? local.kafka_default_credentials_by_secret[name] : {
|
||||||
for key, source in try(secret.credential_keys, {}) : key => try(local.kafka_default_credentials_by_secret[name][source], "")
|
for key, source in try(secret.credential_keys, {}) : key => try(local.kafka_default_credentials_by_secret[name][source], "")
|
||||||
}
|
}
|
||||||
if secret.secret_type == "kafka" && local.kafka_outputs_by_secret[name] != null
|
if secret.type == "kafka" && local.kafka_outputs_by_secret[name] != null
|
||||||
}
|
}
|
||||||
|
|
||||||
yc_sa_sources = {
|
yc_sa_sources = {
|
||||||
for name, secret in local.secrets_map : name => try(
|
for name, secret in local.secrets_map : name => try(
|
||||||
var.yc_service_accounts_map[try(secret.dependencies.service_account, "")],
|
var.yc_service_accounts_map[try(secret.depends_on.service_account, "")],
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
if secret.secret_type == "yc_sa"
|
if secret.type == "yc_sa"
|
||||||
}
|
}
|
||||||
|
|
||||||
yc_sa_key_names_by_secret = {
|
yc_sa_key_names_by_secret = {
|
||||||
@ -126,22 +142,22 @@ locals {
|
|||||||
local.default_yc_sa_key_names,
|
local.default_yc_sa_key_names,
|
||||||
try(secret.yc_sa_key_names, {})
|
try(secret.yc_sa_key_names, {})
|
||||||
)
|
)
|
||||||
if secret.secret_type == "yc_sa"
|
if secret.type == "yc_sa"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Значения, захваченные из live-кластера (adopt 1:1). Приходят из расшифрованного
|
# Явные значения секрета (adopt 1:1 / статические). Приходят прямо из
|
||||||
# secret-values файла как map(resource_key => map(key => base64(raw_value))).
|
# infrastructure-secrets.yaml в поле data как map(key => base64(raw_value)).
|
||||||
# k8s хранит .data уже в base64, поэтому base64decode даёт исходное значение байт-в-байт.
|
# k8s хранит .data уже в base64, поэтому base64decode даёт исходное значение байт-в-байт.
|
||||||
secrets_data_override = {
|
secrets_data_override = {
|
||||||
for name, secret in local.secrets_map : name => {
|
for name, secret in local.secrets_map : name => {
|
||||||
for key, b64 in try(var.secret_values[name], {}) : key => base64decode(b64)
|
for key, b64 in try(secret.data, {}) : key => base64decode(b64)
|
||||||
}
|
}
|
||||||
if length(try(var.secret_values[name], {})) > 0
|
if length(try(secret.data, {})) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
secrets_data_computed = {
|
secrets_data_computed = {
|
||||||
for name, secret in local.secrets_map : name =>
|
for name, secret in local.secrets_map : name =>
|
||||||
secret.secret_type == "dockerconfigjson" ? {
|
secret.type == "dockerconfigjson" ? {
|
||||||
".dockerconfigjson" = jsonencode({
|
".dockerconfigjson" = jsonencode({
|
||||||
auths = {
|
auths = {
|
||||||
"${secret.registry_url}" = {
|
"${secret.registry_url}" = {
|
||||||
@ -151,13 +167,13 @@ locals {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} : secret.secret_type == "s3" ? {
|
} : secret.type == "s3" ? {
|
||||||
# Если указан конкретный bucket в dependencies - используем его, иначе fallback на s3_outputs
|
# Если указан конкретный bucket в depends_on - используем его, иначе fallback на s3_outputs
|
||||||
access_key = try(var.s3_buckets_map[secret.dependencies.bucket].access_key, var.s3_outputs.access_key)
|
access_key = try(var.s3_buckets_map[secret.depends_on.bucket].access_key, var.s3_outputs.access_key)
|
||||||
secret_key = try(var.s3_buckets_map[secret.dependencies.bucket].secret_key, var.s3_outputs.secret_key)
|
secret_key = try(var.s3_buckets_map[secret.depends_on.bucket].secret_key, var.s3_outputs.secret_key)
|
||||||
bucket = try(var.s3_buckets_map[secret.dependencies.bucket].bucket_name, var.s3_outputs.bucket_name)
|
bucket = try(var.s3_buckets_map[secret.depends_on.bucket].bucket_name, var.s3_outputs.bucket_name)
|
||||||
endpoint = try(var.constants.s3_endpoint, "https://storage.yandexcloud.net")
|
endpoint = try(var.constants.s3_endpoint, "https://storage.yandexcloud.net")
|
||||||
} : secret.secret_type == "yc_sa" ? merge(
|
} : secret.type == "yc_sa" ? merge(
|
||||||
{
|
{
|
||||||
(local.yc_sa_key_names_by_secret[name].access_key) = try(local.yc_sa_sources[name].access_key, "")
|
(local.yc_sa_key_names_by_secret[name].access_key) = try(local.yc_sa_sources[name].access_key, "")
|
||||||
(local.yc_sa_key_names_by_secret[name].secret_key) = try(local.yc_sa_sources[name].secret_key, "")
|
(local.yc_sa_key_names_by_secret[name].secret_key) = try(local.yc_sa_sources[name].secret_key, "")
|
||||||
@ -171,7 +187,7 @@ locals {
|
|||||||
{
|
{
|
||||||
for key, config in try(secret.random_keys, {}) : key => random_password.secrets["${name}:${key}"].result
|
for key, config in try(secret.random_keys, {}) : key => random_password.secrets["${name}:${key}"].result
|
||||||
}
|
}
|
||||||
) : secret.secret_type == "database" && local.database_outputs_by_secret[name] != null ? merge(
|
) : secret.type == "database" && local.database_outputs_by_secret[name] != null ? merge(
|
||||||
# Базовые поля из database outputs
|
# Базовые поля из database outputs
|
||||||
{
|
{
|
||||||
host = local.database_outputs_by_secret[name].host
|
host = local.database_outputs_by_secret[name].host
|
||||||
@ -190,7 +206,41 @@ locals {
|
|||||||
{
|
{
|
||||||
for key, config in try(secret.random_keys, {}) : key => random_password.secrets["${name}:${key}"].result
|
for key, config in try(secret.random_keys, {}) : key => random_password.secrets["${name}:${key}"].result
|
||||||
}
|
}
|
||||||
) : secret.secret_type == "rabbitmq" && local.rabbitmq_outputs_by_secret[name] != null ? merge(
|
) : secret.type == "database_url" && local.database_outputs_by_secret[name] != null ? merge(
|
||||||
|
{
|
||||||
|
database_url = format(
|
||||||
|
"postgresql+asyncpg://%s:%s@%s:%s/%s",
|
||||||
|
local.database_outputs_by_secret[name].user_name,
|
||||||
|
local.database_outputs_by_secret[name].password,
|
||||||
|
local.database_outputs_by_secret[name].host,
|
||||||
|
try(var.constants.postgres_port, "6432"),
|
||||||
|
local.database_outputs_by_secret[name].database_name
|
||||||
|
)
|
||||||
|
},
|
||||||
|
try(secret.custom_keys, {}),
|
||||||
|
{
|
||||||
|
for key, constant_name in try(secret.constant_keys, {}) : key => try(var.constants[constant_name], "")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
for key, config in try(secret.random_keys, {}) : key => random_password.secrets["${name}:${key}"].result
|
||||||
|
}
|
||||||
|
) : secret.type == "valkey" && local.valkey_outputs_by_secret[name] != null ? merge(
|
||||||
|
{
|
||||||
|
cert = try(var.constants.valkey_ca, try(var.constants.postgres_ca, ""))
|
||||||
|
host = local.valkey_outputs_by_secret[name].host
|
||||||
|
login = local.valkey_outputs_by_secret[name].user_name
|
||||||
|
password = local.valkey_outputs_by_secret[name].password
|
||||||
|
port = tostring(try(local.valkey_outputs_by_secret[name].port, try(var.constants.valkey_port, "6380")))
|
||||||
|
url = format("%s:%s", local.valkey_outputs_by_secret[name].host, tostring(try(local.valkey_outputs_by_secret[name].port, try(var.constants.valkey_port, "6380"))))
|
||||||
|
},
|
||||||
|
try(secret.custom_keys, {}),
|
||||||
|
{
|
||||||
|
for key, constant_name in try(secret.constant_keys, {}) : key => try(var.constants[constant_name], "")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
for key, config in try(secret.random_keys, {}) : key => random_password.secrets["${name}:${key}"].result
|
||||||
|
}
|
||||||
|
) : secret.type == "rabbitmq" && local.rabbitmq_outputs_by_secret[name] != null ? merge(
|
||||||
local.rabbitmq_credentials_by_secret[name],
|
local.rabbitmq_credentials_by_secret[name],
|
||||||
try(secret.custom_keys, {}),
|
try(secret.custom_keys, {}),
|
||||||
{
|
{
|
||||||
@ -199,7 +249,7 @@ locals {
|
|||||||
{
|
{
|
||||||
for key, config in try(secret.random_keys, {}) : key => random_password.secrets["${name}:${key}"].result
|
for key, config in try(secret.random_keys, {}) : key => random_password.secrets["${name}:${key}"].result
|
||||||
}
|
}
|
||||||
) : secret.secret_type == "kafka" && local.kafka_outputs_by_secret[name] != null ? merge(
|
) : secret.type == "kafka" && local.kafka_outputs_by_secret[name] != null ? merge(
|
||||||
local.kafka_credentials_by_secret[name],
|
local.kafka_credentials_by_secret[name],
|
||||||
try(secret.custom_keys, {}),
|
try(secret.custom_keys, {}),
|
||||||
{
|
{
|
||||||
@ -219,7 +269,7 @@ locals {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Если для секрета заданы live-значения (adopt) - используем их 1:1,
|
# Если для секрета заданы явные значения (data) - используем их 1:1,
|
||||||
# иначе собираем data из outputs/custom/constant/random как обычно.
|
# иначе собираем data из outputs/custom/constant/random как обычно.
|
||||||
secrets_data = {
|
secrets_data = {
|
||||||
for name, secret in local.secrets_map : name => (
|
for name, secret in local.secrets_map : name => (
|
||||||
@ -233,8 +283,8 @@ locals {
|
|||||||
for name, secret in local.secrets_map : name => {
|
for name, secret in local.secrets_map : name => {
|
||||||
name = secret.name
|
name = secret.name
|
||||||
namespace = secret.namespace
|
namespace = secret.namespace
|
||||||
secret_type = secret.secret_type
|
type = secret.type
|
||||||
k8s_type = try(local.k8s_secret_type_map[secret.secret_type], "Opaque")
|
k8s_type = try(local.k8s_secret_type_map[secret.type], "Opaque")
|
||||||
labels = try(secret.labels, {})
|
labels = try(secret.labels, {})
|
||||||
annotations = try(secret.annotations, {})
|
annotations = try(secret.annotations, {})
|
||||||
ignore_changes = try(secret.ignore_changes, false)
|
ignore_changes = try(secret.ignore_changes, false)
|
||||||
@ -278,11 +328,6 @@ resource "kubernetes_secret" "with_ignore" {
|
|||||||
|
|
||||||
type = each.value.k8s_type
|
type = each.value.k8s_type
|
||||||
|
|
||||||
# Совпадает с импортированным состоянием (провайдер по умолчанию ставит true и
|
|
||||||
# даёт phantom "+ wait_for_service_account_token = true" на adopt-секретах).
|
|
||||||
# Актуально только для secret-ов типа service-account-token; тут все Opaque.
|
|
||||||
wait_for_service_account_token = false
|
|
||||||
|
|
||||||
data = each.value.data
|
data = each.value.data
|
||||||
|
|
||||||
lifecycle {
|
lifecycle {
|
||||||
@ -305,9 +350,6 @@ resource "kubernetes_secret" "without_ignore" {
|
|||||||
|
|
||||||
type = each.value.k8s_type
|
type = each.value.k8s_type
|
||||||
|
|
||||||
# Совпадает с импортированным состоянием (иначе phantom "+ wait_for_service_account_token = true").
|
|
||||||
wait_for_service_account_token = false
|
|
||||||
|
|
||||||
data = each.value.data
|
data = each.value.data
|
||||||
|
|
||||||
lifecycle {
|
lifecycle {
|
||||||
|
|||||||
@ -1,24 +1,26 @@
|
|||||||
variable "secrets" {
|
variable "secrets" {
|
||||||
description = "Список секретов для создания"
|
description = "Список секретов. Форма и данные декларируются в одном месте (infrastructure-secrets.yaml)."
|
||||||
type = list(object({
|
type = list(object({
|
||||||
name = string
|
name = string
|
||||||
resource_key = optional(string, "")
|
|
||||||
namespace = string
|
namespace = string
|
||||||
secret_type = string
|
type = string
|
||||||
registry_url = optional(string, "")
|
registry_url = optional(string, "")
|
||||||
dependencies = optional(object({
|
data = optional(map(string), {}) # Явные значения (adopt/static), base64 как в k8s .data
|
||||||
|
depends_on = optional(object({
|
||||||
cluster = optional(string, "")
|
cluster = optional(string, "")
|
||||||
cluster_ref = optional(string, "")
|
cluster_ref = optional(string, "")
|
||||||
db = optional(string, "")
|
db = optional(string, "")
|
||||||
user = optional(string, "")
|
user = optional(string, "")
|
||||||
|
valkey_cluster = optional(string, "")
|
||||||
|
valkey_user = optional(string, "")
|
||||||
kafka_ref = optional(string, "")
|
kafka_ref = optional(string, "")
|
||||||
kafka_user = optional(string, "")
|
kafka_user = optional(string, "")
|
||||||
vhost = optional(string, "")
|
vhost = optional(string, "")
|
||||||
rabbitmq_ref = optional(string, "default")
|
|
||||||
rabbitmq_user = optional(string, "")
|
|
||||||
rabbitmq_vhost = optional(string, "")
|
|
||||||
bucket = optional(string, "") # Имя бакета для s3 секретов
|
bucket = optional(string, "") # Имя бакета для s3 секретов
|
||||||
service_account = optional(string, "") # Имя YC Service Account для yc_sa секретов
|
service_account = optional(string, "") # Имя YC Service Account для yc_sa секретов
|
||||||
|
rabbitmq_ref = optional(string, "default")
|
||||||
|
rabbitmq_vhost = optional(string, "")
|
||||||
|
rabbitmq_user = optional(string, "")
|
||||||
}), {})
|
}), {})
|
||||||
yc_sa_key_names = optional(object({
|
yc_sa_key_names = optional(object({
|
||||||
access_key = optional(string, "access_key")
|
access_key = optional(string, "access_key")
|
||||||
@ -38,12 +40,6 @@ variable "secrets" {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "secret_values" {
|
|
||||||
description = "Захваченные из live-кластера значения секретов для adopt 1:1. Ключ = resource_key (или name), значение = map(key => base64(raw)). Если задано - используется как data секрета без изменений."
|
|
||||||
type = map(map(string))
|
|
||||||
default = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "s3_outputs" {
|
variable "s3_outputs" {
|
||||||
description = "Outputs от модуля S3 (default/первый бакет)"
|
description = "Outputs от модуля S3 (default/первый бакет)"
|
||||||
type = object({
|
type = object({
|
||||||
@ -91,6 +87,18 @@ variable "database_outputs_map" {
|
|||||||
default = {}
|
default = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "valkey_outputs_map" {
|
||||||
|
description = "Outputs от модулей Valkey/Redis users, ключ = cluster_id:user_name"
|
||||||
|
type = map(object({
|
||||||
|
cluster_id = string
|
||||||
|
host = string
|
||||||
|
port = string
|
||||||
|
user_name = string
|
||||||
|
password = string
|
||||||
|
}))
|
||||||
|
default = {}
|
||||||
|
}
|
||||||
|
|
||||||
variable "rabbitmq_outputs_map" {
|
variable "rabbitmq_outputs_map" {
|
||||||
description = "Outputs от модуля RabbitMQ, ключ = rabbitmq_ref:vhost:user"
|
description = "Outputs от модуля RabbitMQ, ключ = rabbitmq_ref:vhost:user"
|
||||||
type = map(object({
|
type = map(object({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user