From ab9f7d29621744eea8d26d12b7087467ea17add1 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Fri, 2 Oct 2026 08:02:28 +0300 Subject: [PATCH] =?UTF-8?q?docs(curated):=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20CRUD-=D1=81=D1=82=D0=B5=D0=BD=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=20=D1=81=D1=85=D0=B5=D0=BC=D0=B5=20pg/=20+=20apps/=20?= =?UTF-8?q?=D0=A1=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20curated/crud?= =?UTF-8?q?/three=5Fapps.md=20=D0=BE=D0=BF=D0=B8=D1=81=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20=D1=81=D1=82=D0=B0=D1=80=D1=83=D1=8E=20=D1=81?= =?UTF-8?q?=D1=85=D0=B5=D0=BC=D1=83:=20=D0=B2=D1=81=D1=91=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B5?= =?UTF-8?q?,=20=D0=B4=D0=B2=D0=B0=20apply=20=D0=B8=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=20=D0=B8=D0=B7=20vault=5Fsecrets=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=82=D0=B5=D1=80=D0=B0=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20try().=20=D0=AD=D1=82=D0=BE=20=D1=83=D0=B6=D0=B5?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D1=82=D0=B0=D0=BA.=20=D0=9F=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B0=20=D0=BF=D0=BE=20TES?= =?UTF-8?q?T=5FSTAND/CRUD/README.md:=20-=20=D0=B4=D0=B2=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20=3D=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=B0=20state,=20apply/destroy=20=D0=B2=20=D0=BA=D0=B0=D0=B6?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=20=D0=BC=D0=B5=D0=BD=D1=8F=D0=B5=D1=82=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=81=D0=B2=D0=BE=D1=91?= =?UTF-8?q?;=20-=20=D1=82=D1=80=D0=B8=20=D1=88=D0=B0=D0=B3=D0=B0:=20pg/=20?= =?UTF-8?q?->=20terraform=20output=20-json=20>=20../apps/creds.json=20->?= =?UTF-8?q?=20apps/;=20-=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C=20=D0=B1?= =?UTF-8?q?=D0=B5=D1=80=D1=91=D1=82=D1=81=D1=8F=20=D0=B8=D0=B7=20=D0=B2?= =?UTF-8?q?=D1=8B=D1=85=D0=BE=D0=B4=D0=B0=20=D0=BF=D0=BE=D0=B4=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D1=83=D1=80=D1=81=D0=B0=20nubes=5Fpostgres=5Fuser=20(?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B8=D0=B7=20vault=5Fsecrets);=20-=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20=C2=AB=D0=A1=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D1=80=D0=B0=20=D0=BC=D0=B0=D0=BD=D0=B8=D1=84?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=C2=BB=20=D0=B8=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20?= =?UTF-8?q?=C2=AB=D0=A1=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0:=20=D0=B2?= =?UTF-8?q?=D1=8B=D1=85=D0=BE=D0=B4=D0=BD=D1=8B=D0=B5=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D1=8B=20pg/=C2=BB=20=20=20(?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D1=82=D0=B0=D0=B2=20=D0=B2=D1=8B=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2,=20=D0=B2=D0=B8=D0=B4=20JSON=20{sensitive,ty?= =?UTF-8?q?pe,value},=20=D1=81=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B8=D0=B5=20env-=D0=BF=D0=B5=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=BC=20=20=20Flask/Node.js/Lucee);=20?= =?UTF-8?q?-=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D0=B5=D0=B4=D0=B5=D0=BD=D1=8B=20=D0=BA=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=83=D1=89=D0=B8=D0=BC:=20lucee-crud=20/=20flask-crud=20/=20n?= =?UTF-8?q?odejs-crud,=20=D0=BF=D1=83=D1=82=D0=B8=20=D0=B2=20=20=20apps/lo?= =?UTF-8?q?cals.tf,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20k?= =?UTF-8?q?eep=5Fon=5Fdestroy;=20-=20=D1=81=D0=BD=D1=8F=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=C2=AB=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=202026-10-01,=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=206=20=D1=80=D0=B5=D1=81=D1=83=D1=80=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=20running=C2=BB=20=D0=B8=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=20=20=20=D0=BF=D1=80=D0=BE=20DEV=5FSTAND/CRUD=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=20=E2=80=94?= =?UTF-8?q?=20=D1=82=D0=B0=D0=BC=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=8C=20=D1=81=D1=82=D0=B0=D1=80=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D1=81=D0=BA=D0=B0=D1=8F=20=D1=81=D1=85=D0=B5=D0=BC?= =?UTF-8?q?=D0=B0,=20=D1=81=D0=BA=D0=B0=D0=B7=D0=B0=D0=BD=D0=BE=20=D0=BF?= =?UTF-8?q?=D1=80=D1=8F=D0=BC=D0=BE.=20=D0=9F=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=BE:=20191=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0,=2016=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D1=8E=D1=89=D0=B8=D1=85/=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=8E=D1=89=D0=B8=D1=85=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BE=D0=B4=D0=B0=20(=D1=87=D1=91=D1=82=D0=BD?= =?UTF-8?q?=D0=BE).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/curated/crud/three_apps.md | 153 ++++++++++++++++++++++++-------- 1 file changed, 114 insertions(+), 39 deletions(-) diff --git a/docs/curated/crud/three_apps.md b/docs/curated/crud/three_apps.md index 5576414..06c7fb9 100644 --- a/docs/curated/crud/three_apps.md +++ b/docs/curated/crud/three_apps.md @@ -1,22 +1,22 @@ # CRUD-стенд: PostgreSQL + Lucee + Flask + Node.js -Проверенный пример: одна общая база PostgreSQL, три приложения на разных стеках и один сквозной -сценарий CRUD (создать/прочитать/обновить/удалить) в одной таблице `crud_items`. +Проверенный пример: одна общая база PostgreSQL, три приложения на разных стеках +(Lucee/CFML, Python, Node.js) и одна таблица `crud_items`. Запись, добавленная в любом +из трёх приложений, видна в двух других. -Манифесты: `TEST_STAND/CRUD/` (test-стенд). Аналог — `DEV_STAND/CRUD/`. - -Состояние на 2026-10-01, TEST-стенд: все 6 ресурсов созданы, сервисы `running`. +Манифесты: `TEST_STAND/CRUD/` (test-стенд) — два каталога: `pg/` (база) и `apps/` (приложения). +`DEV_STAND/CRUD/` — тот же пример, но по старой схеме: всё в одной папке. ## Что создаётся -| Ресурс | Имя | Что это | +| Ресурс | Имя (по умолчанию) | Что это | |---|---|---| | `nubes_postgres` | `pg4crud2` | кластер PostgreSQL 17 | | `nubes_postgres_user` | `user4crudpg` | пользователь БД, роль `ddl_user` | | `nubes_postgres_database` | `db4crudpg` | база, владелец — этот пользователь | -| `nubes_lucee` | `crud-lucee` | приложение на CFML | -| `nubes_flask` | `crud-flask` | приложение на Python | -| `nubes_nodejs` | `crud-nodejs` | приложение на Node.js | +| `nubes_lucee` | `lucee-crud` | приложение на Lucee 5.4 | +| `nubes_flask` | `flask-crud` | приложение на Python 3.12 | +| `nubes_nodejs` | `nodejs-crud` | приложение на Node.js 22 | Все три приложения ходят в одну базу и одну таблицу — так видно, что CRUD работает одинаково с любого стека. @@ -29,11 +29,30 @@ | Flask (Python) | `https://gitea.services.ngcloud.ru/terraform/tfflaskcrud` | | Node.js (Express) | `https://gitea.services.ngcloud.ru/terraform/tfnodejscrud` | -Ссылки на репозитории задаются в манифестах: `lucee_git_path`, `flask_git_path`, -`nodejs_git_path` в `TEST_STAND/CRUD/locals.tf`. Платформа сама клонирует код из этих -репозиториев — вручную ничего собирать и заливать не нужно. +Пути к репозиториям задаются в `TEST_STAND/CRUD/apps/locals.tf` (`lucee_git_path`, +`flask_git_path`, `nodejs_git_path`). Платформа сама клонирует код — собирать и заливать +вручную не нужно. -## Провайдер и запуск +Каждое приложение получает в `json_env` переменную `SERVICE_NAME` (`lucee` / `flask` / `nodejs`) — +это значение колонки `created_by`, чтобы было видно, кто добавил строку. + +## Структура манифестов + +Два каталога — два отдельных файла состояния Terraform: + +``` +CRUD/ +├── pg/ база данных: кластер PostgreSQL + пользователь + база +└── apps/ приложения: Lucee + Flask + Node.js +``` + +- `terraform apply` и `terraform destroy` в `apps/` меняют **только приложения** — база в `pg/` не затрагивается; +- `terraform apply` и `terraform destroy` в `pg/` меняют только базу — приложения в `apps/` не затрагиваются; +- приложения можно пересоздавать сколько угодно, база при этом не меняется; +- связь между каталогами — файл `apps/creds.json`: он делается из `terraform output` в `pg/`, + поэтому после смены хоста или пароля его нужно обновить и повторить `apply` в `apps/`. + +## Провайдер ```hcl terraform { @@ -51,51 +70,107 @@ provider "nubes" { } ``` -```hcl -# terraform.tfvars -api_token = "***" # ЛК → Профиль → Токены → «Технический» -realm = "k8s-4-sandbox-nubes-ru" # кластер Kubernetes (из списка в ЛК) -s3_name = "naeel-s3" # имя экземпляра S3 для бэкапов -``` +## Запуск — три шага + +### 1. База данных (`pg/`) ```bash +cd pg +cp terraform.tfvars.example terraform.tfvars terraform init -terraform apply # 1-й прогон: PostgreSQL + пользователь + база -terraform apply # 2-й прогон: приложения (пароль БД уже в Vault) +terraform apply ``` -!!! warning "Нужны два `apply` — это не ошибка" - Пароль пользователя БД платформа создаёт вместе с пользователем, а `vault_secrets` - у ресурса кластера читаются на этапе его создания — то есть **до** `create_user`. - Поэтому в первом прогоне пароля ещё нет, и приложения получают пустое значение. - Во втором прогоне пароль уже в Vault, и приложения обновляются с верным значением. +Создадутся кластер (несколько минут), пользователь БД и база. - В манифестах чтение пароля обёрнуто в `try(...)`, чтобы первый прогон **не падал** - с `Invalid index ... does not identify an element in this collection value`. - Подробности: [PostgreSQL: пароль пользователя](../postgres/pg_user_db.md). +| Переменная (`pg/terraform.tfvars`) | Где взять | +|---|---| +| `api_token` | ЛК → Профиль → Токены → «Технический» | +| `realm` | ЛК → Кластеры (например `k8s-4-sandbox-nubes-ru`) | +| `s3_name` | ЛК → S3 → имя экземпляра (для бэкапов) | -## Доступ приложений к базе +### 2. Передать креды БД в `apps/` -Пароль берётся из выхода `vault_secrets["users"]` — это JSON `{ "": { "password": "..." } }`: +```bash +# всё ещё в папке pg/ +terraform output -json > ../apps/creds.json +``` -```hcl -locals { - pg_host = nubes_postgres.main_pg.state_out_flat["internalMaster"] - pg_user = nubes_postgres_user.crud_user_0.username - pg_pass = try(nonsensitive(jsondecode(nubes_postgres.main_pg.vault_secrets["users"]).user4crudpg.password), "") +Одна команда выгружает хост, порт, имя пользователя, имя БД и пароль в `apps/creds.json`. +Приложения читают этот файл (`apps/locals.tf`). + +!!! warning "В `creds.json` пароль лежит открытым текстом" + Файл в `apps/.gitignore` — не коммитить и не пересылать. + +### 3. Приложения (`apps/`) + +```bash +cd ../apps +cp terraform.tfvars.example terraform.tfvars +terraform init +terraform apply +``` + +Создадутся три приложения, подключённые к общей БД. + +| Что заполнить/проверить | Файл | Зачем | +|---|---|---| +| `api_token` | `apps/terraform.tfvars` | тот же, что в `pg/` | +| `realm` | `apps/terraform.tfvars` | должен совпадать с БД | +| `lucee_domain`, `flask_domain`, `nodejs_domain` | `apps/locals.tf` | имена доменов — должны быть **уникальны** в облаке | + +## Справка: выходные параметры `pg/` + +В `pg/outputs.tf` объявлено шесть выходов: + +| Выход | Значение | Откуда | +|---|---|---| +| `pg_host` | внутренний хост master | `state_out_flat["internalMaster"]` кластера | +| `pg_port` | `5432` | константа в `outputs.tf` | +| `pg_username` | имя пользователя БД | выход подресурса `nubes_postgres_user` | +| `pg_db_name` | имя базы | выход `nubes_postgres_database` | +| `pg_password` | пароль, `sensitive = true` | выход подресурса `nubes_postgres_user`: платформа генерирует пароль сама при создании пользователя, провайдер читает его из Vault и отдаёт выходом подресурса | +| `pg_ssl_mode` | `require` | константа в `outputs.tf` | + +`terraform output -json` кладёт в файл не голые значения, а объекты вида +`{ "sensitive": ..., "type": ..., "value": ... }`: + +```json +{ + "pg_host": { "sensitive": false, "type": "string", "value": "<хост master>" }, + "pg_password": { "sensitive": true, "type": "string", "value": "<пароль>" } } ``` -⚠️ Не путать с выходом `state_out.users` — там **метаданные** (`role`, `rights`, `username`, -`mtlsAccess`) и **пароля нет**. Пароль только в `vault_secrets`. +Поэтому в `apps/locals.tf` значение берётся через `.value`: + +```hcl +creds = jsondecode(file("${path.module}/creds.json")) +pg_host = local.creds.pg_host.value +pg_pass = local.creds.pg_password.value +``` + +Дальше `apps/*.tf` передают эти значения приложениям как переменные окружения: + +| Выход `pg/` | Flask | Node.js | Lucee | +|---|---|---|---| +| `pg_host` | `PGHOST` | `PGHOST` | `PGHOST`, `testds_connectionString` | +| `pg_port` | `PGPORT` | `PGPORT` | `PGPORT`, `testds_connectionString` | +| `pg_username` | `PGUSER` | `PGUSER` | `PGUSER`, `testds_username` | +| `pg_password` | `PGPASSWORD` | `PGPASSWORD` | `PGPASSWORD`, `testds_password` | +| `pg_db_name` | `PGDATABASE` | `PGDATABASE` | `testds_connectionString`, `DATABASE_URL` | +| `pg_ssl_mode` | `PGSSLMODE` | `PGSSLMODE` | `PGSSLMODE` | ## Особенности этого примера - **`adopt_existing_on_create = true`** у всех ресурсов — если инстанс с таким именем уже есть (например после `destroy`, который приостанавливает, а не удаляет), провайдер его **усыновит**, а не упадёт с «ресурс с таким именем уже существует». +- **`keep_on_destroy = true`** у пользователя и базы: при `destroy` в `pg/` кластер уходит в `Suspend`, + а пользователь и база остаются. Иначе база исчезла бы вместе с кластером. Повторный `apply` + возвращает их в state (усыновление). - **Домены приложений должны быть уникальными** — `lucee_domain`, `flask_domain`, `nodejs_domain` - в `locals.tf`. Иначе платформа откажет. + в `apps/locals.tf`. Иначе платформа откажет. - **`postgres_conf`** — ключи строго как в спецификации платформы: `paramName` / `paramValue` (camelCase). Допустимы только `log_connections` и `log_disconnections`. - **`realm`** — рабочая ресурсная платформа. Если у неё нет ёмкости, операция падает