From 333093ab6c382d98ff4a2db0f8c12c3fdbc24e09 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Sat, 4 Apr 2026 08:38:55 +0300 Subject: [PATCH] Add PG_TEST example - PostgreSQL testing suite Example Terraform configuration for testing PostgreSQL integration: - main.tf: VPC and database setup - postgres.tf: Database resource definitions - outputs.tf: Output values for connection - test_basic.sh: Basic connectivity tests - test_lifecycle.sh: Full lifecycle testing - terraform.tfvars.example: Configuration template - .gitignore: Ignore sensitive data and terraform artifacts --- PG_TEST/.gitignore | 21 ++++ PG_TEST/main.tf | 59 ++++++++++ PG_TEST/outputs.tf | 42 +++++++ PG_TEST/postgres.tf | 99 ++++++++++++++++ PG_TEST/terraform.tfvars.example | 54 +++++++++ PG_TEST/test_basic.sh | 49 ++++++++ PG_TEST/test_lifecycle.sh | 187 +++++++++++++++++++++++++++++++ 7 files changed, 511 insertions(+) create mode 100644 PG_TEST/.gitignore create mode 100644 PG_TEST/main.tf create mode 100644 PG_TEST/outputs.tf create mode 100644 PG_TEST/postgres.tf create mode 100644 PG_TEST/terraform.tfvars.example create mode 100644 PG_TEST/test_basic.sh create mode 100644 PG_TEST/test_lifecycle.sh diff --git a/PG_TEST/.gitignore b/PG_TEST/.gitignore new file mode 100644 index 0000000..d776a24 --- /dev/null +++ b/PG_TEST/.gitignore @@ -0,0 +1,21 @@ +# Terraform provider plugins +.terraform/ +.terraform.lock.hcl + +# Terraform state +terraform.tfstate +terraform.tfstate.backup +*.tfstate +*.tfstate.backup + +# Sensitive data +terraform.tfvars +!terraform.tfvars.example + +# Backup files +*.bak +*.bak_db +*.bak_* + +# Test artifacts +test_*.log diff --git a/PG_TEST/main.tf b/PG_TEST/main.tf new file mode 100644 index 0000000..0d5ec81 --- /dev/null +++ b/PG_TEST/main.tf @@ -0,0 +1,59 @@ +// 2026-04-01 — main.tf: провайдеры и объявления переменных. +// Этот файл не нужно редактировать. Все настройки — в terraform.tfvars. + +terraform { + required_providers { + nubes = { + source = "terra.k8c.ru/nubes/nubes" + version = "5.0.55" + } + } +} + +// ── Объявления переменных ───────────────────────────────────────────────────── +// Значения задаются в terraform.tfvars — не трогать этот файл. + +variable "api_token" { + type = string + sensitive = true + description = "Nubes API token" +} + +variable "s3_uid" { + type = string + sensitive = true + description = "UUID S3-bucket для бэкапов PostgreSQL" +} + +variable "realm" { + type = string + description = "Realm — идентификатор зоны/проекта в Nubes" +} + +variable "pg_resource_name" { + type = string + description = "Имя инстанса PostgreSQL (уникально в рамках realm)" +} + +variable "pg_username" { + type = string + description = "Имя пользователя PostgreSQL" +} + +variable "pg_db_name" { + type = string + description = "Имя создаваемой базы данных" +} + +variable "pg_role" { + type = string + description = "Роль пользователя" +} + +// ── Провайдер ───────────────────────────────────────────────────────────────── + +provider "nubes" { + api_token = var.api_token + log_level = "debug" + api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" +} diff --git a/PG_TEST/outputs.tf b/PG_TEST/outputs.tf new file mode 100644 index 0000000..020107c --- /dev/null +++ b/PG_TEST/outputs.tf @@ -0,0 +1,42 @@ +// 2026-04-01 — outputs.tf: данные подключения к PostgreSQL после apply. +// +// Пароль не выводим напрямую — только через sensitive output (не появляется +// в логах CI по умолчанию). Для явного показа: terraform output pg_password + +output "pg_instance_id" { + description = "ID инстанса PostgreSQL в Nubes" + value = nubes_postgres.pg_test_instance.id +} + +output "pg_host" { + description = "Внутренний адрес master-ноды PostgreSQL" + value = local.pg_host +} + +output "pg_port" { + description = "Порт PostgreSQL" + value = local.pg_port +} + +output "pg_database" { + description = "Имя базы данных" + value = nubes_postgres_database.pg_test_db.db_name +} + +output "pg_username" { + description = "Имя пользователя PostgreSQL" + value = nubes_postgres_user.pg_test_user.username +} + +output "pg_password" { + description = "Пароль пользователя из vault_secrets (пустой на первом apply — заполнится на следующем)" + value = local.pg_password + sensitive = true +} + +// Удобная строка подключения — для psql или приложений. +output "pg_dsn" { + description = "DSN для подключения: postgresql://user:pass@host:port/db" + value = "postgresql://${nubes_postgres_user.pg_test_user.username}:${local.pg_password}@${local.pg_host}:${local.pg_port}/${nubes_postgres_database.pg_test_db.db_name}" + sensitive = true +} diff --git a/PG_TEST/postgres.tf b/PG_TEST/postgres.tf new file mode 100644 index 0000000..69f0490 --- /dev/null +++ b/PG_TEST/postgres.tf @@ -0,0 +1,99 @@ +// 2026-04-01 — postgres.tf: Managed PostgreSQL инстанс, пользователь и база данных. +// +// Порядок создания: +// 1. nubes_postgres — сам инстанс PostgreSQL +// 2. nubes_postgres_user — пользователь; пароль автоматически попадает в vault_secrets +// 3. nubes_postgres_database — база данных с owner = созданный пользователь +// +// Важно: vault_secrets["users"] появляется только ПОСЛЕ первого apply (нет пользователя — нет ключа). +// try() в locals страхует от ошибки на первом прогоне. + +// ── Locals: credentials из vault ───────────────────────────────────────────── + +locals { + # Карта username→{password, username} из vault_secrets, который Nubes заполняет после + # создания пользователя. try() нужен для первого apply, когда ключа ещё нет. + pg_creds_map = try( + jsondecode(lookup(nubes_postgres.pg_test_instance.vault_secrets, "users", "{}")), + {} + ) + pg_password = try(local.pg_creds_map[var.pg_username]["password"], "") + + # Адрес master-ноды (внутренний — для подключения из кластера). + pg_host = nubes_postgres.pg_test_instance.state_out_flat["internalConnect.master"] + pg_port = 5432 +} + +// ── Инстанс PostgreSQL ──────────────────────────────────────────────────────── + +resource "nubes_postgres" "pg_test_instance" { + resource_name = var.pg_resource_name + s3_uid = var.s3_uid + resource_realm = var.realm + + # Минимальные ресурсы — достаточно для тестирования. + resource_instances = 1 + resource_memory = 512 # MiB + resource_c_p_u = 500 # millicores + resource_disk = "1" # GiB + app_version = "17" + + # json_parameters убран — при передаче пустого объекта API возвращает "Invalid JSON String". + # Если нужны кастомные параметры PG — добавить после диагностики. + + # Pooler не нужен для тестов — упрощает топологию. + enable_pg_pooler_master = false + enable_pg_pooler_slave = false + + allow_no_s_s_l = false + auto_scale = false + auto_scale_percentage = 10 + auto_scale_tech_window = 0 + auto_scale_quota_gb = "1" + + # Внешний адрес не нужен — подключаемся изнутри кластера. + need_external_address_master = false + + operation_timeout = "11m" + + # Позволяет импортировать уже существующий инстанс с тем же именем, не падая + # с "already exists" — удобно при повторном apply после ручного создания. + adopt_existing_on_create = true +} + +// ── Пользователь ────────────────────────────────────────────────────────────── + +resource "nubes_postgres_user" "pg_test_user" { + postgres_id = nubes_postgres.pg_test_instance.id + username = var.pg_username + role = var.pg_role + + # Не падать если пользователь с таким именем уже существует. + adopt_existing_on_create = true +} + +resource "nubes_postgres_user" "pg_test_user3" { + postgres_id = nubes_postgres.pg_test_instance.id + username = "u3" + role = var.pg_role + + depends_on = [nubes_postgres_user.pg_test_user] + # Не падать если пользователь с таким именем уже существует. + adopt_existing_on_create = true +} + +// ── База данных ─────────────────────────────────────────────────────────────── + +resource "nubes_postgres_database" "pg_test_db" { + postgres_id = nubes_postgres.pg_test_instance.id + db_name = var.pg_db_name + db_owner = nubes_postgres_user.pg_test_user.username + + # Не падать если БД уже существует. + adopt_existing_on_create = true + + # ВАЖНО: из-за ограничения API Nubes (ERR-PG-08: "Concurrent operations are not supported") + # нужно явно ждать пользователя даже если он не выглядит dependency. + # других ресурс на инстансе ещё обрабатывает операции. + depends_on = [nubes_postgres_user.pg_test_user3] +} diff --git a/PG_TEST/terraform.tfvars.example b/PG_TEST/terraform.tfvars.example new file mode 100644 index 0000000..6c7fb83 --- /dev/null +++ b/PG_TEST/terraform.tfvars.example @@ -0,0 +1,54 @@ +# ============================================================================= +# 2026-04-01 — terraform.tfvars +# +# ЕДИНСТВЕННЫЙ файл, который нужно заполнить перед запуском. +# Остальные .tf-файлы не трогать. +# +# Как запустить: +# 1. Скопировать этот файл: cp terraform.tfvars.example terraform.tfvars +# 2. Заполнить три обязательных поля ниже (ЗАПОЛНИТЬ) +# 3. terraform init +# 4. terraform apply +# +# После apply — увидеть данные подключения: +# terraform output pg_host +# terraform output pg_database +# terraform output pg_username +# terraform output -raw pg_password # пароль (показывается явно только с -raw) +# terraform output -raw pg_dsn # полная строка подключения +# ============================================================================= + + +# ============================================================================= +# ОБЯЗАТЕЛЬНО ЗАПОЛНИТЬ +# ============================================================================= + +# API-токен из личного кабинета Nubes. +# Где взять: https://deck-test.ngcloud.ru/ → Профиль → API-токены +api_token = "ЗАПОЛНИТЬ" + +# UUID вашего S3-бакета — нужен PostgreSQL для хранения бэкапов. +# Пример: "332cdb0d-****-43bf-****-4adcc3b5****" +s3_uid = "ЗАПОЛНИТЬ" + +# Realm — идентификатор вашей зоны/проекта. +# Пример: "k8s-3-sandbox-nubes-ru" +realm = "ЗАПОЛНИТЬ" + + +# ============================================================================= +# МОЖНО ОСТАВИТЬ КАК ЕСТЬ (изменить при необходимости) +# ============================================================================= + +# Имя PostgreSQL-инстанса в Nubes. +# Должно быть уникальным в рамках realm. Менять если создаёте несколько стендов. +pg_resource_name = "pg-test-01" + +# Имя пользователя базы данных. +pg_username = "pgtest_user" + +# Имя базы данных. +pg_db_name = "pgtest_db" + +# Роль пользователя. +pg_role = "ddl_user" diff --git a/PG_TEST/test_basic.sh b/PG_TEST/test_basic.sh new file mode 100644 index 0000000..a4a5a40 --- /dev/null +++ b/PG_TEST/test_basic.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# 2026-04-01 — test_basic.sh: простая проверка что ресурсы созданы и outputs заполнены. +# Не делает apply/destroy — только читает state и outputs. +# Запуск: bash test_basic.sh + +set -uo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" + +GREEN="\033[0;32m"; RED="\033[0;31m"; NC="\033[0m" +PASS=0; FAIL=0 + +ok() { echo -e "${GREEN}PASS${NC} $1"; PASS=$((PASS+1)); } +fail() { echo -e "${RED}FAIL${NC} $1"; FAIL=$((FAIL+1)); } + +echo "=== PG_TEST basic check — $(date '+%Y-%m-%d %H:%M:%S') ===" +echo "" + +# ── 1. Нужные ресурсы есть в state ─────────────────────────────────────────── +echo "--- state ---" +for res in \ + "nubes_postgres.pg_test_instance" \ + "nubes_postgres_user.pg_test_user" \ + "nubes_postgres_database.pg_test_db" +do + if terraform state show "$res" > /dev/null 2>&1; then + ok "state: $res" + else + fail "state: $res — не найден" + fi +done + +echo "" + +# ── 2. Outputs непустые ─────────────────────────────────────────────────────── +echo "--- outputs ---" + +pg_host=$(terraform output -raw pg_host 2>/dev/null || true) +pg_db=$(terraform output -raw pg_database 2>/dev/null || true) +pg_user=$(terraform output -raw pg_username 2>/dev/null || true) +pg_pass=$(terraform output -raw pg_password 2>/dev/null || true) + +[[ -n "$pg_host" ]] && ok "pg_host = $pg_host" || fail "pg_host пустой" +[[ -n "$pg_db" ]] && ok "pg_database = $pg_db" || fail "pg_database пустой" +[[ -n "$pg_user" ]] && ok "pg_username = $pg_user" || fail "pg_username пустой" +[[ -n "$pg_pass" ]] && ok "pg_password непустой" || fail "pg_password пустой (возможно нужен повторный apply)" + +echo "" +echo "=== Итог: PASS=$PASS FAIL=$FAIL ===" diff --git a/PG_TEST/test_lifecycle.sh b/PG_TEST/test_lifecycle.sh new file mode 100644 index 0000000..b576853 --- /dev/null +++ b/PG_TEST/test_lifecycle.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# 2026-04-01 — test_lifecycle.sh +# Гоняет реальный API Nubes: создание/удаление/модификация пользователей и БД. +# Каждый шаг — отдельный terraform apply с живым выводом. +# Запуск: bash test_lifecycle.sh + +set -uo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" + +GREEN="\033[0;32m"; RED="\033[0;31m"; YELLOW="\033[1;33m"; CYAN="\033[0;36m"; NC="\033[0m" +PASS=0; FAIL=0 + +ok() { echo -e "\n${GREEN}>>> PASS${NC} $1"; PASS=$((PASS+1)); } +fail() { echo -e "\n${RED}>>> FAIL${NC} $1"; FAIL=$((FAIL+1)); } +section() { echo -e "\n${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n $1\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; } +step() { echo -e "\n${CYAN}--- $1 ---${NC}"; } + +# run_apply — terraform apply с живым выводом в терминал. +run_apply() { + echo "" + terraform apply -auto-approve + return $? +} + +# run_apply_expect_fail — apply должен упасть (ошибка API = успех теста). +run_apply_expect_fail() { + local label="$1" + echo "" + if terraform apply -auto-approve; then + fail "$label — ожидали ошибку API, но apply прошёл!" + else + ok "$label — API вернул ошибку (ожидаемо)" + fi +} + +echo -e "\n${YELLOW}╔══════════════════════════════════════════════════════╗" +echo "║ PG_TEST lifecycle — $(date '+%Y-%m-%d %H:%M:%S') ║" +echo -e "╚══════════════════════════════════════════════════════╝${NC}" + +# ───────────────────────────────────────────────────────────────────────────── +section "ШАГ 0 — Очистка: destroy всего перед стартом" +# ───────────────────────────────────────────────────────────────────────────── +# Гарантируем чистый старт — убираем все ресурсы и state. +step "terraform destroy (убираем всё что осталось от предыдущих прогонов)" +terraform destroy -auto-approve || true # не падаем если уже пусто + +# ───────────────────────────────────────────────────────────────────────────── +section "ШАГ 1 — Создать: 2 пользователя + 2 БД + 1 app_user" +# ───────────────────────────────────────────────────────────────────────────── +step "terraform apply (postgres.tf + postgres_extra.tf)" +if run_apply; then + ok "Создание прошло" +else + fail "Создание упало — дальше не идём" + exit 1 +fi +echo ""; echo "Ресурсы в state:"; terraform state list | grep -v pg_test_instance + +# ───────────────────────────────────────────────────────────────────────────── +section "ШАГ 2 — Удалить extra_user2 и extra_db2" +# ───────────────────────────────────────────────────────────────────────────── +step "Убираем test_extra_user2 и test_extra_db2 из tf" +python3 - <<'PYEOF' +import re, pathlib + +def comment_block(path, resource_type, resource_name): + text = pathlib.Path(path).read_text() + pattern = rf'(resource\s+"{re.escape(resource_type)}"\s+"{re.escape(resource_name)}"\s*\{{)' + match = re.search(pattern, text) + if not match: + print(f" WARNING: {resource_type}.{resource_name} not found"); return + start = match.start(); depth, i = 0, start + while i < len(text): + if text[i] == '{': depth += 1 + elif text[i] == '}': + depth -= 1 + if depth == 0: end = i + 1; break + i += 1 + pathlib.Path(path).write_text( + text[:start] + "/* DISABLED\n" + text[start:end] + "\nDISABLED */" + text[end:] + ) + print(f" скрыт: {resource_type}.{resource_name}") + +comment_block("postgres_extra.tf", "nubes_postgres_user", "test_extra_user2") +comment_block("postgres_extra.tf", "nubes_postgres_database", "test_extra_db2") +PYEOF + +step "terraform apply — API удаляет user2 и db2" +if run_apply; then ok "Удаление прошло"; else fail "Удаление упало"; fi +echo ""; echo "Ресурсы в state:"; terraform state list | grep -v pg_test_instance + +# ───────────────────────────────────────────────────────────────────────────── +section "ШАГ 3 — Воссоздать extra_user2 и extra_db2" +# ───────────────────────────────────────────────────────────────────────────── +step "Восстанавливаем tf" +python3 - <<'PYEOF' +import pathlib, re +p = pathlib.Path("postgres_extra.tf") +text = re.sub(r'/\* DISABLED\n', '', p.read_text()) +text = re.sub(r'\nDISABLED \*/', '', text) +p.write_text(text); print(" postgres_extra.tf восстановлен") +PYEOF + +step "terraform apply — API воссоздаёт user2 и db2" +if run_apply; then ok "Воссоздание прошло"; else fail "Воссоздание упало"; fi +echo ""; echo "Ресурсы в state:"; terraform state list | grep -v pg_test_instance + +# ───────────────────────────────────────────────────────────────────────────── +section "ШАГ 4 — Модификация: сменить db_owner у extra_db1" +# ───────────────────────────────────────────────────────────────────────────── +step "db_owner test_extra_db1: extra_user1 → extra_user2" +python3 - <<'PYEOF' +import pathlib +p = pathlib.Path("postgres_extra.tf") +text = p.read_text().replace( + 'nubes_postgres_user.test_extra_user1.username', + 'nubes_postgres_user.test_extra_user2.username', 1) +p.write_text(text); print(" db_owner: user1 → user2") +PYEOF + +step "terraform apply — API обновляет db_owner" +if run_apply; then ok "Смена db_owner прошла"; else fail "Смена db_owner упала"; fi + +step "Откат db_owner обратно (user2 → user1)" +python3 - <<'PYEOF' +import pathlib +p = pathlib.Path("postgres_extra.tf") +text = p.read_text().replace( + 'nubes_postgres_user.test_extra_user2.username', + 'nubes_postgres_user.test_extra_user1.username', 1) +p.write_text(text); print(" db_owner: user2 → user1") +PYEOF +run_apply > /dev/null 2>&1 || true + +# ───────────────────────────────────────────────────────────────────────────── +section "ШАГ 5 — Невалидные параметры: ждём ошибку API" +# ───────────────────────────────────────────────────────────────────────────── +step "Тест 5a: db_owner = несуществующий пользователь" +cat > ./pg_test_invalid.tf <<'TFEOF' +resource "nubes_postgres_database" "test_invalid_owner" { + postgres_id = nubes_postgres.pg_test_instance.id + db_name = "invalid_owner_db" + db_owner = "this_user_does_not_exist" + adopt_existing_on_create = false +} +TFEOF +run_apply_expect_fail "5a: db_owner='this_user_does_not_exist'" +rm -f ./pg_test_invalid.tf; run_apply > /dev/null 2>&1 || true + +step "Тест 5b: role = несуществующая строка" +cat > ./pg_test_invalid.tf <<'TFEOF' +resource "nubes_postgres_user" "test_invalid_role" { + postgres_id = nubes_postgres.pg_test_instance.id + username = "invalid_role_user" + role = "fantasy_role_xyz" + adopt_existing_on_create = false +} +TFEOF +run_apply_expect_fail "5b: role='fantasy_role_xyz'" +rm -f ./pg_test_invalid.tf; run_apply > /dev/null 2>&1 || true + +# ───────────────────────────────────────────────────────────────────────────── +section "ШАГ 6 — app_user пытается стать db_owner" +# ───────────────────────────────────────────────────────────────────────────── +# app_user — базовые права. Нельзя быть db_owner — это прерогатива ddl_user. +step "Тест 6a: test_app_user (role=app_user) назначается db_owner" +cat > ./pg_test_invalid.tf <<'TFEOF' +resource "nubes_postgres_database" "test_appuser_as_owner" { + postgres_id = nubes_postgres.pg_test_instance.id + db_name = "appuser_owned_db" + db_owner = nubes_postgres_user.test_app_user.username + adopt_existing_on_create = false +} +TFEOF +run_apply_expect_fail "6a: app_user как db_owner — API должен отклонить" +rm -f ./pg_test_invalid.tf; run_apply > /dev/null 2>&1 || true + +# ───────────────────────────────────────────────────────────────────────────── +section "ИТОГ" +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo -e " PASS: ${GREEN}${PASS}${NC} FAIL: ${RED}${FAIL}${NC}" +echo "" +[[ "$FAIL" -eq 0 ]] \ + && echo -e "${GREEN}Все тесты прошли.${NC}" \ + || echo -e "${RED}Есть ошибки — проверь вывод выше.${NC}"