diff --git a/01_generate_yamls.sh b/01_generate_yamls.sh deleted file mode 100644 index a3e228d..0000000 --- a/01_generate_yamls.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="${ROOT_DIR:-${SCRIPT_DIR}}" -PROVIDER_DIR="${ROOT_DIR}/universal_rebuild" -SERVICES_FILE="${SERVICES_FILE:-${ROOT_DIR}/devops/config/services_list.txt}" - -TOKEN_FILE="${TOKEN_FILE:-}" -NUBES_API_TOKEN="${NUBES_API_TOKEN:-}" - -if [[ -z "$NUBES_API_TOKEN" ]]; then - if [[ -z "$TOKEN_FILE" ]]; then - TOKEN_FILE=$(ls -t /home/naeel/terra/*.token 2>/dev/null | head -n 1 || true) - fi - if [[ -n "$TOKEN_FILE" && -f "$TOKEN_FILE" ]]; then - NUBES_API_TOKEN=$(cat "$TOKEN_FILE") - fi -fi - -if [[ -z "$NUBES_API_TOKEN" ]]; then - echo "Error: NUBES_API_TOKEN or TOKEN_FILE is required" >&2 - exit 2 -fi - -if [[ ! -f "$SERVICES_FILE" ]]; then - echo "Error: services file not found: $SERVICES_FILE" >&2 - exit 2 -fi - -API_ENDPOINT="${NUBES_API_ENDPOINT:-https://deck-api.ngcloud.ru/api/v1/index.cfm}" - -while IFS= read -r sid; do - sid="${sid//[$'\t'\r' ']}" - if [[ -z "$sid" ]]; then - continue - fi - if [[ "$sid" == \#* ]]; then - continue - fi - svc_name=$(python3 - </` (Proxy logic handles mapping to S3 keys). -- **Branding:** Documentation uses MkDocs Material with Nubes "Strict" style (Logo-only header, Blue/Indigo palette). -- **CI Secrets:** `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET`, `REGISTRY_HOSTNAME`. -- **History & Discovery:** See `docs/history/09_s3_migration_and_branded_docs.md`, `docs/discovery/s3-storage-and-docs-architecture.md` and `docs/discovery/documentation_build_and_deploy.md` for full context on this transition and build processes. ---- - -## docs/ -- `docs/README.md` — Главный индекс документации (быстрые ссылки на критические темы). -- `docs/00_overview/` — Обзор, архитектура, чеклисты. -- `docs/20_discovery/` — Discovery-материалы по сервисам. -- `docs/30_registry/` — Реестр и ресурсы. -- `docs/40_analysis/` — Анализ, форензика, инциденты. -- `docs/50_history/` — История изменений. -- `docs/60_strategy/` — Стратегия и принципы. -- `docs/70_api/` — API-материалы и дампы. - - `docs/70_api/api_analysis/` — API reverse-engineering outputs and machine-generated API documentation. - - -## tools/har/ -- `analyze_f12_har.py` — Script to parse `f12vmbad.har` and extract operation timelines. -- `analyze_faststart.py` — Analysis helper for `faststart.har`. -- `extract_params.py` — Extracts `instanceOperationCfsParams` bodies and maps them to svc IDs. -- `compare_har.py` — Diffing tool for HAR files (stages/headers summary). -- `check_op.py` / `check_status.py` — Helpers to re-query API operation status. - -## har/ -- `f12vmbad.har`, `f12vmbad1.har`, `faststart.har`, `edge.har`, `vm.har`, etc. — Raw HAR files. **Large and sensitive**; don't commit new raw HARs to repo without consent. Use `tools/har/*` scripts to parse. - -## artifacts/ -- `edge_instance.json`, `service_22_detail.json`, `instances_list.json` — Live API JSON dumps captured during investigation. Use them to craft provider schemas. - -## internal/provider/ -- `*resource.go`, `*_data_source.go` — Provider implementations for resources (VM, VDC, Edge, VApp, Postgres, S3bucket, etc.). Important files: - - `vm_resource.go` — VM lifecycle logic & polling - - `edge_resource.go` — Edge discovery/resolution - - `provider.go` — Provider schema and configuration - -## examples/ & tests/ -- `examples/*` — Terraform example configs for different deployment patterns (full-stack, infra-only, quick-start). -- `tests/*` — Automated test scenarios (lifecycle, postgres modify tests, s3 tests). Use to validate changes. - -## operator/ & k8s/ -- Operator code and Kubernetes manifests to run the Registry/Operator. Contains build artifacts in `operator/bin/`. - -## scripts/ -- `scripts/deploy-dev.sh` — Dev deployment helpers. - ---- - -## Recommended reading order for agents -1. Read `REPO_CONTENTS.md` at chat start (and re-read when unclear context appears). -2. For build/deploy/generation tasks: read `devops/README.md` and relevant scripts in `devops/` first. -3. Read `docs/60_strategy/provider_philosophy.md` (обязательные правила поведения провайдера и агентов; сначала разделы 7-9). -4. Read `docs/README.md` (главный индекс). -5. Read `docs/70_api/api_analysis/API_DOCUMENTATION_SUMMARY.md` to understand API endpoints. -6. Read `docs/20_discovery/*` relevant to the target service (e.g., `edge-service.md`). -7. Inspect `artifacts/*` JSON examples and `har/*` only via `tools/har/*` scripts. - ---- - -If you want, I can add more granular mapping (function names, exported symbols) per source file, or generate a Markdown tree with intra-file links. Tell me the level of detail you want: "brief", "detailed", or "code-level". \ No newline at end of file diff --git a/REPO_INVENTORY.md b/REPO_INVENTORY.md deleted file mode 100644 index e4ee534..0000000 --- a/REPO_INVENTORY.md +++ /dev/null @@ -1,126 +0,0 @@ -# REPO INVENTORY — 01.07.2026 - -Полная карта репозитория `/home/naeel/tf_provider`: структура, версии, профили, публикации. - ---- - -## 1. Директории - -| Путь | Назначение | Файлы | -|------|-----------|-------| -| `/` | Корень Go-модуля `terraform-provider-mycloud` (Legacy провайдер) | `main.go`, `go.mod`, `go.sum` | -| `internal/` | Исходный код Legacy провайдера (ручные ресурсы) | 25 `.go` файлов | -| `internal/core/` | Ядро Legacy: HTTP-клиент, lookup инстансов, операции | 3 файла | -| `internal/generated/` | Сгенерированные ресурсы болванки (3 файла) | 3 `.go` | -| `internal/provider/` | 12 ручных ресурсов + дата-сорсы | 17 `.go` | -| `internal/registrykeys/` | Пусто | — | -| `universal_rebuild/` | **Universal провайдер** (главный, генерируемый) | Go-модуль | -| `universal_rebuild/internal/core/` | Универсальное ядро: клиент, операции, реф-резолв | 10 файлов | -| `universal_rebuild/internal/provider/` | Provider entrypoint + таймауты | 3 файла | -| `universal_rebuild/internal/resources_core/` | CRUD-логика, state, params | 22 файла | -| `universal_rebuild/internal/resources_gen/` | **Сгенерированные ресурсы** (82 файла) | 82 `.go` | -| `universal_rebuild/resources_yaml/` | **YAML-спеки** (42 файла) — source of truth | 42 `.yaml` | -| `universal_rebuild/tools/` | Инструменты генерации | 5 тулов | -| `universal_rebuild/tools/service_spec_gen/` | API → YAML (основной генератор) | 1 `.go` | -| `universal_rebuild/tools/gen_v2/` | YAML → Go (основной генератор) | 1 `.go` | -| `universal_rebuild/tools/docs_template_gen_v2/` | YAML → Документация | 1 `.go` | -| `universal_rebuild/tools/docs_template_gen/` | Docs gen v1 | 1 `.go` | -| `universal_rebuild/tools/ops_docs_gen/` | Документация операций | 1 `.go` | -| `devops/` | DevOps скрипты и CI | ~20 файлов | -| `devops/profiles/prod/` | **PROD профиль** (версия 2.1.23) | profile.env, services_list, timeouts | -| `devops/profiles/test/` | **TEST профиль** (версия 5.0.55) | profile.env, services_list, timeouts | -| `devops/profiles/dev/` | **DEV профиль** (версия 3.0.1) | profile.env, services_list, timeouts | -| `devops/config/` | Мастер-список сервисов + таймауты | 2 файла | -| `devops/ci/` | GitLab CI пайплайн | 1 файл | -| `docs/` | **MkDocs документация** | ~600+ MD файлов | -| `docs/30_registry/resources/` | **Реестр ресурсов** (документация по каждому сервису) | ~500+ | -| `docs/50_history/` | История разработки (24 спринта) | 24 `.md` | -| `docs/ops/` | Операционные документы (runbook, troubleshooting) | 9 `.md` | -| `cloud-dashboard/` | FastAPI дашборд мониторинга | Python + K8s | -| `HAR/` | HTTP-трассировки API (8 файлов) | 8 `.har` | -| `HISTORY/OPUS/` | История работы с AI-ассистентом Opus | 7 `.md` | -| `PROD_STAND/` | Terraform-конфиги продакшен-стенда | ~20 файлов | -| `TEST_STAND/` | Terraform-конфиги тестового стенда | ~15 файлов | -| `RABBIT/` | Terraform-конфиги RabbitMQ | 2 `.tf` | -| `tools/` | Вспомогательные утилиты | ~15 файлов | -| `secrets/` | Токены и ключи (в gitignore) | 6 файлов | -| `scripts/` | Скрипты деплоя | 5 файлов | -| `charts/` | Пусто (под Helm-чарты) | — | -| `.github/` | GitHub Actions + Copilot инструкции | 4 файла | - ---- - -## 2. Версии — полная матрица - -### 2.1. Код - -| Компонент | Путь | Версия | -|-----------|------|--------| -| **Universal провайдер** | `universal_rebuild/main.go` | **5.0.56** | -| **Legacy провайдер** | `main.go` | **5.0.52** | - -Оба кода указывают на один registry-адрес `terra.k8c.ru/nubes/nubes`. - -### 2.2. Профили (devops/profiles/) - -| Стенд | Файл | RELEASE_VERSION | PROVIDER_VERSION | DOCS_VERSION | -|-------|------|-----------------|------------------|--------------| -| **PROD** | `prod/profile.env` | **2.1.23** | **2.1.23** | **2.1.23** | -| **TEST** | `test/profile.env` | **5.0.55** | **5.0.55** | **5.0.55** | -| **DEV** | `dev/profile.env` | **3.0.1** | **3.0.1** | **3.0.1** | - -### 2.3. Registry (terra.k8c.ru — опубликованное) - -| Линейка | Количество | Диапазон | Последняя | -|---------|-----------|----------|-----------| -| **2.x** (Legacy) | 35 версий | 2.0.0 → **2.12.13** | 2.12.13 | -| **5.x** (Universal) | 52 версии | 5.0.1 → **5.0.55** | 5.0.55 | - -**Всего на регистре: 87 версий.** - -### 2.4. Документация (опубликованная) - -| Версия | Статус | -|--------|--------| -| `2.0.0` | ✅ 200 | -| `2.1.2` | ✅ 200 | -| `2.1.23` | ✅ 200 | -| `5.0.1` | ✅ 200 | -| `5.0.51` | ✅ 200 | -| `5.0.55` | ❌ 404 | -| `5.0.56` | ❌ 404 | - -**Проблема:** документация для 5.x публиковалась не для всех версий. 5.0.1 и 5.0.51 есть, 5.0.55 — нет. - ---- - -## 3. Что не синхронизировано - -### Разрыв между кодом и профилями - -| Что | Код | PROD профиль | Registry | Разрыв | -|-----|-----|-------------|----------|--------| -| Universal | 5.0.56 | 5.0.55 (test) | 5.0.55 | +1 | -| Legacy | 5.0.52 | 2.1.23 (prod) | 2.12.13 | -? | - -- **Universal код (5.0.56) новее registry-версии (5.0.55).** Наши правки P0-P2 + регенерация 88 ресурсов не опубликованы. -- **PROD профиль (2.1.23) использует Legacy.** Universal (5.x) развёрнут только на TEST. -- **DEV профиль (3.0.1)** — непонятная версия, не совпадает ни с кодом, ни с registry. - -### Разрыв между услугами в PROD и TEST - -| Файл | PROD | TEST | -|------|------|------| -| `services_list.txt` | 27 активных | 31 активный | -| Отличия | Нет: `vcOrgSaas`, `vcexternalip`, `superset`, `k8sZitiController`, ... | Есть все | - -PROD использует меньше сервисов (старый список), TEST — полный. - ---- - -## 4. Итого - -1. Universal (5.0.56) — самая свежая версия кода, но **не опубликована** -2. PROD стоит на Legacy 2.1.23 — старом ручном провайдере -3. Документация для 5.x публикуется нерегулярно (дыры) -4. DEV профиль (3.0.1) — не используется, версия ничему не соответствует diff --git a/check_ops.py b/check_ops.py deleted file mode 100644 index 65e8093..0000000 --- a/check_ops.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -# Проверка операций на инстансе pg-test-02 (710C1C21) -import json, urllib.request - -TFVARS = "/home/naeel/terra/sless/examples/PG_TEST/terraform.tfvars" -INSTANCE_UID = "710C1C21-B9E8-4973-8DE4-EAA796ADDA00" -API = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" - -# Читаем токен из tfvars -token = "" -for line in open(TFVARS): - if "api_token" in line: - token = line.split('"')[1] - break - -def get(path): - req = urllib.request.Request(f"{API}{path}", headers={"Authorization": f"Bearer {token}"}) - return json.loads(urllib.request.urlopen(req, timeout=10).read()) - -# Операции на инстансе -print("=== Операции на инстансе ===") -ops = get(f"/instanceOperations?instanceUid={INSTANCE_UID}") -items = ops if isinstance(ops, list) else ops.get("data", []) -for i in items[:10]: - print(f" uid={i.get('uid')} op={i.get('svcOperation')} status={i.get('status')} created={i.get('createdAt')}") - -# Статус инстанса -print("\n=== Статус инстанса ===") -inst = get(f"/instances/{INSTANCE_UID}") -print(f" name={inst.get('name') or inst.get('displayName')} state={inst.get('state') or inst.get('instanceState')} status={inst.get('status') or inst.get('instanceStatus')}") -# Все ключи инстанса для диагностики -print(" DEBUG keys:", list(inst.keys())[:15]) - -# Поиск по имени через search API (как делает провайдер) -print("\n=== Поиск pg-test-02 через search API ===") -search_res = get("/instances?fields=instanceUid,displayName,serviceId&page=1&pageSize=100&search=pg-test-02&isAuxiliary=false&isDeleted=false") -results = search_res if isinstance(search_res, list) else search_res.get("results", search_res.get("data", [])) -for i in results: - uid = i.get("instanceUid") or i.get("uid") - print(f" uid={uid} name={i.get('displayName') or i.get('name')} svcId={i.get('serviceId')} state={i.get('state')}") - -# Пагинированный список всех инстансов (первая страница) -print("\n=== Все инстансы (страница 1, size=100) ===") -all_inst = get("/instances?page=1&size=100") -all_items = all_inst if isinstance(all_inst, list) else all_inst.get("data", all_inst.get("results", [])) -pg_found = 0 -for i in all_items: - name = i.get("displayName") or i.get("name") or "" - if "pg-test" in name.lower(): - uid = i.get("instanceUid") or i.get("uid") - print(f" uid={uid} name={name} state={i.get('state')} status={i.get('status')}") - pg_found += 1 -if pg_found == 0: - print(f" pg-test инстансов не найдено (всего в ответе: {len(all_items)} записей)") - # Печатаем первые 3 для отладки структуры - for i in all_items[:3]: - print(f" DEBUG: keys={list(i.keys())[:8]}") diff --git a/instance_fca3f912.json b/instance_fca3f912.json deleted file mode 100644 index 5600894..0000000 --- a/instance_fca3f912.json +++ /dev/null @@ -1 +0,0 @@ -{"queryDurationMs":134,"instance":{"instanceUid":"fca3f912-c32f-49f9-9cba-40fbaefced63","displayName":"lucy1","serviceId":94,"descr":"Created via Terraform Universal Provider","specificationItemId":1705,"isAuxiliary":false,"instanceConfigDtCreated":"2026-02-13T16:06:51.978+0300","instanceConfigDtUpdated":"2026-02-13T16:06:51.978+0300","contractId":4,"specificationId":4,"specification":"2","quantity":1,"price":null,"svc":"Lucee","code":null,"man":null,"updaterId":7594,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","resourceRealm":"k8s-3.ext.nubes.ru","operationIsInProgress":false,"operationIsPending":false,"uptime":0,"explainedStatus":"running","resourceRealmCnt":2,"isCreated":true,"isDeleted":false,"state":{"instanceStateUid":"1f8e98d0-a2ad-4188-b965-a8ec874cc34b","version":15,"creatorId":7594,"dtState":"2026-02-14T09:40:10.817+0300","isTest":true,"instanceOperationUid":"9c1554b8-5023-4aef-85b1-21f6afc6260a","out":{"monitoring":{"base":{"url":"https://grafana.ngcloud.ru/d/fca3f912-c32f-49f9-9cba-40fbaefced63-def?orgId=28"},"loki":{"url":"https://grafana.ngcloud.ru/d/fca3f912-c32f-49f9-9cba-40fbaefced63-log?orgId=28"},"allDashboards":"https://grafana.ngcloud.ru/dashboards/f/fca3f912-c32f-49f9-9cba-40fbaefced63?orgId=28"},"connectionUrl":"https://web03.luceek8s.services.ngcloud.ru"},"params":{"domain":"web03","gitPath":"https://github.com/xahys/testlucee","jsonEnv":{"PGHOST":"postgresqlk8s-master.a71094c1-b0ed-4bfb-a264-37b41d279567.svc.k8s-3.ext.nubes.ru","PGPORT":"5432","PGUSER":"postgres","PGSSLMODE":"require","PGPASSWORD":"TMGMCN5GYzx8gj1GiqA4rmf19HEJKgQXSUHcN17hJNnbyZmicfhU5AfFpLQyF22I"},"appVersion":"5.4","healthPath":"","resourceCPU":300,"resourceRealm":"k8s-3.ext.nubes.ru","resourceMemory":512,"resourceInstances":1},"vault":{"url":"https://vault.lk.adl.nubes.ru/v1/deck-tool-prod/data/clients/luceek8s/fca3f912-c32f-49f9-9cba-40fbaefced63?version=15","fields":[],"userPath":"luceek8s/fca3f912-c32f-49f9-9cba-40fbaefced63"},"isDeleted":false,"isSuspended":false},"operations":[{"instanceOperationUid":"db0be830-f61d-46a6-8663-6280ace602c9","instanceStateUid":null,"stateVersion":null,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-14T09:40:47.389+0300","submitResult":"201","dtStart":"2026-02-14T09:40:47.389+0300","dtFinish":"2026-02-14T09:43:37.337+0300","duration":169.948,"secondsPassed":365.3,"isSuccessful":false,"dtCreated":"2026-02-14T09:40:34.638+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"9c1554b8-5023-4aef-85b1-21f6afc6260a","instanceStateUid":"1f8e98d0-a2ad-4188-b965-a8ec874cc34b","stateVersion":15,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-14T09:38:52.735+0300","submitResult":"201","dtStart":"2026-02-14T09:38:52.735+0300","dtFinish":"2026-02-14T09:40:12.280+0300","duration":79.545,"secondsPassed":570.3,"isSuccessful":true,"dtCreated":"2026-02-14T09:38:40.357+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"1e36b5ea-7a54-452e-8ebd-70f0c6aa82e8","instanceStateUid":"c301c5bc-2abd-44e0-a59a-34fc81da4b56","stateVersion":14,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-14T09:34:26.821+0300","submitResult":"201","dtStart":"2026-02-14T09:34:26.821+0300","dtFinish":"2026-02-14T09:35:40.839+0300","duration":74.018,"secondsPassed":841.8,"isSuccessful":true,"dtCreated":"2026-02-14T09:34:12.549+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"c0c60270-3100-4b8b-abe6-356955c65d6d","instanceStateUid":"6644063b-22cb-4a68-b9b2-c0e2ba2539ee","stateVersion":13,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-14T08:39:06.299+0300","submitResult":"201","dtStart":"2026-02-14T08:39:06.299+0300","dtFinish":"2026-02-14T08:39:42.265+0300","duration":35.966,"secondsPassed":4200.3,"isSuccessful":true,"dtCreated":"2026-02-13T21:29:58.266+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"97b6d67b-9476-4c7f-8d77-bfc30aab050f","instanceStateUid":"48c9307a-a73d-4b0b-8ad9-51f6a7aaabdf","stateVersion":12,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T21:29:04.474+0300","submitResult":"201","dtStart":"2026-02-13T21:29:04.473+0300","dtFinish":"2026-02-13T21:29:36.965+0300","duration":32.492,"secondsPassed":44405.6,"isSuccessful":true,"dtCreated":"2026-02-13T21:28:54.690+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"6b6e7072-1b4d-4367-8123-e09dc0a63d5f","instanceStateUid":"5e5d9464-b204-4852-84ed-542c2d52b9f9","stateVersion":11,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T19:17:08.914+0300","submitResult":"201","dtStart":"2026-02-13T19:17:08.914+0300","dtFinish":"2026-02-13T19:17:42.381+0300","duration":33.467,"secondsPassed":52320.2,"isSuccessful":true,"dtCreated":"2026-02-13T19:17:01.113+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"5c5272be-c392-4a39-b0fb-c1aa8bb63907","instanceStateUid":"b1a508a9-bd3c-4720-809e-3617663f16c4","stateVersion":10,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T19:15:30.638+0300","submitResult":"201","dtStart":"2026-02-13T19:15:30.638+0300","dtFinish":"2026-02-13T19:16:03.443+0300","duration":32.805,"secondsPassed":52419.2,"isSuccessful":true,"dtCreated":"2026-02-13T19:15:23.401+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"63e98213-0e7a-4fbb-a8b8-2e2375488bd4","instanceStateUid":"af7973a4-5486-4765-886c-187db17fbbc6","stateVersion":9,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T19:13:18.592+0300","submitResult":"201","dtStart":"2026-02-13T19:13:18.592+0300","dtFinish":"2026-02-13T19:13:52.565+0300","duration":33.973,"secondsPassed":52550,"isSuccessful":true,"dtCreated":"2026-02-13T18:53:38.157+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"41767567-e747-4028-83f2-e02d492c434d","instanceStateUid":"2aa22fa6-c143-46b9-86db-c2112aae2f17","stateVersion":8,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T18:51:20.635+0300","submitResult":"201","dtStart":"2026-02-13T18:51:20.635+0300","dtFinish":"2026-02-13T18:52:41.425+0300","duration":80.79,"secondsPassed":53821.2,"isSuccessful":true,"dtCreated":"2026-02-13T18:51:08.062+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"e53d43be-171b-4540-babc-5b9403e7a982","instanceStateUid":"c79eb58d-7fe6-4442-bec5-8c54dc470ad8","stateVersion":7,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T18:43:52.574+0300","submitResult":"201","dtStart":"2026-02-13T18:43:52.573+0300","dtFinish":"2026-02-13T18:44:27.973+0300","duration":35.4,"secondsPassed":54314.6,"isSuccessful":true,"dtCreated":"2026-02-13T18:43:44.492+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"9a11e25d-6dc1-447c-b8c0-22b172455d62","instanceStateUid":"4eeac614-9497-455e-b4c8-6b6e06a62cf7","stateVersion":6,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T18:41:17.999+0300","submitResult":"201","dtStart":"2026-02-13T18:41:17.999+0300","dtFinish":"2026-02-13T18:42:59.165+0300","duration":101.166,"secondsPassed":54403.4,"isSuccessful":true,"dtCreated":"2026-02-13T18:41:08.248+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"50a3f864-97fc-4e45-b92b-b9537646e6a7","instanceStateUid":"9629e035-02fb-45a4-8896-000184f5a020","stateVersion":5,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T18:32:36.412+0300","submitResult":"201","dtStart":"2026-02-13T18:32:36.412+0300","dtFinish":"2026-02-13T18:33:10.212+0300","duration":33.8,"secondsPassed":54992.4,"isSuccessful":true,"dtCreated":"2026-02-13T18:32:20.849+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"be06ab86-1a48-4c13-b4b2-4e3ed95d7da4","instanceStateUid":"f544dc74-d4ad-4380-90f3-1bf1d3d70110","stateVersion":4,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T18:28:20.245+0300","submitResult":"201","dtStart":"2026-02-13T18:28:20.244+0300","dtFinish":"2026-02-13T18:28:53.651+0300","duration":33.407,"secondsPassed":55248.9,"isSuccessful":true,"dtCreated":"2026-02-13T18:28:11.225+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"e40443e0-0891-41b1-8965-978eed5ba6d3","instanceStateUid":null,"stateVersion":null,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T18:14:36.753+0300","submitResult":"201","dtStart":"2026-02-13T18:14:36.753+0300","dtFinish":"2026-02-13T18:25:40.744+0300","duration":663.991,"secondsPassed":55441.9,"isSuccessful":false,"dtCreated":"2026-02-13T18:14:28.365+0300","errorLog":"Приложение не запустилось. Производится полный откат установки. Ошибка: jlib.k8s [correctReplicaActive] | ERROR | Под(ы) не работают: 'luceek8s' (Deployment).","note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"75146b18-4b58-4672-ae8e-96f51fd86912","instanceStateUid":"40b24238-b1b2-425b-8ad1-51d0ef56a14e","stateVersion":3,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T18:11:55.353+0300","submitResult":"201","dtStart":"2026-02-13T18:11:55.353+0300","dtFinish":"2026-02-13T18:12:33.597+0300","duration":38.244,"secondsPassed":56229,"isSuccessful":true,"dtCreated":"2026-02-13T18:11:45.024+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"cdc73ec4-51b6-4ef3-9e8d-7e9bccd27340","instanceStateUid":"c4ae70b9-a35b-4b26-84b9-b62514613475","stateVersion":2,"operation":"modify","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T17:59:53.784+0300","submitResult":"201","dtStart":"2026-02-13T17:59:53.783+0300","dtFinish":"2026-02-13T18:00:29.370+0300","duration":35.587,"secondsPassed":56953.2,"isSuccessful":true,"dtCreated":"2026-02-13T17:59:46.242+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false},{"instanceOperationUid":"04563ada-d2a4-40cb-9019-c79906cfc361","instanceStateUid":"c2e2c108-9241-4f7e-8f8d-75c12f578375","stateVersion":1,"operation":"create","resourceRealmId":null,"resourceRealm":null,"dtSubmit":"2026-02-13T17:56:45.645+0300","submitResult":"201","dtStart":"2026-02-13T17:56:45.645+0300","dtFinish":"2026-02-13T17:58:41.256+0300","duration":115.611,"secondsPassed":57061.3,"isSuccessful":true,"dtCreated":"2026-02-13T16:06:52.171+0300","errorLog":null,"note":null,"updaterLogin":"tazet@narod.ru","updaterShortname":" Н. Ф.","isInProgress":false,"isPending":false}],"availableOperations":[{"svcOperationId":50,"operation":"delete","instanceOperationUid":null},{"svcOperationId":53,"operation":"redeploy","instanceOperationUid":null},{"svcOperationId":55,"operation":"modify","instanceOperationUid":null},{"svcOperationId":95,"operation":"resume","instanceOperationUid":null},{"svcOperationId":96,"operation":"suspend","instanceOperationUid":null},{"svcOperationId":163,"operation":"restart","instanceOperationUid":null}],"dependentInstances":[],"dependencies":[]},"runDurationMs":136} \ No newline at end of file diff --git a/internal/core/client.go b/internal/core/client.go deleted file mode 100644 index 012df85..0000000 --- a/internal/core/client.go +++ /dev/null @@ -1,1277 +0,0 @@ -package core - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" - - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -func debugLog(format string, args ...interface{}) { - f, err := os.OpenFile("/home/naeel/terra/debug_nubes.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - fmt.Fprintf(f, time.Now().Format(time.RFC3339)+": "+format+"\n", args...) -} - -// UniversalClient handles Nubes API logic -type UniversalClient struct { - HttpClient *http.Client - ApiEndpoint string - ApiToken string - ProviderVersion string - LogLevel string -} - -// Request models -type genericInstanceReq struct { - ServiceId int `json:"serviceId"` - DisplayName string `json:"displayName"` - Descr string `json:"descr"` -} - -type genericOpReq struct { - InstanceUid string `json:"instanceUid"` - Operation string `json:"operation"` -} - -type genericParamReq struct { - InstanceOperationUid string `json:"instanceOperationUid"` - SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` - ParamValue string `json:"paramValue"` -} - -// Deprecated: Use CreateGenericInstanceUniversalV5 instead. -// This method is kept for backward compatibility and will be removed in v3.0. -func (c *UniversalClient) CreateGenericInstance(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { - // 1. Create Placeholder (POST /instances) - tflog.Debug(ctx, fmt.Sprintf("Creating generic instance placeholder for service %d", serviceId)) - - instPayload := genericInstanceReq{ - ServiceId: serviceId, - DisplayName: displayName, - Descr: "Created via Terraform Universal Provider", - } - - instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) - if err != nil { - return "", err - } - debugLog("Step 1 Resp Body: %s", string(instResp)) - debugLog("Step 1 Resp Headers: %v", instHeaders) - - var instanceUid string - - // Try Location header first - if loc := instHeaders.Get("Location"); loc != "" { - // Location usually looks like "./3A021B21-..." - instanceUid = strings.TrimPrefix(loc, "./") - } - - // If header didn't give us ID, try body - if instanceUid == "" { - var instResult struct { - InstanceUid string `json:"instanceUid"` - } - if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { - instanceUid = instResult.InstanceUid - } else { - // Fallback: maybe the body IS the ID (string)? - var justId string - if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { - instanceUid = justId - } - } - } - - if instanceUid == "" { - return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) - } - - // 2. Init Operation (POST /instanceOperations) - tflog.Debug(ctx, fmt.Sprintf("Initializing create operation for %s", instanceUid)) - opPayload := genericOpReq{ - InstanceUid: instanceUid, - Operation: "create", - } - - opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) - if err != nil { - return "", err - } - debugLog("Step 2 Resp Body: %s", string(opResp)) - debugLog("Step 2 Resp Headers: %v", opHeaders) - - var opUid string - - if loc := opHeaders.Get("Location"); loc != "" { - opUid = strings.TrimPrefix(loc, "./") - } - - if opUid == "" { - var opResult struct { - InstanceOperationUid string `json:"instanceOperationUid"` - } - if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { - opUid = opResult.InstanceOperationUid - } else { - var justId string - if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { - opUid = justId - } - } - } - - if opUid == "" { - return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) - } - - // 3. Send Parameters (POST /instanceOperationCfsParams) - for paramId, value := range params { - tflog.Debug(ctx, fmt.Sprintf("Sending param %d = %s", paramId, value)) - - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: paramId, - ParamValue: value, - } - - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - debugLog("Step 3 Failed for param %d. Error: %v", paramId, err) - return "", fmt.Errorf("failed to set param %d: %w", paramId, err) - } - debugLog("Step 3 OK for param %d", paramId) - } - - // 4. Validate (GET /validate-cfs) - tflog.Debug(ctx, fmt.Sprintf("Validating operation %s", opUid)) - _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) - if err != nil { - return "", fmt.Errorf("validation failed: %w", err) - } - - // 5. Execute (POST /run) - tflog.Debug(ctx, fmt.Sprintf("Executing operation %s", opUid)) - _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) - if err != nil { - return "", fmt.Errorf("execution failed: %w", err) - } - - // 6. Wait for completion (Simple Polling) - tflog.Info(ctx, "Waiting for resource creation...") - time.Sleep(5 * time.Second) - - return instanceUid, nil -} - -func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) { - // User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md). - const userAgent = "Mozilla/5.0 (compatible; Terraform-Provider-Nubes)" - const maxRetries = 3 - baseDelay := 2 * time.Second - - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - if attempt > 0 { - delay := baseDelay * time.Duration(1<<(attempt-1)) - select { - case <-time.After(delay): - case <-ctx.Done(): - return nil, nil, ctx.Err() - } - } - - var body io.Reader - if payload != nil { - b, err := json.Marshal(payload) - if err != nil { - return nil, nil, err - } - body = bytes.NewBuffer(b) - } - - req, err := http.NewRequestWithContext(ctx, method, c.ApiEndpoint+path, body) - if err != nil { - return nil, nil, err - } - - req.Close = true - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", userAgent) - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - lastErr = err - if attempt < maxRetries { - continue - } - return nil, nil, err - } - - respBody, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - lastErr = err - if attempt < maxRetries { - continue - } - return nil, nil, err - } - - if resp.StatusCode >= 400 { - err := fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) - if attempt < maxRetries && method == "GET" && isRetryable(resp.StatusCode) { - lastErr = err - continue - } - return nil, nil, err - } - - return respBody, resp.Header, nil - } - - return nil, nil, fmt.Errorf("doRequest failed after %d retries: %w", maxRetries, lastErr) -} - -func isRetryable(statusCode int) bool { - return statusCode == http.StatusTooManyRequests || - statusCode == http.StatusServiceUnavailable || - statusCode == http.StatusBadGateway || - statusCode == http.StatusGatewayTimeout -} - -// ===== UNIVERSAL FLOW (APPEND-ONLY) ===== - -type universalOpResponse struct { - InstanceOperation universalOperation `json:"instanceOperation"` -} - -type universalOperation struct { - CfsParams []universalCfsParam `json:"cfsParams"` -} - -type universalCfsParam struct { - SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` - ParamValue *string `json:"paramValue"` - DefaultValue *string `json:"defaultValue"` - DataType string `json:"dataType"` -} - -// Deprecated: Use CreateGenericInstanceUniversalV5 instead. -// This method is kept for backward compatibility and will be removed in v3.0. -// CreateGenericInstanceUniversal реализует универсальный Nubes Flow: -// instances -> instanceOperations -> get cfsParams -> submit params -> validate -> run -func (c *UniversalClient) CreateGenericInstanceUniversal(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { - // 1. Create Placeholder (POST /instances) - instPayload := genericInstanceReq{ - ServiceId: serviceId, - DisplayName: displayName, - Descr: "Created via Terraform Universal Provider", - } - - instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) - if err != nil { - return "", err - } - - var instanceUid string - if loc := instHeaders.Get("Location"); loc != "" { - instanceUid = strings.TrimPrefix(loc, "./") - } - if instanceUid == "" { - var instResult struct { - InstanceUid string `json:"instanceUid"` - } - if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { - instanceUid = instResult.InstanceUid - } else { - var justId string - if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { - instanceUid = justId - } - } - } - if instanceUid == "" { - return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) - } - - // 2. Init Operation (POST /instanceOperations) - opPayload := genericOpReq{ - InstanceUid: instanceUid, - Operation: "create", - } - - opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) - if err != nil { - return "", err - } - - var opUid string - if loc := opHeaders.Get("Location"); loc != "" { - opUid = strings.TrimPrefix(loc, "./") - } - if opUid == "" { - var opResult struct { - InstanceOperationUid string `json:"instanceOperationUid"` - } - if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { - opUid = opResult.InstanceOperationUid - } else { - var justId string - if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { - opUid = justId - } - } - } - if opUid == "" { - return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) - } - - // 3. Get operation details (cfsParams) - opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) - if err != nil { - return "", fmt.Errorf("failed to get operation details: %w", err) - } - var opDetails universalOpResponse - if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { - return "", fmt.Errorf("failed to parse operation details: %w", err) - } - - // 4. Submit explicit params - sent := make(map[int]bool) - for paramId, value := range params { - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: paramId, - ParamValue: value, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to set param %d: %w", paramId, err) - } - sent[paramId] = true - } - - // 5. Submit defaults for missing params - for _, param := range opDetails.InstanceOperation.CfsParams { - if sent[param.SvcOperationCfsParamId] { - continue - } - - val := "" - if param.ParamValue != nil { - val = *param.ParamValue - } else if param.DefaultValue != nil { - val = *param.DefaultValue - } - val = normalizeUniversalValue(val, param.DataType) - - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: val, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) - } - } - - // 6. Validate (GET /validate-cfs) - _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) - if err != nil { - return "", fmt.Errorf("validation failed: %w", err) - } - - // 7. Execute (POST /run) - _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) - if err != nil { - return "", fmt.Errorf("execution failed: %w", err) - } - - // 8. Wait for completion (Simple Polling) - time.Sleep(5 * time.Second) - - return instanceUid, nil -} - -func normalizeUniversalValue(val string, dataType string) string { - if val != "" { - return val - } - - switch strings.ToLower(dataType) { - case "map", "json": - return "{}" - case "array", "list": - return "[]" - default: - return val - } -} - -// ===== UNIVERSAL FLOW V2 (APPEND-ONLY) ===== - -type universalOpResponseV2 struct { - InstanceOperation universalOperationV2 `json:"instanceOperation"` -} - -type universalOperationV2 struct { - CfsParams []universalCfsParamV2 `json:"cfsParams"` -} - -type universalCfsParamV2 struct { - SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` - ParamValue *string `json:"paramValue"` - DefaultValue *string `json:"defaultValue"` - DataType string `json:"dataType"` - Name string `json:"name"` - Code string `json:"code"` - SvcOperationCfsParam string `json:"svcOperationCfsParam"` -} - -// Deprecated: Use CreateGenericInstanceUniversalV5 instead. -// This method is kept for backward compatibility and will be removed in v3.0. -// CreateGenericInstanceUniversalV2 - расширенная нормализация значений -// (map/json/list/array) с учетом name/code/svcOperationCfsParam -func (c *UniversalClient) CreateGenericInstanceUniversalV2(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { - // 1. Create Placeholder (POST /instances) - instPayload := genericInstanceReq{ - ServiceId: serviceId, - DisplayName: displayName, - Descr: "Created via Terraform Universal Provider", - } - - instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) - if err != nil { - return "", err - } - - var instanceUid string - if loc := instHeaders.Get("Location"); loc != "" { - instanceUid = strings.TrimPrefix(loc, "./") - } - if instanceUid == "" { - var instResult struct { - InstanceUid string `json:"instanceUid"` - } - if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { - instanceUid = instResult.InstanceUid - } else { - var justId string - if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { - instanceUid = justId - } - } - } - if instanceUid == "" { - return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) - } - - // 2. Init Operation (POST /instanceOperations) - opPayload := genericOpReq{ - InstanceUid: instanceUid, - Operation: "create", - } - - opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) - if err != nil { - return "", err - } - - var opUid string - if loc := opHeaders.Get("Location"); loc != "" { - opUid = strings.TrimPrefix(loc, "./") - } - if opUid == "" { - var opResult struct { - InstanceOperationUid string `json:"instanceOperationUid"` - } - if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { - opUid = opResult.InstanceOperationUid - } else { - var justId string - if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { - opUid = justId - } - } - } - if opUid == "" { - return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) - } - - // 3. Get operation details (cfsParams) - opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) - if err != nil { - return "", fmt.Errorf("failed to get operation details: %w", err) - } - var opDetails universalOpResponseV2 - if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { - return "", fmt.Errorf("failed to parse operation details: %w", err) - } - - // 4. Submit explicit params - sent := make(map[int]bool) - for paramId, value := range params { - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: paramId, - ParamValue: value, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to set param %d: %w", paramId, err) - } - sent[paramId] = true - } - - // 5. Submit defaults for missing params - for _, param := range opDetails.InstanceOperation.CfsParams { - if sent[param.SvcOperationCfsParamId] { - continue - } - - val := "" - if param.ParamValue != nil { - val = *param.ParamValue - } else if param.DefaultValue != nil { - val = *param.DefaultValue - } - val = normalizeUniversalValueV2(val, param) - - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: val, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) - } - } - - // 6. Validate (GET /validate-cfs) - _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) - if err != nil { - return "", fmt.Errorf("validation failed: %w", err) - } - - // 7. Execute (POST /run) - _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) - if err != nil { - return "", fmt.Errorf("execution failed: %w", err) - } - - // 8. Wait for completion (Simple Polling) - time.Sleep(5 * time.Second) - - return instanceUid, nil -} - -func normalizeUniversalValueV2(val string, param universalCfsParamV2) string { - if val != "" { - return val - } - - dataType := strings.ToLower(param.DataType) - nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.SvcOperationCfsParam) - - if dataType == "map" || dataType == "json" || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { - return "{}" - } - if dataType == "array" || dataType == "list" || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { - return "[]" - } - - return val -} - -// ===== UNIVERSAL FLOW V3 (APPEND-ONLY) ===== - -type universalOpResponseV3 struct { - InstanceOperation universalOperationV3 `json:"instanceOperation"` -} - -type universalOperationV3 struct { - CfsParams []universalCfsParamV3 `json:"cfsParams"` -} - -type universalCfsParamV3 struct { - SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` - ParamValue *string `json:"paramValue"` - DefaultValue *string `json:"defaultValue"` - DataType string `json:"dataType"` - Name string `json:"name"` - Code string `json:"code"` - Label string `json:"label"` - SvcOperationCfsParam string `json:"svcOperationCfsParam"` -} - -// Deprecated: Use CreateGenericInstanceUniversalV5 instead. -// This method is kept for backward compatibility and will be removed in v3.0. -// CreateGenericInstanceUniversalV3 - расширенная нормализация (label/name/code) -func (c *UniversalClient) CreateGenericInstanceUniversalV3(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { - // 1. Create Placeholder (POST /instances) - instPayload := genericInstanceReq{ - ServiceId: serviceId, - DisplayName: displayName, - Descr: "Created via Terraform Universal Provider", - } - - instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) - if err != nil { - return "", err - } - - var instanceUid string - if loc := instHeaders.Get("Location"); loc != "" { - instanceUid = strings.TrimPrefix(loc, "./") - } - if instanceUid == "" { - var instResult struct { - InstanceUid string `json:"instanceUid"` - } - if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { - instanceUid = instResult.InstanceUid - } else { - var justId string - if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { - instanceUid = justId - } - } - } - if instanceUid == "" { - return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) - } - - // 2. Init Operation (POST /instanceOperations) - opPayload := genericOpReq{ - InstanceUid: instanceUid, - Operation: "create", - } - - opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) - if err != nil { - return "", err - } - - var opUid string - if loc := opHeaders.Get("Location"); loc != "" { - opUid = strings.TrimPrefix(loc, "./") - } - if opUid == "" { - var opResult struct { - InstanceOperationUid string `json:"instanceOperationUid"` - } - if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { - opUid = opResult.InstanceOperationUid - } else { - var justId string - if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { - opUid = justId - } - } - } - if opUid == "" { - return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) - } - - // 3. Get operation details (cfsParams) - opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) - if err != nil { - return "", fmt.Errorf("failed to get operation details: %w", err) - } - var opDetails universalOpResponseV3 - if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { - return "", fmt.Errorf("failed to parse operation details: %w", err) - } - - // Debug log of cfsParams (for type mismatch analysis) - for _, p := range opDetails.InstanceOperation.CfsParams { - debugLog("CFS Param: id=%d dataType=%s name=%s code=%s label=%s svcParam=%s", p.SvcOperationCfsParamId, p.DataType, p.Name, p.Code, p.Label, p.SvcOperationCfsParam) - } - - // 4. Submit explicit params - sent := make(map[int]bool) - for paramId, value := range params { - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: paramId, - ParamValue: value, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to set param %d: %w", paramId, err) - } - sent[paramId] = true - } - - // 5. Submit defaults for missing params - for _, param := range opDetails.InstanceOperation.CfsParams { - if sent[param.SvcOperationCfsParamId] { - continue - } - - val := "" - if param.ParamValue != nil { - val = *param.ParamValue - } else if param.DefaultValue != nil { - val = *param.DefaultValue - } - val = normalizeUniversalValueV3(val, param) - - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: val, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) - } - } - - // 6. Validate (GET /validate-cfs) - _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) - if err != nil { - return "", fmt.Errorf("validation failed: %w", err) - } - - // 7. Execute (POST /run) - _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) - if err != nil { - return "", fmt.Errorf("execution failed: %w", err) - } - - // 8. Wait for completion (Simple Polling) - time.Sleep(5 * time.Second) - - return instanceUid, nil -} - -func normalizeUniversalValueV3(val string, param universalCfsParamV3) string { - if val != "" { - return val - } - - dataType := strings.ToLower(param.DataType) - nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam) - - if dataType == "map" || dataType == "json" || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { - return "{}" - } - if dataType == "array" || dataType == "list" || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { - return "[]" - } - - return val -} - -// ===== UNIVERSAL FLOW V4 (APPEND-ONLY) ===== - -// Deprecated: Use CreateGenericInstanceUniversalV5 instead. -// This method is kept for backward compatibility and will be removed in v3.0. -// CreateGenericInstanceUniversalV4 - нормализация с trim + расширенный разбор dataType -func (c *UniversalClient) CreateGenericInstanceUniversalV4(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { - // 1. Create Placeholder (POST /instances) - instPayload := genericInstanceReq{ - ServiceId: serviceId, - DisplayName: displayName, - Descr: "Created via Terraform Universal Provider", - } - - instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) - if err != nil { - return "", err - } - - var instanceUid string - if loc := instHeaders.Get("Location"); loc != "" { - instanceUid = strings.TrimPrefix(loc, "./") - } - if instanceUid == "" { - var instResult struct { - InstanceUid string `json:"instanceUid"` - } - if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { - instanceUid = instResult.InstanceUid - } else { - var justId string - if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { - instanceUid = justId - } - } - } - if instanceUid == "" { - return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) - } - - // 2. Init Operation (POST /instanceOperations) - opPayload := genericOpReq{ - InstanceUid: instanceUid, - Operation: "create", - } - - opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) - if err != nil { - return "", err - } - - var opUid string - if loc := opHeaders.Get("Location"); loc != "" { - opUid = strings.TrimPrefix(loc, "./") - } - if opUid == "" { - var opResult struct { - InstanceOperationUid string `json:"instanceOperationUid"` - } - if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { - opUid = opResult.InstanceOperationUid - } else { - var justId string - if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { - opUid = justId - } - } - } - if opUid == "" { - return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) - } - - // 3. Get operation details (cfsParams) - opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) - if err != nil { - return "", fmt.Errorf("failed to get operation details: %w", err) - } - var opDetails universalOpResponseV3 - if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { - return "", fmt.Errorf("failed to parse operation details: %w", err) - } - - // Debug log of cfsParams (for type mismatch analysis) - for _, p := range opDetails.InstanceOperation.CfsParams { - debugLog("CFS Param: id=%d dataType=%s name=%s code=%s label=%s svcParam=%s", p.SvcOperationCfsParamId, p.DataType, p.Name, p.Code, p.Label, p.SvcOperationCfsParam) - } - - // 4. Submit explicit params - sent := make(map[int]bool) - for paramId, value := range params { - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: paramId, - ParamValue: value, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to set param %d: %w", paramId, err) - } - sent[paramId] = true - } - - // 5. Submit defaults for missing params - for _, param := range opDetails.InstanceOperation.CfsParams { - if sent[param.SvcOperationCfsParamId] { - continue - } - - val := "" - if param.ParamValue != nil { - val = *param.ParamValue - } else if param.DefaultValue != nil { - val = *param.DefaultValue - } - val = normalizeUniversalValueV4(val, param) - - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: val, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) - } - } - - // 6. Validate (GET /validate-cfs) - _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) - if err != nil { - return "", fmt.Errorf("validation failed: %w", err) - } - - // 7. Execute (POST /run) - _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) - if err != nil { - return "", fmt.Errorf("execution failed: %w", err) - } - - // 8. Wait for completion (Simple Polling) - time.Sleep(5 * time.Second) - - return instanceUid, nil -} - -func normalizeUniversalValueV4(val string, param universalCfsParamV3) string { - trimmed := strings.TrimSpace(val) - if trimmed != "" { - return trimmed - } - - dataType := strings.ToLower(param.DataType) - nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam) - - if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { - return "[]" - } - if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { - return "{}" - } - - return trimmed -} - -// ===== UNIVERSAL FLOW V5 (APPEND-ONLY) ===== - -// CreateGenericInstanceUniversalV5 - добавляет логирование отправляемых значений -func (c *UniversalClient) CreateGenericInstanceUniversalV5(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { - // 1. Create Placeholder (POST /instances) - instPayload := genericInstanceReq{ - ServiceId: serviceId, - DisplayName: displayName, - Descr: "Created via Terraform Universal Provider", - } - - instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) - if err != nil { - return "", err - } - - var instanceUid string - if loc := instHeaders.Get("Location"); loc != "" { - instanceUid = strings.TrimPrefix(loc, "./") - } - if instanceUid == "" { - var instResult struct { - InstanceUid string `json:"instanceUid"` - } - if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { - instanceUid = instResult.InstanceUid - } else { - var justId string - if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { - instanceUid = justId - } - } - } - if instanceUid == "" { - return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) - } - - // 2. Init Operation (POST /instanceOperations) - opPayload := genericOpReq{ - InstanceUid: instanceUid, - Operation: "create", - } - - opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) - if err != nil { - return "", err - } - - var opUid string - if loc := opHeaders.Get("Location"); loc != "" { - opUid = strings.TrimPrefix(loc, "./") - } - if opUid == "" { - var opResult struct { - InstanceOperationUid string `json:"instanceOperationUid"` - } - if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { - opUid = opResult.InstanceOperationUid - } else { - var justId string - if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { - opUid = justId - } - } - } - if opUid == "" { - return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) - } - - // 3. Get operation details (cfsParams) - opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) - if err != nil { - return "", fmt.Errorf("failed to get operation details: %w", err) - } - var opDetails universalOpResponseV3 - if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { - return "", fmt.Errorf("failed to parse operation details: %w", err) - } - - // Debug log of cfsParams (for type mismatch analysis) - for _, p := range opDetails.InstanceOperation.CfsParams { - debugLog("CFS Param: id=%d dataType=%s name=%s code=%s label=%s svcParam=%s", p.SvcOperationCfsParamId, p.DataType, p.Name, p.Code, p.Label, p.SvcOperationCfsParam) - } - - // 4. Submit explicit params - sent := make(map[int]bool) - for paramId, value := range params { - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: paramId, - ParamValue: value, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to set param %d: %w", paramId, err) - } - sent[paramId] = true - } - - // 5. Submit defaults for missing params - for _, param := range opDetails.InstanceOperation.CfsParams { - if sent[param.SvcOperationCfsParamId] { - continue - } - - val := "" - if param.ParamValue != nil { - val = *param.ParamValue - } else if param.DefaultValue != nil { - val = *param.DefaultValue - } - normalized := normalizeUniversalValueV5(val, param) - debugLog("CFS Send: id=%d raw=%q normalized=%q", param.SvcOperationCfsParamId, val, normalized) - - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: normalized, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) - } - } - - // 6. Validate (GET /validate-cfs) - _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) - if err != nil { - return "", fmt.Errorf("validation failed: %w", err) - } - - // 7. Execute (POST /run) - _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) - if err != nil { - return "", fmt.Errorf("execution failed: %w", err) - } - - // 8. Wait for completion (Simple Polling) - time.Sleep(5 * time.Second) - - return instanceUid, nil -} - -func normalizeUniversalValueV5(val string, param universalCfsParamV3) string { - trimmed := strings.TrimSpace(val) - if strings.EqualFold(trimmed, "null") { - trimmed = "" - } - if trimmed != "" { - return trimmed - } - - dataType := strings.ToLower(param.DataType) - nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam) - - if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { - return "[]" - } - if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { - return "{}" - } - - return trimmed -} - -// ===== UNIVERSAL FLOW V6 (APPEND-ONLY) ===== - -// CreateGenericInstanceUniversalV6 - обработка raw="\"\"" для map/json/array/list -func (c *UniversalClient) CreateGenericInstanceUniversalV6(ctx context.Context, serviceId int, displayName string, params map[int]string) (string, error) { - // 1. Create Placeholder (POST /instances) - instPayload := genericInstanceReq{ - ServiceId: serviceId, - DisplayName: displayName, - Descr: "Created via Terraform Universal Provider", - } - - instResp, instHeaders, err := c.doRequest(ctx, "POST", "/instances", instPayload) - if err != nil { - return "", err - } - - var instanceUid string - if loc := instHeaders.Get("Location"); loc != "" { - instanceUid = strings.TrimPrefix(loc, "./") - } - if instanceUid == "" { - var instResult struct { - InstanceUid string `json:"instanceUid"` - } - if err := json.Unmarshal(instResp, &instResult); err == nil && instResult.InstanceUid != "" { - instanceUid = instResult.InstanceUid - } else { - var justId string - if err2 := json.Unmarshal(instResp, &justId); err2 == nil && justId != "" { - instanceUid = justId - } - } - } - if instanceUid == "" { - return "", fmt.Errorf("could not extract instanceUid from response (Header: %s, Body: %s)", instHeaders.Get("Location"), string(instResp)) - } - - // 2. Init Operation (POST /instanceOperations) - opPayload := genericOpReq{ - InstanceUid: instanceUid, - Operation: "create", - } - - opResp, opHeaders, err := c.doRequest(ctx, "POST", "/instanceOperations", opPayload) - if err != nil { - return "", err - } - - var opUid string - if loc := opHeaders.Get("Location"); loc != "" { - opUid = strings.TrimPrefix(loc, "./") - } - if opUid == "" { - var opResult struct { - InstanceOperationUid string `json:"instanceOperationUid"` - } - if err := json.Unmarshal(opResp, &opResult); err == nil && opResult.InstanceOperationUid != "" { - opUid = opResult.InstanceOperationUid - } else { - var justId string - if err2 := json.Unmarshal(opResp, &justId); err2 == nil && justId != "" { - opUid = justId - } - } - } - if opUid == "" { - return "", fmt.Errorf("failed to extract instanceOperationUid from response (Header: %s, Body: %s)", opHeaders.Get("Location"), string(opResp)) - } - - // 3. Get operation details (cfsParams) - opDetailsResp, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid), nil) - if err != nil { - return "", fmt.Errorf("failed to get operation details: %w", err) - } - var opDetails universalOpResponseV3 - if err := json.Unmarshal(opDetailsResp, &opDetails); err != nil { - return "", fmt.Errorf("failed to parse operation details: %w", err) - } - - for _, p := range opDetails.InstanceOperation.CfsParams { - debugLog("CFS Param: id=%d dataType=%s name=%s code=%s label=%s svcParam=%s", p.SvcOperationCfsParamId, p.DataType, p.Name, p.Code, p.Label, p.SvcOperationCfsParam) - } - - // 4. Submit explicit params - sent := make(map[int]bool) - for paramId, value := range params { - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: paramId, - ParamValue: value, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to set param %d: %w", paramId, err) - } - sent[paramId] = true - } - - // 5. Submit defaults for missing params - for _, param := range opDetails.InstanceOperation.CfsParams { - if sent[param.SvcOperationCfsParamId] { - continue - } - - val := "" - if param.ParamValue != nil { - val = *param.ParamValue - } else if param.DefaultValue != nil { - val = *param.DefaultValue - } - normalized := normalizeUniversalValueV6(val, param) - debugLog("CFS Send: id=%d raw=%q normalized=%q", param.SvcOperationCfsParamId, val, normalized) - - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: normalized, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) - } - } - - // 6. Validate (GET /validate-cfs) - _, _, err = c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid), nil) - if err != nil { - return "", fmt.Errorf("validation failed: %w", err) - } - - // 7. Execute (POST /run) - _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) - if err != nil { - return "", fmt.Errorf("execution failed: %w", err) - } - - // 8. Wait for completion (Simple Polling) - time.Sleep(5 * time.Second) - - return instanceUid, nil -} - -func normalizeUniversalValueV6(val string, param universalCfsParamV3) string { - trimmed := strings.TrimSpace(val) - if strings.EqualFold(trimmed, "null") { - trimmed = "" - } - // Handle JSON-encoded empty string: "" - if trimmed == "\"\"" { - trimmed = "" - } - if trimmed != "" { - return trimmed - } - - dataType := strings.ToLower(param.DataType) - nameHint := strings.ToLower(param.Name + " " + param.Code + " " + param.Label + " " + param.SvcOperationCfsParam) - - if strings.Contains(dataType, "array") || strings.Contains(nameHint, "array") || strings.Contains(nameHint, "list") { - return "[]" - } - if strings.Contains(dataType, "map") || strings.Contains(dataType, "json") || strings.Contains(nameHint, "map") || strings.Contains(nameHint, "json") { - return "{}" - } - - return trimmed -} diff --git a/internal/core/instance_lookup.go b/internal/core/instance_lookup.go deleted file mode 100644 index 5903e20..0000000 --- a/internal/core/instance_lookup.go +++ /dev/null @@ -1,178 +0,0 @@ -package core - -import ( - "context" - "encoding/json" - "fmt" - "strings" -) - -type ApiOperation struct { - SvcOperationId int `json:"svcOperationId"` - Operation string `json:"operation"` -} - -type InstanceStateResponse struct { - InstanceUid string `json:"instanceUid"` - ExplainedStatus string `json:"explainedStatus"` - IsDeleted bool `json:"isDeleted"` - OperationIsInProgress bool `json:"operationIsInProgress"` - OperationIsPending bool `json:"operationIsPending"` - AvailableOperations []ApiOperation `json:"availableOperations"` -} - -// FindInstanceByDisplayName finds an instance by display_name for a given serviceId. -// Если найдено больше одного non-deleted инстанса — возвращает ошибку. -func (c *UniversalClient) FindInstanceByDisplayName(ctx context.Context, serviceId int, displayName string) (*InstanceStateResponse, error) { - all, err := c.FindAllInstancesByDisplayName(ctx, serviceId, displayName) - if err != nil { - return nil, err - } - if len(all) == 0 { - return nil, nil - } - if len(all) == 1 { - return &all[0], nil - } - // Множественные non-deleted инстансы: пробуем выбрать единственный running/suspended - var nonDeleted []InstanceStateResponse - for _, inst := range all { - if !inst.IsDeleted { - nonDeleted = append(nonDeleted, inst) - } - } - if len(nonDeleted) == 0 { - return nil, nil - } - if len(nonDeleted) == 1 { - return &nonDeleted[0], nil - } - // Больше одного — формируем понятное сообщение - details := make([]string, 0, len(nonDeleted)) - for _, inst := range nonDeleted { - details = append(details, fmt.Sprintf(" - %s (статус: %s)", inst.InstanceUid, inst.ExplainedStatus)) - } - return nil, fmt.Errorf( - "обнаружено %d инстансов с именем '%s' (serviceId=%d):\n%s\nНевозможно определить какой adopt-ить. Удалите лишние через ЛК (Управление облаком) или укажите конкретный UUID через 'terraform import'", - len(nonDeleted), displayName, serviceId, strings.Join(details, "\n"), - ) -} - -func (c *UniversalClient) GetInstanceState(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { - respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil) - if err != nil { - return nil, err - } - - var res struct { - Instance InstanceStateResponse `json:"instance"` - } - if err := json.Unmarshal(respBody, &res); err != nil { - return nil, err - } - - if err := validateInstanceStatus(&res.Instance); err != nil { - return nil, err - } - - return &res.Instance, nil -} - -// GetInstanceStateRaw получает состояние инстанса БЕЗ валидации статуса. -// Используется для проверки ref-параметров: нужно читать даже deleted/suspended инстансы. -func (c *UniversalClient) GetInstanceStateRaw(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { - respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instances/%s", instanceUid), nil) - if err != nil { - return nil, err - } - - var res struct { - Instance InstanceStateResponse `json:"instance"` - } - if err := json.Unmarshal(respBody, &res); err != nil { - return nil, err - } - - return &res.Instance, nil -} - -// FindAllInstancesByDisplayName находит ВСЕ non-deleted инстансы по display_name и serviceId. -// Возвращает slice, чтобы caller мог обработать дубликаты. -func (c *UniversalClient) FindAllInstancesByDisplayName(ctx context.Context, serviceId int, displayName string) ([]InstanceStateResponse, error) { - var found []InstanceStateResponse - page := 1 - for { - path := fmt.Sprintf("/instances?page=%d&size=100", page) - respBody, _, err := c.doRequest(ctx, "GET", path, nil) - if err != nil { - return nil, err - } - - var res struct { - Results []struct { - InstanceUid string `json:"instanceUid"` - DisplayName string `json:"displayName"` - ServiceId int `json:"serviceId"` - } `json:"results"` - } - if err := json.Unmarshal(respBody, &res); err != nil { - return nil, err - } - - if len(res.Results) == 0 { - break - } - - for _, item := range res.Results { - if item.ServiceId == serviceId && strings.EqualFold(item.DisplayName, displayName) { - state, err := c.GetInstanceStateRaw(ctx, item.InstanceUid) - if err != nil { - return nil, err - } - if state != nil && !isInstanceDeleted(state) { - found = append(found, *state) - } - } - } - - page++ - if page > 100 { - break - } - } - - return found, nil -} - -func isInstanceDeleted(state *InstanceStateResponse) bool { - if state == nil { - return false - } - if state.IsDeleted { - return true - } - return strings.EqualFold(strings.TrimSpace(state.ExplainedStatus), "deleted") -} - -func validateInstanceStatus(state *InstanceStateResponse) error { - if state == nil { - return fmt.Errorf("missing instance state") - } - if state.IsDeleted { - return fmt.Errorf("instance %s is deleted", state.InstanceUid) - } - if state.OperationIsPending || state.OperationIsInProgress { - return fmt.Errorf("instance %s not ready: operation pending", state.InstanceUid) - } - status := strings.ToLower(strings.TrimSpace(state.ExplainedStatus)) - if strings.Contains(status, "not created") { - return fmt.Errorf("instance %s not created", state.InstanceUid) - } - if strings.Contains(status, "pending") { - return fmt.Errorf("instance %s pending", state.InstanceUid) - } - if strings.Contains(status, "failed") || strings.Contains(status, "error") { - return fmt.Errorf("instance %s failed: %s", state.InstanceUid, state.ExplainedStatus) - } - return nil -} diff --git a/internal/core/instance_ops.go b/internal/core/instance_ops.go deleted file mode 100644 index ec9cf70..0000000 --- a/internal/core/instance_ops.go +++ /dev/null @@ -1,137 +0,0 @@ -package core - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" -) - -// RunInstanceOperationUniversal runs an available operation (modify/suspend/delete/resume) if possible. -func (c *UniversalClient) RunInstanceOperationUniversal(ctx context.Context, instanceUid string, action string, params map[int]string) error { - state, err := c.GetInstanceState(ctx, instanceUid) - if err != nil { - return err - } - - var opId int - for _, op := range state.AvailableOperations { - if strings.EqualFold(op.Operation, action) { - opId = op.SvcOperationId - break - } - } - if opId == 0 { - return fmt.Errorf("action %s not available for instance %s", action, instanceUid) - } - - payload := map[string]interface{}{ - "instanceUid": instanceUid, - "svcOperationId": opId, - "operation": action, - } - - opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true) - if err != nil { - return fmt.Errorf("failed to create %s operation: %w", action, err) - } - if opUid == "" { - return fmt.Errorf("failed to get operation UID for %s", action) - } - - for paramId, value := range params { - pPayload := genericParamReq{ - InstanceOperationUid: opUid, - SvcOperationCfsParamId: paramId, - ParamValue: value, - } - _, _, err := c.doRequest(ctx, "POST", "/instanceOperationCfsParams", pPayload) - if err != nil { - return fmt.Errorf("failed to set param %d: %w", paramId, err) - } - } - - _, _, err = c.doRequest(ctx, "POST", fmt.Sprintf("/instanceOperations/%s/run", opUid), map[string]interface{}{}) - if err != nil { - return err - } - - return c.waitForOperationFinish(ctx, opUid, defaultOperationTimeout) -} - -const defaultOperationTimeout = 30 * time.Minute - -type operationStatusResponse struct { - InstanceOperation struct { - DtFinish *string `json:"dtFinish"` - IsSuccessful *bool `json:"isSuccessful"` - ErrorLog *string `json:"errorLog"` - IsInProgress bool `json:"isInProgress"` - IsPending bool `json:"isPending"` - } `json:"instanceOperation"` -} - -func (c *UniversalClient) waitForOperationFinish(ctx context.Context, opUid string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("operation %s cancelled", opUid) - case <-ticker.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for operation %s to finish", opUid) - } - - respBody, _, err := c.doRequest(ctx, "GET", fmt.Sprintf("/instanceOperations/%s", opUid), nil) - if err != nil { - return fmt.Errorf("failed to check operation %s status: %w", opUid, err) - } - - var status operationStatusResponse - if err := json.Unmarshal(respBody, &status); err != nil { - return fmt.Errorf("failed to parse operation %s status: %w", opUid, err) - } - - if status.InstanceOperation.DtFinish != nil && strings.TrimSpace(*status.InstanceOperation.DtFinish) != "" { - if status.InstanceOperation.IsSuccessful != nil && !*status.InstanceOperation.IsSuccessful { - if status.InstanceOperation.ErrorLog != nil && strings.TrimSpace(*status.InstanceOperation.ErrorLog) != "" { - return fmt.Errorf("operation %s failed: %s", opUid, *status.InstanceOperation.ErrorLog) - } - return fmt.Errorf("operation %s failed", opUid) - } - return nil - } - } - } -} - -func (c *UniversalClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocation bool) (string, error) { - respBody, headers, err := c.doRequest(ctx, "POST", path, payload) - if err != nil { - return "", err - } - - if returnLocation { - if loc := headers.Get("Location"); loc != "" { - return extractUIDFromLocation(loc), nil - } - } - - var justId string - if err := json.Unmarshal(respBody, &justId); err == nil && justId != "" { - return justId, nil - } - - return "", nil -} - -func extractUIDFromLocation(loc string) string { - if loc == "" { - return "" - } - return strings.TrimPrefix(loc, "./") -} diff --git a/internal/generated/bolvan_resource.go b/internal/generated/bolvan_resource.go deleted file mode 100644 index 94d98d9..0000000 --- a/internal/generated/bolvan_resource.go +++ /dev/null @@ -1,112 +0,0 @@ -package generated - -import ( - "context" - "strconv" - - "terraform-provider-mycloud/internal/core" - // "terraform-provider-mycloud/internal/provider" REMOVED CYCLE - - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// ========= RESOURCE DEFINITION (Auto-Generated) ========= -// Service: Bolvanka (Dummy) -// Service ID: 1 - -var _ resource.Resource = &BolvankaResource{} - -func NewBolvankaResource() resource.Resource { - return &BolvankaResource{} -} - -type BolvankaResource struct { - client *core.UniversalClient -} - -type BolvankaResourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - - // Params - DurationMs types.Int64 `tfsdk:"duration_ms"` // ID 198 - FailAtStart types.Bool `tfsdk:"fail_at_start"` // ID 199 -} - -func (r *BolvankaResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_bolvanka" -} - -func (r *BolvankaResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{Computed: true}, - "display_name": schema.StringAttribute{Required: true}, - "duration_ms": schema.Int64Attribute{ - Required: true, - MarkdownDescription: "Sleep duration in ms (Param 198)", - }, - "fail_at_start": schema.BoolAttribute{ - Optional: true, - MarkdownDescription: "Fail immediately (Param 199)", - }, - }, - } -} - -func (r *BolvankaResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data BolvankaResourceModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // === MAP PARAMETERS (The Stringifier Logic) === - params := make(map[int]string) - - // 198: durationMs (int -> string) - params[198] = strconv.FormatInt(data.DurationMs.ValueInt64(), 10) - - // 199: failAtStart (bool -> string) - valRef := "false" - if data.FailAtStart.ValueBool() { - valRef = "true" - } - params[199] = valRef - - // Call Core - id, err := r.client.CreateGenericInstance(ctx, 1, data.DisplayName.ValueString(), params) - if err != nil { - resp.Diagnostics.AddError("Client Error", err.Error()) - return - } - - data.ID = types.StringValue(id) - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *BolvankaResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - // Implemented as shim for now -} - -func (r *BolvankaResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { -} -func (r *BolvankaResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { -} - -func (r *BolvankaResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - // Assuming Provider passes the correct *UniversalClient or compatible interface - client, ok := req.ProviderData.(*core.UniversalClient) - if !ok { - resp.Diagnostics.AddError("Error", "Wrong client type expected *core.UniversalClient") - return - } - - r.client = client -} diff --git a/internal/generated/bolvan_resource_universal.go b/internal/generated/bolvan_resource_universal.go deleted file mode 100644 index 2f2c9ee..0000000 --- a/internal/generated/bolvan_resource_universal.go +++ /dev/null @@ -1,110 +0,0 @@ -package generated - -import ( - "context" - "strconv" - - "terraform-provider-mycloud/internal/core" - - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// ========= RESOURCE DEFINITION (Auto-Generated, Universal Flow) ========= -// Service: Bolvanka (Dummy) -// Service ID: 1 - -var _ resource.Resource = &BolvankaUniversalResource{} - -func NewBolvankaUniversalResource() resource.Resource { - return &BolvankaUniversalResource{} -} - -type BolvankaUniversalResource struct { - client *core.UniversalClient -} - -type BolvankaUniversalResourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - - // Params - DurationMs types.Int64 `tfsdk:"duration_ms"` // ID 198 - FailAtStart types.Bool `tfsdk:"fail_at_start"` // ID 199 -} - -func (r *BolvankaUniversalResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_bolvanka_universal" -} - -func (r *BolvankaUniversalResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{Computed: true}, - "display_name": schema.StringAttribute{Required: true}, - "duration_ms": schema.Int64Attribute{ - Required: true, - MarkdownDescription: "Sleep duration in ms (Param 198)", - }, - "fail_at_start": schema.BoolAttribute{ - Optional: true, - MarkdownDescription: "Fail immediately (Param 199)", - }, - }, - } -} - -func (r *BolvankaUniversalResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data BolvankaUniversalResourceModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // === MAP PARAMETERS (The Stringifier Logic) === - params := make(map[int]string) - - // 198: durationMs (int -> string) - params[198] = strconv.FormatInt(data.DurationMs.ValueInt64(), 10) - - // 199: failAtStart (bool -> string) - valRef := "false" - if data.FailAtStart.ValueBool() { - valRef = "true" - } - params[199] = valRef - - // Call Core (Universal Flow V6) - id, err := r.client.CreateGenericInstanceUniversalV6(ctx, 1, data.DisplayName.ValueString(), params) - if err != nil { - resp.Diagnostics.AddError("Client Error", err.Error()) - return - } - - data.ID = types.StringValue(id) - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *BolvankaUniversalResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - // Implemented as shim for now -} - -func (r *BolvankaUniversalResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { -} -func (r *BolvankaUniversalResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { -} - -func (r *BolvankaUniversalResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*core.UniversalClient) - if !ok { - resp.Diagnostics.AddError("Error", "Wrong client type expected *core.UniversalClient") - return - } - - r.client = client -} diff --git a/internal/generated/bolvan_resource_universal_lifecycle.go b/internal/generated/bolvan_resource_universal_lifecycle.go deleted file mode 100644 index d67be1a..0000000 --- a/internal/generated/bolvan_resource_universal_lifecycle.go +++ /dev/null @@ -1,192 +0,0 @@ -package generated - -import ( - "context" - "strconv" - "strings" - - "terraform-provider-mycloud/internal/core" - - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -// ========= RESOURCE DEFINITION (Auto-Generated, Universal Flow + Lifecycle) ========= -// Service: Bolvanka (Dummy) -// Service ID: 1 - -var _ resource.Resource = &BolvankaUniversalLifecycleResource{} - -func NewBolvankaUniversalLifecycleResource() resource.Resource { - return &BolvankaUniversalLifecycleResource{} -} - -type BolvankaUniversalLifecycleResource struct { - client *core.UniversalClient -} - -type BolvankaUniversalLifecycleModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - DurationMs types.Int64 `tfsdk:"duration_ms"` - FailAtStart types.Bool `tfsdk:"fail_at_start"` - DeleteMode types.String `tfsdk:"delete_mode"` - ResumeIfExists types.Bool `tfsdk:"resume_if_exists"` -} - -func (r *BolvankaUniversalLifecycleResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_bolvanka_universal_lifecycle" -} - -func (r *BolvankaUniversalLifecycleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - }, - "display_name": schema.StringAttribute{ - Required: true, - }, - "duration_ms": schema.Int64Attribute{ - Required: true, - MarkdownDescription: "Sleep duration in ms (Param 198)", - }, - "fail_at_start": schema.BoolAttribute{ - Optional: true, - MarkdownDescription: "Fail immediately (Param 199)", - }, - "delete_mode": schema.StringAttribute{ - Optional: true, - Computed: true, - Default: stringdefault.StaticString("state_only"), - MarkdownDescription: "Deletion mode: delete | suspend | state_only", - }, - "resume_if_exists": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(true), - MarkdownDescription: "If true, attempt resume/adopt when instance already exists", - }, - }, - } -} - -func (r *BolvankaUniversalLifecycleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data BolvankaUniversalLifecycleModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - displayName := data.DisplayName.ValueString() - - // If resume_if_exists: try to find existing instance by display_name - if data.ResumeIfExists.ValueBool() { - existing, err := r.client.FindInstanceByDisplayName(ctx, 1, displayName) - if err != nil { - resp.Diagnostics.AddError("Client Error", err.Error()) - return - } - if existing != nil { - status := strings.ToLower(existing.ExplainedStatus) - if strings.Contains(status, "suspend") || strings.Contains(status, "suspended") { - if err := r.client.RunInstanceOperationUniversal(ctx, existing.InstanceUid, "resume", nil); err != nil { - resp.Diagnostics.AddError("Client Error", err.Error()) - return - } - } - - data.ID = types.StringValue(existing.InstanceUid) - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) - return - } - } - - params := map[int]string{ - 198: strconv.FormatInt(data.DurationMs.ValueInt64(), 10), - 199: boolToString(data.FailAtStart.ValueBool()), - } - - id, err := r.client.CreateGenericInstanceUniversalV6(ctx, 1, displayName, params) - if err != nil { - resp.Diagnostics.AddError("Client Error", err.Error()) - return - } - - data.ID = types.StringValue(id) - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *BolvankaUniversalLifecycleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - // Implemented as shim for now -} - -func (r *BolvankaUniversalLifecycleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var data BolvankaUniversalLifecycleModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - params := map[int]string{ - 198: strconv.FormatInt(data.DurationMs.ValueInt64(), 10), - 199: boolToString(data.FailAtStart.ValueBool()), - } - - if err := r.client.RunInstanceOperationUniversal(ctx, data.ID.ValueString(), "modify", params); err != nil { - resp.Diagnostics.AddError("Client Error", err.Error()) - return - } - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *BolvankaUniversalLifecycleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state BolvankaUniversalLifecycleModel - resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { - return - } - - mode := strings.ToLower(state.DeleteMode.ValueString()) - if mode == "" { - mode = "state_only" - } - - switch mode { - case "state_only": - return - case "suspend", "delete": - if err := r.client.RunInstanceOperationUniversal(ctx, state.ID.ValueString(), mode, nil); err != nil { - resp.Diagnostics.AddError("Client Error", err.Error()) - return - } - default: - resp.Diagnostics.AddError("Invalid delete_mode", "Use: delete | suspend | state_only") - return - } -} - -func (r *BolvankaUniversalLifecycleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*core.UniversalClient) - if !ok { - resp.Diagnostics.AddError("Error", "Wrong client type expected *core.UniversalClient") - return - } - - r.client = client -} - -func boolToString(v bool) string { - if v { - return "true" - } - return "false" -} diff --git a/internal/provider/README.md b/internal/provider/README.md deleted file mode 100644 index b4f7c4b..0000000 --- a/internal/provider/README.md +++ /dev/null @@ -1,132 +0,0 @@ -# Internal Provider — Code-level Index & Developer Guide - -Generated: 2026-01-27 -Purpose: Quick reference for developers and Copilot agents to understand the internal Terraform provider implementation (`internal/provider/`). **Read this file before making changes to provider code.** - ---- - -## Overview ✨ -This folder contains the implementation of the Terraform provider `nubes` (resources and data-sources) and the HTTP client used to interact with the Taffy/Deck API. - -Primary responsibilities: -- Define provider schema and configuration (`provider.go`). -- Implement resources (`*_resource.go`) and data-sources (`*_data_source.go`). -- Encapsulate HTTP interactions in `client_impl.go` (NubesClient) and helper types. -- Implement polling/wait logic and operation submission patterns used across resources. - ---- - -## Files (short mapping) -- `provider.go` — Provider factory, Metadata, Schema, and lists of Resources/DataSources. -- `client_impl.go` — `NubesClient` implementation: request helpers, polling (`WaitForInstanceReady`, `WaitForOperation`), instance state retrieval, and operation submission. -- `edge_resource.go` / `edge_data_source.go` — Edge Gateway resource and data-source implementations. -- `vm_resource.go` — VM resource implementation (create/read/update/delete/import and operation waiting, `waitForVMOperationAndInstanceStatus`). -- `vapp_resource.go` / `vapp_data_source.go` — vApp resource and data-source. -- `vdc_resource.go` / `vdc_data_source.go` — vDC resource and data-source. -- `organization_resource.go` — Organization resource implementation. -- `postgres_resource.go` / `pgadmin_resource.go` — Postgres and PgAdmin resources. -- `s3bucket_resource.go` — S3 bucket resource. -- `quick_start_resource.go` — QuickStart resource (helper for full-stack installs). -- `tubulus_resource.go` / `tubulus_ai.go` — Tubulus resource (AI integrations). Contains `askGemini` usage. - ---- - -## Key Patterns & Conventions 🔧 -1. Resource lifecycle methods follow Terraform SDK conventions: `Metadata`, `Schema`, `Configure`, `Create`, `Read`, `Update`, `Delete`, `ImportState`. -2. Creation pattern: - - Submit a create operation (via `NubesClient` helpers) -> wait for operation state -> wait for instance readiness (if applicable) -> apply post-creation steps (VIP, DNS, FW). -3. Operation polling: - - Each resource implements `waitForOperationAndInstanceStatus` style helpers (e.g., VM uses specialized `waitForVMOperationAndInstanceStatus`) that poll Deck API for operation completion using `GetInstanceOperation` / `GetInstanceState`. -4. Parameter submission: - - Before `Create`, resources collect a model (e.g., `VMResourceModel`) and call `submitVMOperationParams` / `submitOperationParams` which uses `instanceOperationCfsParams` mapping to Deck's `svcOperationCfsParamId`. -5. Error handling: - - If operation stage `dtFinish` is `null` or the stage is stuck on `[PROCESS]`, functions return a timeout/error and propagate to Terraform user. - ---- - -## Important Types & Functions (by file) - -### provider.go -- `New(version string) func() provider.Provider` — Provider factory used by Terraform to instantiate provider. -- `NubesProvider` (type) — implements provider hooks: `Metadata`, `Schema`, `Configure`. -- `Resources()` and `DataSources()` — lists of registered resources and data-sources. - -### client_impl.go -- `type NubesClient struct` — HTTP client wrapper; holds base URL, token provider, logger. -- `GetOperationId(ctx, serviceId, opName)` — Resolve operation numeric ID by name. -- `CreateInstance(ctx, displayName, serviceId, svcOperationId, params)` — Creates instance and returns UIDs. -- `WaitForInstanceReady`, `WaitForOperation` — Polling helpers. -- `GetInstanceStateDetails`, `GetInstanceOperation` — low-level getters for state & operation details. -- `Post`, `postInstance` helpers — handle posting and optionally returning Location/ID from `Location` header. - -### vm_resource.go -- `NewVMResource()` — resource constructor. -- `VMResource` and `VMResourceModel` — model for user-specified parameters and mapping. -- `Create` — builds request model, submits params, runs operation (watching for `Firewall` stage and others), and treats partial success carefully. -- `waitForVMOperationAndInstanceStatus` — VM-specific wait logic (parses stages and handles FW timeouts). - -### edge_resource.go & edge_data_source.go -- `NewEdgeResource()`, `NewEdgeDataSource()` — constructors. -- `EdgeResourceModel`, `readInstance`, `submitOperationParams`, `waitForOperationAndInstanceStatus` — same patterns applied for Edge resource. - -### tubulus_resource.go & tubulus_ai.go -- Integrates AI flows (Gemini) with resource flow. -- `askGemini` — helper which calls AI integration for instruction parsing. - ---- - -## How to build the provider locally (developer workflow) ⚙️ -1. Build the provider binary: - -```bash -# At repo root -go build -o terraform-provider-nubes ./ -``` - -2. Make it available to Terraform for local testing: - -```bash -# Option A: plugin dir -mkdir -p ~/.terraform.d/plugins/local/terraform-provider-nubes -cp terraform-provider-nubes ~/.terraform.d/plugins/local/terraform-provider-nubes/ -# Option B: use plugin-dir during init -terraform init -plugin-dir=./ (not recommended if you have mixed plugins) -``` - -3. Run an example config: - -```bash -cd examples/quick_start -terraform init -terraform apply -var="token=" -auto-approve -``` - -4. Useful commands during development: -- `go vet`, `golangci-lint run` (if configured), `go test ./...`. -- Use `tools/har/*` scripts to replay HAR-based scenarios when testing resource behavior. - -> Note: Examples might perform destructive operations against live environment — prefer dev account and `-auto-approve` only when you expect the run. - ---- - -## Acceptance / Integration Tests -- Use `tests/` scenarios to validate create/modify/delete flows. -- Manual acceptance: run an example on a dev account, inspect `instances` and `operations` via Deck API. -- Test idempotency: apply the same config twice and ensure `plan` shows no changes. - ---- - -## Conventions for Agents and Contributors -- ALWAYS read `/home/naeel/terra/REPO_CONTENTS.md` and this file before making changes to provider code. -- When adding a resource: - - Add a new `*_resource.go` and a `*_data_source.go` if discovery is required. - - Implement `Create` / `Read` / `Update` / `Delete` / import if supported. - - Add tests in `tests/` and examples in `examples/`. - ---- - -## Next steps I can take ✅ -- Generate a function-level index (per-file exported functions and short signatures) as a machine-readable YAML/JSON for other agents. -- Add `internal/provider/DEVELOPMENT.md` with a checklist and `make` targets for build and acceptance test steps. - -If you want the function-level JSON index and a `DEVELOPMENT.md`, say "code-level" and I'll create them and commit to the repo. \ No newline at end of file diff --git a/internal/provider/client_impl.go b/internal/provider/client_impl.go deleted file mode 100644 index 61da137..0000000 --- a/internal/provider/client_impl.go +++ /dev/null @@ -1,924 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" - - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -// Helper struct for parameters -type InstanceParam struct { - SvcOperationCfsParamId int - ParamValue string -} - -type InstanceOperationRequest struct { - Action string `json:"action"` - Params interface{} `json:"params"` -} - -// Implement methods for NubesClient defined in provider.go - -func (c *NubesClient) GetOperationId(ctx context.Context, serviceId int, opName string) (int, error) { - // Logic to fetch operation ID if needed. - // Based on HAR, we might not strictly need this if we pass "operation": "create" - // But let's assume we return a dummy or look it up. - // For now, return 0 as placeholder or implement lookup if API supports it. - return 0, nil -} - -func (c *NubesClient) CreateInstance(ctx context.Context, displayName string, serviceId int, svcOperationId int, params []InstanceParam) (string, string, error) { - // 1. Create Instance Placeholder - // Payload based on pg_admin.har: {"serviceId":96,"displayName":"...","descr":""} - - payload := map[string]interface{}{ - "serviceId": serviceId, - "displayName": displayName, - "descr": "", - } - - instanceUid, err := c.postInstance(ctx, payload) - if err != nil { - return "", "", fmt.Errorf("failed to create instance placeholder: %w", err) - } - tflog.Info(ctx, fmt.Sprintf("Created Instance Placeholder: %s", instanceUid)) - - // 2. Create Operation - opPayload := map[string]interface{}{ - "instanceUid": instanceUid, - "operation": "create", - } - // If svcOperationId is valid (>0), maybe we use it? HAR just said "operation":"create". - // We'll stick to "operation":"create". - - opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", opPayload, true) - if err != nil { - return "", "", fmt.Errorf("failed to create operation: %w", err) - } - tflog.Info(ctx, fmt.Sprintf("Created Operation: %s", opUid)) - - // 3. Get operation details with parameters - opPath := fmt.Sprintf("/instanceOperations/%s?fields=cfsParams", opUid) - var opDetails GetOperationResponse - if err := c.get(ctx, opPath, &opDetails); err != nil { - return "", "", fmt.Errorf("failed to get operation details: %w", err) - } - - // 3a. Submit parameters - // Strategy: - // 1. Send all explicitly provided 'params' (from Terraform config/resource) - // 2. Iterate server-provided defaults (opDetails) for anything we missed and send defaults - - sentParams := make(map[int]bool) - - // Phase 1: Send explicit overrides - for _, p := range params { - valToSend := p.ParamValue - // Simple normalization for empty values if needed - if valToSend == "" { - // Some fields might reject empty string? For now send as is - // or apply the map/list fix if we knew the type. - // But for explicit params, we assume caller knows best. - } - - paramPayload := map[string]interface{}{ - "instanceOperationUid": opUid, - "svcOperationCfsParamId": p.SvcOperationCfsParamId, - "paramValue": valToSend, - } - - _, err := c.postIgnoreResponse(ctx, "/instanceOperationCfsParams", paramPayload, false) - if err != nil { - return "", "", fmt.Errorf("failed to submit explicit param %d: %w", p.SvcOperationCfsParamId, err) - } - sentParams[p.SvcOperationCfsParamId] = true - } - - // Phase 2: Fill in defaults from Server Metadata (if not already sent) - for _, param := range opDetails.InstanceOperation.CfsParams { - if _, sent := sentParams[param.SvcOperationCfsParamId]; sent { - continue // Already sent in Phase 1 - } - - valToSend := "" - if param.ParamValue != nil { - valToSend = *param.ParamValue - } else if param.DefaultValue != nil { - valToSend = *param.DefaultValue - } - - // Fix specific data type formatting (from tubulus example) - if valToSend == "" { - if param.DataType == "map" || param.DataType == "json" { - valToSend = "{}" - } else if param.DataType == "array" || param.DataType == "list" { - valToSend = "[]" - } - } - - paramPayload := map[string]interface{}{ - "instanceOperationUid": opUid, - "svcOperationCfsParamId": param.SvcOperationCfsParamId, - "paramValue": valToSend, - } - _, err := c.postIgnoreResponse(ctx, "/instanceOperationCfsParams", paramPayload, false) - if err != nil { - return "", "", fmt.Errorf("failed to submit default param %d: %w", param.SvcOperationCfsParamId, err) - } - } - - // 4. Validate (Optional, seen in S3 HAR) - validateUrl := fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid) - tflog.Info(ctx, fmt.Sprintf("Validating operation: %s", validateUrl)) - _ = c.get(ctx, validateUrl, nil) - - // 5. Run Operation - // HAR: POST .../instanceOperations/{ids}/run - runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid) - tflog.Info(ctx, fmt.Sprintf("Running operation: %s", runUrl)) - _, err = c.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false) - if err != nil { - return "", "", fmt.Errorf("failed to run operation: %w", err) - } - - return instanceUid, opUid, nil -} - -// RunInstanceOperation is a helper to just fill params and run an existing OP UID -func (c *NubesClient) RunInstanceOperation(ctx context.Context, opUid string, params []InstanceParam) (string, string, error) { - // Add params - for _, param := range params { - valToSend := param.ParamValue - // Simple normalization - if valToSend == "" { - valToSend = "pass" // Default fallback if needed, or empty - } - - paramPayload := map[string]interface{}{ - "instanceOperationUid": opUid, - "svcOperationCfsParamId": param.SvcOperationCfsParamId, - "paramValue": valToSend, - } - _, err := c.postIgnoreResponse(ctx, "/instanceOperationCfsParams", paramPayload, false) - if err != nil { - return "", "", fmt.Errorf("failed to add param %d: %w", param.SvcOperationCfsParamId, err) - } - } - - // Validate - validateUrl := fmt.Sprintf("/instanceOperations/%s/validate-cfs", opUid) - _ = c.get(ctx, validateUrl, nil) - - // Run - runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid) - _, err := c.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false) - if err != nil { - return "", "", fmt.Errorf("failed to run operation: %w", err) - } - - return "", opUid, nil -} - -func (c *NubesClient) WaitForInstanceStatus(ctx context.Context, instanceUid string, targetStatus string) error { - - timeout := time.After(15 * time.Minute) - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-timeout: - return fmt.Errorf("timeout waiting for instance %s to reach status %s", instanceUid, targetStatus) - case <-ticker.C: - inst, err := c.GetInstanceState(ctx, instanceUid) - if err != nil { - tflog.Warn(ctx, fmt.Sprintf("Error checking state for %s: %s", instanceUid, err)) - continue - } - - state := inst.ExplainedStatus - tflog.Info(ctx, fmt.Sprintf("Instance %s current status: %s (target: %s, InProgress: %v)", instanceUid, state, targetStatus, inst.OperationIsInProgress)) - - if strings.EqualFold(state, targetStatus) { - return nil - } - - lowerState := strings.ToLower(state) - if strings.Contains(lowerState, "error") || strings.Contains(lowerState, "failed") { - return fmt.Errorf("instance %s in error state: %s", instanceUid, state) - } - - if !inst.OperationIsInProgress && !inst.OperationIsPending { - if strings.EqualFold(state, targetStatus) { - return nil - } - return fmt.Errorf("operation finished but target status %s not reached (current: %s)", targetStatus, state) - } - } - } -} - -// ttyWriter открывает /dev/tty для прямого вывода в терминал пользователя, -// минуя перехват stderr terraform'ом. Fallback на os.Stderr. -func ttyWriter() *os.File { - if f, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil { - return f - } - return os.Stderr -} - -func (c *NubesClient) WaitForOperation(ctx context.Context, opUid string) error { - timeout := time.After(15 * time.Minute) - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - // отслеживаем уже напечатанные этапы чтобы не дублировать - printedStages := make(map[string]bool) - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-timeout: - return fmt.Errorf("timeout waiting for operation %s", opUid) - case <-ticker.C: - op, err := c.GetInstanceOperation(ctx, opUid) - if err != nil { - tflog.Warn(ctx, fmt.Sprintf("Error checking operation %s: %s", opUid, err)) - continue - } - - // Печатаем завершённые этапы - tty := ttyWriter() - defer tty.Close() - for _, stage := range op.Stages { - if printedStages[stage.InstanceOperationStageUid] { - continue - } - if stage.DtFinish == nil || *stage.DtFinish == "" { - continue - } - printedStages[stage.InstanceOperationStageUid] = true - status := "OK " - if !stage.IsSuccessful { - status = "FAIL" - } - fmt.Fprintf(tty, " [%s] %s — %.1f sec\n", status, stage.Stage, stage.Duration) - if stage.StageMsg != nil && *stage.StageMsg != "" { - fmt.Fprintf(tty, " %s\n", *stage.StageMsg) - } - } - - // Операция завершена - if !op.IsInProgress && !op.IsPending && op.DtFinish != nil && *op.DtFinish != "" { - if op.IsSuccessful != nil && *op.IsSuccessful { - if op.Duration != nil { - fmt.Fprintf(tty, " [DONE] operation completed in %.1f sec\n", *op.Duration) - } - return nil - } - // Собираем детали ошибки из упавших этапов - var failedStages []string - for _, stage := range op.Stages { - if !stage.IsSuccessful && stage.DtFinish != nil { - msg := stage.Stage - if stage.StageMsg != nil && *stage.StageMsg != "" { - msg += ": " + *stage.StageMsg - } - failedStages = append(failedStages, msg) - } - } - errMsg := "operation failed" - if op.ErrorLog != nil && *op.ErrorLog != "" { - errMsg = *op.ErrorLog - } - if len(failedStages) > 0 { - errMsg += " | failed stages: " + strings.Join(failedStages, "; ") - } - return fmt.Errorf("%s", errMsg) - } - - // Ранний выход если ErrorLog появился раньше dtFinish - if op.ErrorLog != nil && *op.ErrorLog != "" { - return fmt.Errorf("operation failed (early error): %s", *op.ErrorLog) - } - } - } -} - -func (c *NubesClient) get(ctx context.Context, path string, target interface{}) error { - url := fmt.Sprintf("%s%s", c.ApiEndpoint, path) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return err - } - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("GET %s failed with status %d: %s", path, resp.StatusCode, string(body)) - } - - if target != nil { - return json.NewDecoder(resp.Body).Decode(target) - } - return nil -} - -func (c *NubesClient) WaitForInstanceReady(ctx context.Context, instanceUid string) error { - // Set timeout - timeout := time.After(15 * time.Minute) - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-timeout: - return fmt.Errorf("timeout waiting for instance %s", instanceUid) - case <-ticker.C: - // check status - inst, err := c.GetInstanceState(ctx, instanceUid) - if err != nil { - tflog.Warn(ctx, fmt.Sprintf("Error checking state for %s: %s", instanceUid, err)) - continue - } - - state := inst.ExplainedStatus - tflog.Info(ctx, fmt.Sprintf("Instance %s status: %s (InProgress: %v)", instanceUid, state, inst.OperationIsInProgress)) - - // Statuses from HAR: "running", "Active", "deployed", "Deployed" - if strings.EqualFold(state, "Active") || strings.EqualFold(state, "Running") || strings.EqualFold(state, "Deployed") { - return nil - } - - // Detect failure patterns in explainedStatus - lowerState := strings.ToLower(state) - if strings.Contains(lowerState, "error") || - strings.Contains(lowerState, "failed") || - strings.Contains(lowerState, "не удалось") || - strings.Contains(lowerState, "не заполнена") { - return fmt.Errorf("instance %s end status: %s", instanceUid, state) - } - - // If operation finished but we didn't reach success state - if !inst.OperationIsInProgress && !inst.OperationIsPending { - // Second check for success, sometimes status updates slightly after op finishes - if strings.EqualFold(state, "Active") || strings.EqualFold(state, "Running") || strings.EqualFold(state, "Deployed") { - return nil - } - // If still not success, and not in a known transitioning state (like "Processing", "Creating") - if !strings.EqualFold(state, "Creating") && !strings.EqualFold(state, "Processing") && state != "" { - return fmt.Errorf("operation finished but instance %s is in unexpected state: %s", instanceUid, state) - } - } - } - } -} - -func (c *NubesClient) DeleteInstance(ctx context.Context, instanceUid string, opId int) error { - // Create delete operation - opPayload := map[string]interface{}{ - "instanceUid": instanceUid, - "operation": "delete", - } - opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", opPayload, true) - if err != nil { - return err - } - - // Run It - runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid) - _, err = c.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false) - return err -} - -// Helpers - -func (c *NubesClient) postInstance(ctx context.Context, payload interface{}) (string, error) { - url := fmt.Sprintf("%s/instances", c.ApiEndpoint) - data, err := json.Marshal(payload) - if err != nil { - return "", err - } - - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(data)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 201 && resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) - } - - // Try to get ID from Location header - loc := resp.Header.Get("Location") - if loc != "" { - parts := strings.Split(loc, "/") - if len(parts) > 0 { - return parts[len(parts)-1], nil - } - } - - // Try body - var res map[string]interface{} - body, _ := io.ReadAll(resp.Body) - if len(body) > 0 { - if err := json.Unmarshal(body, &res); err == nil { - if uid, ok := res["instanceUid"].(string); ok { - return uid, nil - } - } - } - - return "", fmt.Errorf("could not extract instanceUid from response") -} - -func (c *NubesClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocationId bool) (string, error) { - url := fmt.Sprintf("%s%s", c.ApiEndpoint, path) - var reqBody io.Reader - if payload != nil { - data, err := json.Marshal(payload) - if err != nil { - return "", err - } - reqBody = bytes.NewBuffer(data) - } - - req, err := http.NewRequestWithContext(ctx, "POST", url, reqBody) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 201 && resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) - } - - if returnLocationId { - // Extract ID from Location header .../something/{id} - loc := resp.Header.Get("Location") - if loc != "" { - parts := strings.Split(loc, "/") - return parts[len(parts)-1], nil - } - // Fallback to body scan if needed - var res map[string]interface{} - body, _ := io.ReadAll(resp.Body) - if len(body) > 0 { - if err := json.Unmarshal(body, &res); err == nil { - // Try common ID fields - if id, ok := res["instanceOperationUid"].(string); ok { - return id, nil - } - } - } - } - return "", nil -} - -type ApiOperation struct { - SvcOperationId int `json:"svcOperationId"` - Operation string `json:"operation"` -} - -type InstanceStateData struct { - Params map[string]interface{} `json:"params"` -} - -type InstanceStateResponse struct { - InstanceUid string `json:"instanceUid"` - ExplainedStatus string `json:"explainedStatus"` - OperationIsInProgress bool `json:"operationIsInProgress"` - OperationIsPending bool `json:"operationIsPending"` - AvailableOperations []ApiOperation `json:"availableOperations"` - State *InstanceStateData `json:"state"` -} - -func (c *NubesClient) GetInstanceState(ctx context.Context, instanceUid string) (*InstanceStateResponse, error) { - url := fmt.Sprintf("%s/instances/%s", c.ApiEndpoint, instanceUid) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("status %d", resp.StatusCode) - } - - var res struct { - Instance InstanceStateResponse `json:"instance"` - } - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - return &res.Instance, nil -} - -// Post is a helper for generic API calls, used by postgres_resource -func (c *NubesClient) Post(path string, payload interface{}, target interface{}) error { - url := fmt.Sprintf("%s%s", c.ApiEndpoint, path) - var reqBody io.Reader - if payload != nil { - data, err := json.Marshal(payload) - if err != nil { - return err - } - reqBody = bytes.NewBuffer(data) - } - - req, err := http.NewRequestWithContext(context.Background(), "POST", url, reqBody) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 && resp.StatusCode != 201 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) - } - - if target != nil { - return json.NewDecoder(resp.Body).Decode(target) - } - return nil -} - -func (c *NubesClient) GetInstanceStateDetails(ctx context.Context, instanceUid string) (map[string]interface{}, error) { - url := fmt.Sprintf("%s/instances/%s", c.ApiEndpoint, instanceUid) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("status %d", resp.StatusCode) - } - - var res map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - if inst, ok := res["instance"].(map[string]interface{}); ok { - if state, ok := inst["state"].(map[string]interface{}); ok { - return state, nil - } - } - return nil, fmt.Errorf("could not find instance.state in response") -} - -func (c *NubesClient) GetOperationIdForInstance(ctx context.Context, instanceUid string, opName string) (int, error) { - url := fmt.Sprintf("%s/instances/%s", c.ApiEndpoint, instanceUid) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return 0, err - } - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return 0, fmt.Errorf("status %d", resp.StatusCode) - } - - var res map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return 0, err - } - - inst, ok := res["instance"].(map[string]interface{}) - if !ok { - return 0, fmt.Errorf("instance field missing") - } - - availableOps, ok := inst["availableOperations"].([]interface{}) - if !ok { - return 0, fmt.Errorf("availableOperations missing or empty") - } - - for _, item := range availableOps { - opMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - - var name string - var id int - - if n, ok := opMap["name"].(string); ok { - name = n - } - if n, ok := opMap["operation"].(string); ok { - name = n - } - if i, ok := opMap["svcOperationId"].(float64); ok { - id = int(i) - } - - if svcOp, ok := opMap["svcOperation"].(map[string]interface{}); ok { - if n, ok := svcOp["name"].(string); ok { - name = n - } - if idFloat, ok := svcOp["id"].(float64); ok { - id = int(idFloat) - } - } - - if strings.EqualFold(name, opName) { - return id, nil - } - } - - return 0, fmt.Errorf("operation '%s' not found in available operations", opName) -} - -type GetOperationResponse struct { - InstanceOperation OperationResponse `json:"instanceOperation"` -} - -// OperationResponse представляет собой полное состояние операции из Deck API. -// Все поля получены путем анализа HAR файлов (dummy.har, vdc.har) и документации платформы. -type OperationResponse struct { - InstanceOperationUid string `json:"instanceOperationUid"` // Уникальный идентификатор операции (UUID) - InstanceUid string `json:"instanceUid"` // Идентификатор инстанса, над которым идет работа - Operation string `json:"operation"` // Имя операции (create, modify, suspend, и т.д.) - IsInProgress bool `json:"isInProgress"` // true, если Jenkins-джоб выполняется в данный момент - IsPending bool `json:"isPending"` // true, если операция стоит в очереди (ждем слота в Jenkins) - IsSuccessful *bool `json:"isSuccessful"` // "Зеленая галка" платформы. Появляется ПОСЛЕ dtFinish. - DtFinish *string `json:"dtFinish"` // Штамп времени окончания (ГГГГ-ММ-ДД...). Не null = ОПЕРАЦИЯ ЗАВЕРШЕНА. - DtCreated *string `json:"dtCreated"` // Время создания записи об операции - DtUpdated *string `json:"dtUpdated"` // Время последнего обновления записи - DtSubmit *string `json:"dtSubmit"` // Когда кнопка была нажата (или API вызван) - DtStart *string `json:"dtStart"` // Когда реально начался Jenkins-джоб - SubmitResult *string `json:"submitResult"` // HTTP-код первичной регистрации (обычно "201") - Duration *float64 `json:"duration"` // Общее время выполнения в секундах - ErrorLog *string `json:"errorLog"` // Текст ошибки, если операция упала - UpdaterId *int `json:"updaterId"` // ID пользователя, запустившего операцию - UpdaterLogin *string `json:"updaterLogin"` // Логин инициатора - UpdaterShortname *string `json:"updaterShortname"` // Инициалы инициатора (например, "Н. Ф.") - DisplayName *string `json:"displayName"` // Имя инстанса на момент операции - ServiceId *int `json:"serviceId"` // ID сервиса (1 - Болванка, 96 - PG Admin и т.д.) - Svc *string `json:"svc"` // Текстовое имя сервиса - SvcOperationId *int `json:"svcOperationId"` // Внутренний ID операции в каталоге - Man *string `json:"man"` // Мануал/описание операции (иногда содержит Markdown) - CfsParams []CfsParam `json:"cfsParams"` // Список всех параметров (конфигурация) - Stages []ApiStage `json:"stages"` // Этапы выполнения джоба (подготовка, секреты...) - State any `json:"state"` // Результирующее состояние (выходные данные джоба) -} - -// ApiStage представляет этап выполнения операции в Jenkins -type ApiStage struct { - InstanceOperationStageUid string `json:"instanceOperationStageUid"` - Stage string `json:"stage"` // Название (например, "Подготовка среды") - IsSuccessful bool `json:"isSuccessful"` // Успех конкретного этапа - DtStart *string `json:"dtStart"` - DtFinish *string `json:"dtFinish"` - Duration float64 `json:"duration"` - StageMsg *string `json:"stageMsg"` // Лог этапа (часто JSON в строке) -} - -type CfsParam struct { - InstanceOperationCfsParamUid string `json:"instanceOperationCfsParamUid"` - SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` - ParamValue *string `json:"paramValue"` - DefaultValue *string `json:"defaultValue"` - DataType string `json:"dataType"` - Name string `json:"name"` - Code string `json:"code"` - SvcOperationCfsParam string `json:"svcOperationCfsParam"` -} - -func (c *NubesClient) GetInstanceOperation(ctx context.Context, opUid string) (*OperationResponse, error) { - // ВАЖНО: Список полей максимально расширен на основе HAR (dummy.har, vdc.har). - // Эти поля позволяют видеть полную картину происходящего на платформе. - fields := "instanceOperationUid,instanceUid,state,stages,isSuccessful,dtCreated,dtUpdated,dtStart,dtFinish,operation,svcOperationId,svc,displayName,submitResult,duration,errorLog,updaterShortname,man,isInProgress,isPending,dtSubmit" - url := fmt.Sprintf("%s/instanceOperations/%s?fields=%s", c.ApiEndpoint, opUid, fields) - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) - } - - var res GetOperationResponse - if err := json.Unmarshal(body, &res); err != nil { - return nil, fmt.Errorf("json unmarshal failed: %w, body: %s", err, string(body)) - } - - return &res.InstanceOperation, nil -} - -func (c *NubesClient) RunAction(ctx context.Context, instanceUid string, action string) error { - state, err := c.GetInstanceState(ctx, instanceUid) - if err != nil { - return err - } - - var opId int - for _, op := range state.AvailableOperations { - if op.Operation == action { - opId = op.SvcOperationId - break - } - } - - if opId == 0 { - return fmt.Errorf("action %s not available for instance %s", action, instanceUid) - } - - // 1. Create Op - payload := map[string]interface{}{ - "instanceUid": instanceUid, - "svcOperationId": opId, - "operation": action, - } - - opUid, err := c.postIgnoreResponse(ctx, "/instanceOperations", payload, true) - if err != nil { - return fmt.Errorf("failed to create %s operation: %w", action, err) - } - if opUid == "" { - return fmt.Errorf("failed to get operation UID for %s", action) - } - - // 2. Run Op - runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid) - _, err = c.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false) - return err -} - -type InstanceSummary struct { - InstanceUid string `json:"instanceUid"` - DisplayName string `json:"displayName"` - ServiceId int `json:"serviceId"` - Svc string `json:"svc"` -} - -type InstancesListResponse struct { - Results []InstanceSummary `json:"results"` -} - -func (c *NubesClient) GetInstances(ctx context.Context) ([]InstanceSummary, error) { - var allInstances []InstanceSummary - page := 1 - - for { - url := fmt.Sprintf("%s/instances?page=%d&size=100", c.ApiEndpoint, page) - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("status %d", resp.StatusCode) - } - - var res InstancesListResponse - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - if len(res.Results) == 0 { - break - } - - allInstances = append(allInstances, res.Results...) - page++ - - // Safety break to prevent infinite loops if API behaves weirdly - if page > 100 { - break - } - } - - return allInstances, nil -} - -func (c *NubesClient) GetInstanceFull(ctx context.Context, instanceUid string) (map[string]interface{}, error) { - url := fmt.Sprintf("%s/instances/%s", c.ApiEndpoint, instanceUid) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("status %d", resp.StatusCode) - } - - var res map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return nil, err - } - - if inst, ok := res["instance"].(map[string]interface{}); ok { - return inst, nil - } - return nil, fmt.Errorf("instance field missing in response") -} diff --git a/internal/provider/edge_data_source.go b/internal/provider/edge_data_source.go deleted file mode 100644 index a3dcd8e..0000000 --- a/internal/provider/edge_data_source.go +++ /dev/null @@ -1,144 +0,0 @@ -package provider - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/datasource" - "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var _ datasource.DataSource = &EdgeDataSource{} - -func NewEdgeDataSource() datasource.DataSource { - return &EdgeDataSource{} -} - -type EdgeDataSource struct { - client *NubesClient -} - -type EdgeDataSourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - Status types.String `tfsdk:"status"` - Description types.String `tfsdk:"description"` -} - -func (d *EdgeDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_edge" -} - -func (d *EdgeDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Data source для получения информации о существующем Edge Gateway по имени", - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - MarkdownDescription: "UUID Edge Gateway", - Computed: true, - }, - "display_name": schema.StringAttribute{ - MarkdownDescription: "Имя Edge Gateway для поиска", - Required: true, - }, - "status": schema.StringAttribute{ - MarkdownDescription: "Статус Edge Gateway", - Computed: true, - }, - "description": schema.StringAttribute{ - MarkdownDescription: "Описание Edge Gateway", - Computed: true, - }, - }, - } -} - -func (d *EdgeDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Data Source Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - d.client = client -} - -func (d *EdgeDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data EdgeDataSourceModel - - resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "GET", d.client.ApiEndpoint+"/instances", nil) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) - return - } - - httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(d.client.ApiToken)) - httpReq.Header.Set("Content-Type", "application/json") - - httpResp, err := d.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instances: %s", err)) - return - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read response: %s", err)) - return - } - - if httpResp.StatusCode != http.StatusOK { - resp.Diagnostics.AddError("API Error", fmt.Sprintf("GET /instances returned %d: %s", httpResp.StatusCode, string(body))) - return - } - - var instancesResp struct { - Results []struct { - ID string `json:"id"` - DisplayName string `json:"name"` - Status string `json:"status"` - Description string `json:"desc"` - Service string `json:"svc"` - } `json:"results"` - } - - if err := json.Unmarshal(body, &instancesResp); err != nil { - resp.Diagnostics.AddError("Parse Error", fmt.Sprintf("Unable to parse response: %s", err)) - return - } - - searchName := data.DisplayName.ValueString() - for _, instance := range instancesResp.Results { - if instance.Service == "Сетевой шлюз периметра (Edge)" && instance.DisplayName == searchName { - data.ID = types.StringValue(instance.ID) - data.Status = types.StringValue(instance.Status) - data.Description = types.StringValue(instance.Description) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) - return - } - } - - resp.Diagnostics.AddError( - "Edge Gateway Not Found", - fmt.Sprintf("Edge Gateway с именем '%s' не найден. Проверьте имя или создайте новый Edge.", searchName), - ) -} diff --git a/internal/provider/edge_resource.go b/internal/provider/edge_resource.go deleted file mode 100644 index 2f7e82b..0000000 --- a/internal/provider/edge_resource.go +++ /dev/null @@ -1,674 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" - - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var _ resource.Resource = &EdgeResource{} -var _ resource.ResourceWithImportState = &EdgeResource{} - -func NewEdgeResource() resource.Resource { - return &EdgeResource{} -} - -type EdgeResource struct { - client *NubesClient -} - -type EdgeResourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - Description types.String `tfsdk:"description"` - VdcUID types.String `tfsdk:"vdc_uid"` - EdgeType types.String `tfsdk:"edge_type"` - EdgeCount types.Int64 `tfsdk:"edge_count"` - EnableAdvanced types.Bool `tfsdk:"enable_advanced"` - ExternalNetwork types.String `tfsdk:"external_network"` - Status types.String `tfsdk:"status"` -} - -func (r *EdgeResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_edge" -} - -func (r *EdgeResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Nubes Edge Gateway resource", - - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "Edge identifier (UUID)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "display_name": schema.StringAttribute{ - MarkdownDescription: "Edge display name", - Required: true, - }, - "description": schema.StringAttribute{ - MarkdownDescription: "Edge description", - Optional: true, - }, - "vdc_uid": schema.StringAttribute{ - MarkdownDescription: "VDC UUID (parameter ID 8)", - Required: true, - }, - "edge_type": schema.StringAttribute{ - MarkdownDescription: "Edge type: vdc (parameter ID 621)", - Optional: true, - Computed: true, - }, - "edge_count": schema.Int64Attribute{ - MarkdownDescription: "Number of Edge Gateways (parameter ID 341)", - Optional: true, - Computed: true, - }, - "enable_advanced": schema.BoolAttribute{ - MarkdownDescription: "Enable advanced features (parameter ID 340)", - Optional: true, - Computed: true, - }, - "external_network": schema.StringAttribute{ - MarkdownDescription: "External network name (parameter ID 367)", - Optional: true, - }, - "status": schema.StringAttribute{ - MarkdownDescription: "Current status of the Edge", - Computed: true, - }, - }, - } -} - -func (r *EdgeResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - r.client = client -} - -func (r *EdgeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data EdgeResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // Set defaults - if data.EdgeType.IsNull() || data.EdgeType.IsUnknown() { - data.EdgeType = types.StringValue("vdc") - } - if data.EdgeCount.IsNull() || data.EdgeCount.IsUnknown() { - data.EdgeCount = types.Int64Value(1) - } - if data.EnableAdvanced.IsNull() || data.EnableAdvanced.IsUnknown() { - data.EnableAdvanced = types.BoolValue(false) - } - - // Step 1: Create instance - createReq := CreateInstanceRequest{ - ServiceId: 22, // Edge service ID - DisplayName: data.DisplayName.ValueString(), - Descr: data.Description.ValueString(), - } - - jsonData, err := json.Marshal(createReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location := httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in response") - return - } - instanceId := location[2:] - - data.ID = types.StringValue(instanceId) - - // Step 2: Create operation - operationReq := CreateOperationRequest{ - InstanceUid: instanceId, - Operation: "create", - } - - jsonData, err = json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location = httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in operation response") - return - } - operationId := location[2:] - - // Step 3: Submit operation parameters and run - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - - // Step 4: Wait for operation completion and instance running status - if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 10*time.Minute); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to reach running status: %s", err)) - return - } - - readData, err := r.readInstance(ctx, instanceId) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *EdgeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data EdgeResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) - return - } - - data.Status = types.StringValue(instanceResp.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *EdgeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var data EdgeResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - operationReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "modify", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - } - - time.Sleep(5 * time.Second) - readData, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *EdgeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data EdgeResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - operationReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "delete", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusNotFound { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Delete operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - } - } -} - -func (r *EdgeResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) -} - -func (r *EdgeResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) { - httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id, nil) - if err != nil { - return nil, err - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - - if httpResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var instanceResp InstanceResponse - if err := json.Unmarshal(body, &instanceResp); err != nil { - return nil, err - } - - return &instanceResp, nil -} - -func (r *EdgeResource) submitOperationParams(ctx context.Context, operationUid string, data EdgeResourceModel) error { - // Get operation details - httpReq, err := http.NewRequestWithContext(ctx, "GET", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil) - if err != nil { - return fmt.Errorf("unable to create request: %s", err) - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to get operation: %s", err) - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return fmt.Errorf("unable to read response: %s", err) - } - - if httpResp.StatusCode != http.StatusOK { - return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var getResp GetOperationResponse - if err := json.Unmarshal(body, &getResp); err != nil { - return fmt.Errorf("unable to unmarshal response: %s", err) - } - operationResp := getResp.InstanceOperation - - // Submit each parameter - for _, param := range operationResp.CfsParams { - valToSend := "" - - // Map Edge parameters by ID - switch param.SvcOperationCfsParamId { - case 8: // vdcUid - if !data.VdcUID.IsNull() && !data.VdcUID.IsUnknown() { - valToSend = data.VdcUID.ValueString() - } - case 340: // enableAdvanced - if !data.EnableAdvanced.IsNull() && !data.EnableAdvanced.IsUnknown() { - valToSend = fmt.Sprintf("%t", data.EnableAdvanced.ValueBool()) - } - case 341: // edgeCount - if !data.EdgeCount.IsNull() && !data.EdgeCount.IsUnknown() { - valToSend = fmt.Sprintf("%d", data.EdgeCount.ValueInt64()) - } - case 367: // externalNetwork - if !data.ExternalNetwork.IsNull() && !data.ExternalNetwork.IsUnknown() { - valToSend = data.ExternalNetwork.ValueString() - } - case 621: // edgeType - if !data.EdgeType.IsNull() && !data.EdgeType.IsUnknown() { - valToSend = data.EdgeType.ValueString() - } - case 622: // unknown optional parameter - // Leave empty or use default - default: - // Use existing or default value for unknown parameters - if param.ParamValue != nil { - valToSend = *param.ParamValue - } else if param.DefaultValue != nil { - valToSend = *param.DefaultValue - } - } - - // Fix specific data type formatting - if valToSend == "" || valToSend == "\"\"" { - if param.DataType == "map" || param.DataType == "json" { - valToSend = "{}" - } else if param.DataType == "array" || param.DataType == "list" { - valToSend = "[]" - } - } - - paramReq := CreateCfsParamRequest{ - InstanceOperationUid: operationUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: valToSend, - } - - jsonData, err := json.Marshal(paramReq) - if err != nil { - return fmt.Errorf("unable to marshal param request: %s", err) - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("unable to create param request: %s", err) - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to submit parameter: %s", err) - } - - respBody, _ := io.ReadAll(httpResp.Body) - httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated && - httpResp.StatusCode != http.StatusOK && - httpResp.StatusCode != http.StatusNoContent { - return fmt.Errorf("submit parameter id %d failed with status %d: %s", - param.SvcOperationCfsParamId, httpResp.StatusCode, string(respBody)) - } - } - - // Run the operation - runReq, err := http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) - if err != nil { - return fmt.Errorf("unable to create run request: %s", err) - } - - runReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - runResp, err := r.client.HttpClient.Do(runReq) - if err != nil { - return fmt.Errorf("unable to run operation: %s", err) - } - defer runResp.Body.Close() - - if runResp.StatusCode != http.StatusOK && - runResp.StatusCode != http.StatusNoContent && - runResp.StatusCode != http.StatusCreated { - runBody, _ := io.ReadAll(runResp.Body) - return fmt.Errorf("run operation failed with status %d: %s", - runResp.StatusCode, string(runBody)) - } - - return nil -} - -func (r *EdgeResource) waitForOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - log.Printf("[DEBUG] Starting two-stage polling: operationId=%s, instanceId=%s, timeout=%v", operationId, instanceId, timeout) - - // Шаг 1: Ждём завершения операции -operationLoop: - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - case <-ticker.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for operation to complete") - } - - log.Printf("[DEBUG] Polling operation status: operationId=%s", operationId) - - // Проверяем статус операции - httpReq, err := http.NewRequestWithContext(ctx, "GET", - r.client.ApiEndpoint+"/instanceOperations/"+operationId, nil) - if err != nil { - return fmt.Errorf("failed to create request: %s", err) - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("failed to check operation status: %s", err) - } - - body, _ := io.ReadAll(httpResp.Body) - httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK { - return fmt.Errorf("get operation failed with status %d", httpResp.StatusCode) - } - - var opResp struct { - InstanceOperation struct { - IsInProgress bool `json:"isInProgress"` - IsPending bool `json:"isPending"` - } `json:"instanceOperation"` - } - - if err := json.Unmarshal(body, &opResp); err != nil { - return fmt.Errorf("failed to parse operation response: %s", err) - } - - log.Printf("[DEBUG] Operation status: isInProgress=%v, isPending=%v", opResp.InstanceOperation.IsInProgress, opResp.InstanceOperation.IsPending) - - // Операция завершена когда isInProgress=false И isPending=false - if !opResp.InstanceOperation.IsInProgress && !opResp.InstanceOperation.IsPending { - log.Printf("[DEBUG] Operation completed, moving to instance status check") - break operationLoop - } - } - } - - // Шаг 2: Проверяем статус instance - ticker2 := time.NewTicker(5 * time.Second) - defer ticker2.Stop() - - log.Printf("[DEBUG] Starting instance status polling") - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - case <-ticker2.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for instance to become running") - } - - log.Printf("[DEBUG] Polling instance status: instanceId=%s", instanceId) - -instance, err := r.readInstance(ctx, instanceId) -if err != nil { -return fmt.Errorf("failed to check instance status: %s", err) -} - -if instance.Status == "running" { -return nil -} - -if instance.Status == "error" || instance.Status == "failed" { -return fmt.Errorf("instance entered error state: %s", instance.Status) -} - -// Continue waiting for other statuses -} -} -} diff --git a/internal/provider/organization_resource.go b/internal/provider/organization_resource.go deleted file mode 100644 index c29496b..0000000 --- a/internal/provider/organization_resource.go +++ /dev/null @@ -1,517 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var _ resource.Resource = &OrganizationResource{} - -func NewOrganizationResource() resource.Resource { - return &OrganizationResource{} -} - -type OrganizationResource struct { - client *NubesClient -} - -type OrganizationResourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - Description types.String `tfsdk:"description"` - Platform types.String `tfsdk:"platform"` - OrganizationType types.String `tfsdk:"organization_type"` - ResourceRealm types.String `tfsdk:"resource_realm"` - Status types.String `tfsdk:"status"` -} - -func (r *OrganizationResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_organization" -} - -func (r *OrganizationResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Cloud Director Organization resource - корневая сущность для управления облачной инфраструктурой", - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "UUID организации", - }, - "display_name": schema.StringAttribute{ - Required: true, - MarkdownDescription: "Отображаемое имя организации", - }, - "description": schema.StringAttribute{ - Optional: true, - MarkdownDescription: "Описание организации", - }, - "platform": schema.StringAttribute{ - Required: true, - MarkdownDescription: "Платформа для развертывания (например: ngcloud.ru)", - }, - "organization_type": schema.StringAttribute{ - Required: true, - MarkdownDescription: "Тип организации: iaas (с доступом к Cloud Director) или saas (управляется Nubes)", - }, - "resource_realm": schema.StringAttribute{ - Optional: true, - Computed: true, - MarkdownDescription: "Realm ресурса (по умолчанию: vcd)", - }, - "status": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "Текущий статус организации", - }, - }, - } -} - -func (r *OrganizationResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - r.client = client -} - -func (r *OrganizationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data OrganizationResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // Set default resource_realm if not provided - if data.ResourceRealm.IsNull() || data.ResourceRealm.IsUnknown() { - data.ResourceRealm = types.StringValue("vcd") - } - - // Step 1: Create instance - createReq := CreateInstanceRequest{ - ServiceId: 19, // Cloud Director Organization - DisplayName: data.DisplayName.ValueString(), - Descr: data.Description.ValueString(), - } - - jsonData, err := json.Marshal(createReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal create request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - // Extract instance ID from Location header - location := httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in response") - return - } - instanceId := location[2:] // Remove "./" - - data.ID = types.StringValue(instanceId) - - // Step 2: Create operation - operationReq := CreateOperationRequest{ - InstanceUid: instanceId, - Operation: "create", - } - - jsonData, err = json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - // Extract operation ID from Location header - location = httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in operation response") - return - } - operationId := location[2:] // Remove "./" - - // Step 3: Submit operation parameters - if err := r.submitOrganizationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - - // Step 4: Wait for completion and read status - time.Sleep(5 * time.Second) - - readData, err := r.readInstance(ctx, instanceId) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *OrganizationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data OrganizationResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) - return - } - - data.Status = types.StringValue(instanceResp.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *OrganizationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var data OrganizationResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // Trigger modify operation - operationReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "modify", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - // Extract operation ID and submit parameters - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - if err := r.submitOrganizationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - } - - time.Sleep(5 * time.Second) - - readData, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *OrganizationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data OrganizationResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // First suspend the organization - suspendReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "suspend", - } - - jsonData, err := json.Marshal(suspendReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal suspend request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create suspend request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend organization: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Suspend operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - // Note: Actual deletion requires 14 days wait after suspend - // For now we just suspend and remove from state - resp.Diagnostics.AddWarning( - "Organization Suspended", - "Organization has been suspended. Actual deletion requires 14 days wait period and must be performed manually.", - ) -} - -func (r *OrganizationResource) readInstance(ctx context.Context, instanceId string) (*InstanceResponse, error) { - httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+instanceId, nil) - if err != nil { - return nil, err - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - - if httpResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var instanceResp InstanceResponse - if err := json.Unmarshal(body, &instanceResp); err != nil { - return nil, err - } - - return &instanceResp, nil -} - -func (r *OrganizationResource) submitOrganizationParams(ctx context.Context, operationUid string, data OrganizationResourceModel) error { - // Step 1: Get operation details with parameters - httpReq, err := http.NewRequestWithContext(ctx, "GET", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil) - if err != nil { - return fmt.Errorf("unable to create request: %s", err) - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to get operation: %s", err) - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return fmt.Errorf("unable to read response: %s", err) - } - - if httpResp.StatusCode != http.StatusOK { - return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var getResp GetOperationResponse - if err := json.Unmarshal(body, &getResp); err != nil { - return fmt.Errorf("unable to unmarshal response: %s", err) - } - operationResp := getResp.InstanceOperation - - // Step 2: Submit each parameter - for _, param := range operationResp.CfsParams { - valToSend := "" - - // Map parameters by ID - switch param.SvcOperationCfsParamId { - case 418: // platform - if !data.Platform.IsNull() && !data.Platform.IsUnknown() { - valToSend = data.Platform.ValueString() - } - case 556: // organizationType - if !data.OrganizationType.IsNull() && !data.OrganizationType.IsUnknown() { - valToSend = data.OrganizationType.ValueString() - } - default: - // Use existing or default value - if param.ParamValue != nil { - valToSend = *param.ParamValue - } else if param.DefaultValue != nil { - valToSend = *param.DefaultValue - } - } - - paramReq := CreateCfsParamRequest{ - InstanceOperationUid: operationUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: valToSend, - } - - jsonData, err := json.Marshal(paramReq) - if err != nil { - return fmt.Errorf("unable to marshal param request: %s", err) - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("unable to create param request: %s", err) - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to submit param: %s", err) - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - return fmt.Errorf("submit parameter id %d failed with status %d: %s", - param.SvcOperationCfsParamId, httpResp.StatusCode, string(body)) - } - } - - // Step 3: Run the operation - httpReq, err = http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) - if err != nil { - return fmt.Errorf("unable to create run request: %s", err) - } - httpReq.Header.Set("Content-Type", "application/json") - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to run operation: %s", err) - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK && httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - return fmt.Errorf("run operation failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - return nil -} diff --git a/internal/provider/pgadmin_resource.go b/internal/provider/pgadmin_resource.go deleted file mode 100644 index 33f5381..0000000 --- a/internal/provider/pgadmin_resource.go +++ /dev/null @@ -1,359 +0,0 @@ -package provider - -import ( - "context" - "fmt" - "strconv" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" - - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -var _ resource.Resource = &PgAdminResource{} -var _ resource.ResourceWithImportState = &PgAdminResource{} - -type PgAdminResource struct { - client *NubesClient -} - -func NewPgAdminResource() resource.Resource { - return &PgAdminResource{} -} - -type PgAdminResourceModel struct { - ID types.String `tfsdk:"id"` - Domain types.String `tfsdk:"domain"` - ResourceRealm types.String `tfsdk:"resource_realm"` - Cpu types.Int64 `tfsdk:"cpu"` - Memory types.Int64 `tfsdk:"memory"` - Disk types.Int64 `tfsdk:"disk"` - Email types.String `tfsdk:"email"` - Password types.String `tfsdk:"password"` - DeletionProtection types.Bool `tfsdk:"deletion_protection"` -} - - - -func (r *PgAdminResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_pgadmin" -} - -func (r *PgAdminResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - Description: "Manages a pgAdmin instance.", - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "domain": schema.StringAttribute{ - Required: true, - Description: "Domain name (param 164)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "resource_realm": schema.StringAttribute{ - Required: true, - Description: "Deployment platform (param 169). E.g. k8s-3.ext.nubes.ru", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "cpu": schema.Int64Attribute{ - Optional: true, - Computed: true, - Default: int64default.StaticInt64(200), - Description: "CPU quota in milicores (param 165)", - }, - "memory": schema.Int64Attribute{ - Optional: true, - Computed: true, - Default: int64default.StaticInt64(256), - Description: "Memory quota in MB (param 166)", - }, - "disk": schema.Int64Attribute{ - Optional: true, - Computed: true, - Default: int64default.StaticInt64(1), - Description: "Disk size in GB (param 167)", - }, - "email": schema.StringAttribute{ - Required: true, - Description: "Login email (param 170)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "password": schema.StringAttribute{ - Required: true, - Sensitive: true, - Description: "Login password (param 171)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "deletion_protection": schema.BoolAttribute{ - MarkdownDescription: "If true, the resource will only be removed from Terraform state upon destroy, but will remain in the cloud. If false, destroy will trigger 'suspend' in Nubes.", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(true), - }, - }, - } -} - - -func (r *PgAdminResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError("Unexpected Resource Configure Type", fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData)) - return - } - r.client = client -} - -func (r *PgAdminResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan PgAdminResourceModel - diags := req.Plan.Get(ctx, &plan) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - tflog.Info(ctx, "Creating pgAdmin resource...") - - // Params based on pg_admin.har analysis - params := []InstanceParam{ - {SvcOperationCfsParamId: 164, ParamValue: plan.Domain.ValueString()}, - {SvcOperationCfsParamId: 169, ParamValue: plan.ResourceRealm.ValueString()}, - {SvcOperationCfsParamId: 165, ParamValue: fmt.Sprintf("%d", plan.Cpu.ValueInt64())}, - {SvcOperationCfsParamId: 166, ParamValue: fmt.Sprintf("%d", plan.Memory.ValueInt64())}, - {SvcOperationCfsParamId: 167, ParamValue: fmt.Sprintf("%d", plan.Disk.ValueInt64())}, - {SvcOperationCfsParamId: 170, ParamValue: plan.Email.ValueString()}, - {SvcOperationCfsParamId: 171, ParamValue: plan.Password.ValueString()}, - } - - serviceId := 96 - svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "create") - if err != nil { - resp.Diagnostics.AddError("Failed to get create operation ID for pgAdmin", err.Error()) - return - } - - instanceUid, opUid, err := r.client.CreateInstance(ctx, plan.Domain.ValueString(), serviceId, svcOperationId, params) - if err != nil { - resp.Diagnostics.AddError("Error creating pgAdmin", err.Error()) - return - } - - err = r.client.WaitForOperation(ctx, opUid) - if err != nil { - resp.Diagnostics.AddError("Error waiting for pgAdmin ready", err.Error()) - return - } - - plan.ID = types.StringValue(instanceUid) - // plan.DeletionProtection = types.BoolValue(true) - - diags = resp.State.Set(ctx, plan) - resp.Diagnostics.Append(diags...) -} - - - -func (r *PgAdminResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state PgAdminResourceModel - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - instanceUid := state.ID.ValueString() - // Fetch current state - inst, err := r.client.GetInstanceState(ctx, instanceUid) - if err != nil { - // If 404, remove from state - if strings.Contains(err.Error(), "status 404") { - resp.State.RemoveResource(ctx) - return - } - resp.Diagnostics.AddError("Error reading pgAdmin state", err.Error()) - return - } - - // Map params - if inst.State != nil && inst.State.Params != nil { - for k, v := range inst.State.Params { - valStr := fmt.Sprintf("%v", v) - // Log for debugging - tflog.Info(ctx, fmt.Sprintf("PGAdmin Param: %s = %s", k, valStr)) - - switch k { - case "resourceCPU": - val, _ := strconv.ParseInt(valStr, 10, 64) - state.Cpu = types.Int64Value(val) - case "resourceMemory": - val, _ := strconv.ParseInt(valStr, 10, 64) - state.Memory = types.Int64Value(val) - case "resourceDisk": - val, _ := strconv.ParseInt(valStr, 10, 64) - state.Disk = types.Int64Value(val) - case "domain": - state.Domain = types.StringValue(valStr) - case "resourceRealm": - state.ResourceRealm = types.StringValue(valStr) - case "login": - state.Email = types.StringValue(valStr) - } - } - } - - diags = resp.State.Set(ctx, &state) - resp.Diagnostics.Append(diags...) -} - -func (r *PgAdminResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan, state PgAdminResourceModel - diags := req.Plan.Get(ctx, &plan) - resp.Diagnostics.Append(diags...) - diags = req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - tflog.Info(ctx, "Updating pgAdmin resource...") - - // Find operation ID for 'modify' - // Based on HAR/Screenshots, modify uses standard "modify" operation - opId, err := r.client.GetOperationIdForInstance(ctx, state.ID.ValueString(), "modify") - if err != nil { - resp.Diagnostics.AddError("Failed to find update operation", err.Error()) - return - } - - // Payload for OP creation - opPayload := map[string]interface{}{ - "instanceUid": state.ID.ValueString(), - "svcOperationId": opId, - "operation": "modify", - } - - // Create request - var opUid string - opUid, err = r.client.postIgnoreResponse(ctx, "/instanceOperations", opPayload, true) - if err != nil { - resp.Diagnostics.AddError("Failed to create update operation", err.Error()) - return - } - - // Fetch Op details to get correct CFS Param IDs for 'modify' - opDetails, err := r.client.GetInstanceOperation(ctx, opUid) - if err != nil { - resp.Diagnostics.AddError("Failed to fetch operation details", err.Error()) - return - } - - paramMapping := make(map[string]int) - for _, p := range opDetails.CfsParams { - paramMapping[p.SvcOperationCfsParam] = p.SvcOperationCfsParamId - } - - // Create params list from plan - // We assume standard param names "resourceCPU", "resourceMemory", "resourceDisk" based on screenshot - // But we use mapping to be safe if IDs differ from Create - - type Modification struct { - Name string - Val string - } - - mods := []Modification{ - {"resourceCPU", fmt.Sprintf("%d", plan.Cpu.ValueInt64())}, - {"resourceMemory", fmt.Sprintf("%d", plan.Memory.ValueInt64())}, - {"resourceDisk", fmt.Sprintf("%d", plan.Disk.ValueInt64())}, - } - - params := []InstanceParam{} - for _, m := range mods { - if id, ok := paramMapping[m.Name]; ok { - params = append(params, InstanceParam{SvcOperationCfsParamId: id, ParamValue: m.Val}) - } else { - tflog.Warn(ctx, fmt.Sprintf("Param %s not found in modify operation", m.Name)) - } - } - - // If no params (e.g. only email changed but modify doesn't support it?), skip - if len(params) == 0 { - tflog.Warn(ctx, "No matching parameters found for modify operation. Skipping.") - } else { - // Run op - _, _, err = r.client.RunInstanceOperation(ctx, opUid, params) - if err != nil { - resp.Diagnostics.AddError("Error running modify operation", err.Error()) - return - } - - // Wait - err = r.client.WaitForOperation(ctx, opUid) - if err != nil { - resp.Diagnostics.AddError("Error waiting for modification", err.Error()) - return - } - } - - diags = resp.State.Set(ctx, plan) - resp.Diagnostics.Append(diags...) -} - - -func (r *PgAdminResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state PgAdminResourceModel - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - // User requested NO DELETE for pgAdmin (suspend/protection). - // We will simply remove it from Terraform state without calling API delete. - - if state.DeletionProtection.ValueBool() { - tflog.Warn(ctx, "Deletion Protection is ENABLED for PgAdmin. Resource will remain active in Nubes Cloud. Manual cleanup required.") - return - } - - tflog.Info(ctx, "Deletion Protection is DISABLED for PgAdmin. Triggering 'suspend'...") - - instanceId := state.ID.ValueString() - - // Выполняем операцию suspend - err := r.client.RunAction(ctx, instanceId, "suspend") - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend PgAdmin: %s", err)) - return - } - - tflog.Info(ctx, "PgAdmin suspended successfully. It will be permanently deleted from the cloud in 14 days.") -} - - -func (r *PgAdminResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { -resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) -} diff --git a/internal/provider/postgres_resource.go b/internal/provider/postgres_resource.go deleted file mode 100644 index d0d3746..0000000 --- a/internal/provider/postgres_resource.go +++ /dev/null @@ -1,1046 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "regexp" - "strconv" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -var _ resource.Resource = &PostgresResource{} -var _ resource.ResourceWithImportState = &PostgresResource{} -var _ resource.ResourceWithModifyPlan = &PostgresResource{} - -type PostgresResource struct { - client *NubesClient -} - -var uuidLikeRegex = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") - -func isUUIDLike(value string) bool { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return false - } - return uuidLikeRegex.MatchString(trimmed) -} - -func (r *PostgresResource) resolveS3Uid(ctx context.Context, value string) (string, error) { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return "", nil - } - if isUUIDLike(trimmed) { - return trimmed, nil - } - - return "", fmt.Errorf("s3_uid must be a UUID, got: %s", trimmed) -} - -func NewPostgresResource() resource.Resource { - return &PostgresResource{} -} - -type PostgresResourceModel struct { - ID types.String `tfsdk:"id"` - Name types.String `tfsdk:"name"` - ResourceRealm types.String `tfsdk:"resource_realm"` - S3Uid types.String `tfsdk:"s3_uid"` - Cpu types.Int64 `tfsdk:"cpu"` - Ram types.Int64 `tfsdk:"ram"` - DiskSize types.Int64 `tfsdk:"disk_size"` - NodesCount types.Int64 `tfsdk:"nodes_count"` - Version types.String `tfsdk:"version"` - BackupSchedule types.String `tfsdk:"backup_schedule"` - BackupRetention types.Int64 `tfsdk:"backup_retention"` - Parameters types.String `tfsdk:"parameters"` - EnablePgPoolerMaster types.Bool `tfsdk:"enable_pgpooler_master"` - EnablePgPoolerSlave types.Bool `tfsdk:"enable_pgpooler_slave"` - AllowNoSSL types.Bool `tfsdk:"allow_no_ssl"` - AutoScale types.Bool `tfsdk:"auto_scale"` - AutoScalePercentage types.Int64 `tfsdk:"auto_scale_percentage"` - AutoScaleTechWindow types.Int64 `tfsdk:"auto_scale_tech_window"` - AutoScaleQuotaGb types.Int64 `tfsdk:"auto_scale_quota_gb"` - EnableExternalMaster types.Bool `tfsdk:"enable_external_master"` - EnableExternalSlave types.Bool `tfsdk:"enable_external_slave"` - IpSpaceMaster types.String `tfsdk:"ip_space_master"` - IpSpaceSlave types.String `tfsdk:"ip_space_slave"` - DeletionProtection types.Bool `tfsdk:"deletion_protection"` - - // Computed - AdminUser types.String `tfsdk:"admin_user"` - AdminPassword types.String `tfsdk:"admin_password"` - StandbyUser types.String `tfsdk:"standby_user"` - StandbyPassword types.String `tfsdk:"standby_password"` - InternalHost types.String `tfsdk:"internal_host"` - VaultUserPath types.String `tfsdk:"vault_user_path"` - VaultUrl types.String `tfsdk:"vault_url"` - MonitoringUrl types.String `tfsdk:"monitoring_url"` - InternalConnectMaster types.String `tfsdk:"internal_connect_master"` - InternalConnectSlave types.String `tfsdk:"internal_connect_slave"` - ExternalConnectMasterIp types.String `tfsdk:"external_connect_master_ip"` - ExternalConnectMasterFqdn types.String `tfsdk:"external_connect_master_fqdn"` - ExternalConnectSlaveIp types.String `tfsdk:"external_connect_slave_ip"` - ExternalConnectSlaveFqdn types.String `tfsdk:"external_connect_slave_fqdn"` -} - -func (r *PostgresResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_postgres" -} - -func (r *PostgresResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - Description: "Manages a Postgres cluster.", - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "name": schema.StringAttribute{ - Required: true, - Description: "Name of the Postgres instance", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "resource_realm": schema.StringAttribute{ - Required: true, - Description: "Deployment platform (param 102). Example: k8s-3.ext.nubes.ru", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "s3_uid": schema.StringAttribute{ - Required: true, - Description: "UID Корневой услуги S3 (Param 23). Только UUID, без displayName. Пример: 6d6061cb-b0c1-44b9-8969-a70f08fe673c", - Validators: []validator.String{ - UUIDLike(), - }, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "cpu": schema.Int64Attribute{ - Required: true, - Description: "CPU in millicores (param 82)", - }, - "ram": schema.Int64Attribute{ - Required: true, - Description: "Memory in MB (param 81)", - }, - "disk_size": schema.Int64Attribute{ - Required: true, - Description: "Disk size in GB (param 83)", - }, - "nodes_count": schema.Int64Attribute{ - Optional: true, - Computed: true, - Default: int64default.StaticInt64(1), - Description: "Number of instances (param 80)", - }, - "version": schema.StringAttribute{ - Optional: true, - Computed: true, - Default: stringdefault.StaticString("17"), - Description: "Postgres Version (param 310)", - }, - "backup_schedule": schema.StringAttribute{ - Optional: true, - Computed: true, - Default: stringdefault.StaticString("0 0 * * *"), - Description: "Backup Schedule (param 265)", - }, - "backup_retention": schema.Int64Attribute{ - Optional: true, - Computed: true, - Default: int64default.StaticInt64(7), - Description: "Backup Retention Days (param 266)", - }, - "parameters": schema.StringAttribute{ - Optional: true, - Computed: true, - Default: stringdefault.StaticString("{}"), - Description: "Postgres config as JSON string (param 311)", - }, - "enable_pgpooler_master": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - Description: "Enable pgPooler for Master (param 312)", - }, - "enable_pgpooler_slave": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - Description: "Enable pgPooler for Slave (param 313)", - }, - "allow_no_ssl": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - Description: "Allow No SSL (param 314)", - }, - "auto_scale": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - Description: "Enable AutoScale (param 329)", - }, - "auto_scale_percentage": schema.Int64Attribute{ - Optional: true, - Computed: true, - Default: int64default.StaticInt64(10), - Description: "AutoScale Percentage (param 330)", - }, - "auto_scale_tech_window": schema.Int64Attribute{ - Optional: true, - Computed: true, - Default: int64default.StaticInt64(0), - Description: "AutoScale Tech Window (param 331)", - }, - "auto_scale_quota_gb": schema.Int64Attribute{ - Optional: true, - Computed: true, - Default: int64default.StaticInt64(1), - Description: "AutoScale Quota GB (param 339)", - }, - "enable_external_master": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - Description: "Need External IP for Master (param 145)", - }, - "enable_external_slave": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - Description: "Need External IP for Slave (param 146)", - }, - "ip_space_master": schema.StringAttribute{ - Optional: true, - Description: "IP Space Name for Master (param 337)", - }, - "ip_space_slave": schema.StringAttribute{ - Optional: true, - Description: "IP Space Name for Slave (param 338)", - }, - "deletion_protection": schema.BoolAttribute{ - MarkdownDescription: "If true, the resource will only be removed from Terraform state upon destroy, but will remain in the cloud. If false, destroy will trigger 'suspend' in Nubes.", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(true), - }, - - // Output - "admin_user": schema.StringAttribute{ - Computed: true, - }, - "admin_password": schema.StringAttribute{ - Computed: true, - Sensitive: true, - }, - "standby_user": schema.StringAttribute{ - Computed: true, - }, - "standby_password": schema.StringAttribute{ - Computed: true, - Sensitive: true, - }, - "internal_host": schema.StringAttribute{ - Computed: true, - }, - "vault_user_path": schema.StringAttribute{ - Computed: true, - }, - "vault_url": schema.StringAttribute{ - Computed: true, - }, - "monitoring_url": schema.StringAttribute{ - Computed: true, - }, - "internal_connect_master": schema.StringAttribute{ - Computed: true, - }, - "internal_connect_slave": schema.StringAttribute{ - Computed: true, - }, - "external_connect_master_ip": schema.StringAttribute{ - Computed: true, - }, - "external_connect_master_fqdn": schema.StringAttribute{ - Computed: true, - }, - "external_connect_slave_ip": schema.StringAttribute{ - Computed: true, - }, - "external_connect_slave_fqdn": schema.StringAttribute{ - Computed: true, - }, - }, - } -} - -func (r *PostgresResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError("Unexpected Resource Configure Type", fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData)) - return - } - r.client = client -} - -func (r *PostgresResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan PostgresResourceModel - diags := req.Plan.Get(ctx, &plan) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - tflog.Info(ctx, "Creating Postgres resource...") - - // Attempt to resume suspended instance if exists - tflog.Info(ctx, "Checking for suspended instances to resume...") - var resumedInstanceUid string - instances, err := r.client.GetInstances(ctx) - if err != nil { - tflog.Warn(ctx, fmt.Sprintf("Failed to list instances: %s", err)) - } else { - tflog.Info(ctx, fmt.Sprintf("Scanned %d instances.", len(instances))) - for _, inst := range instances { - if inst.DisplayName == plan.Name.ValueString() && inst.ServiceId == 90 { - tflog.Info(ctx, fmt.Sprintf("Found matching instance name: %s (%s)", inst.DisplayName, inst.InstanceUid)) - details, err := r.client.GetInstanceStateDetails(ctx, inst.InstanceUid) - if err == nil { - isSuspended, _ := details["isSuspended"].(bool) - isDeleted, _ := details["isDeleted"].(bool) - - tflog.Info(ctx, fmt.Sprintf("Instance status isSuspended: %v, isDeleted: %v", isSuspended, isDeleted)) - - if isSuspended { - tflog.Info(ctx, fmt.Sprintf("Found suspended instance %s. Resuming...", inst.InstanceUid)) - if err := r.client.RunAction(ctx, inst.InstanceUid, "resume"); err != nil { - resp.Diagnostics.AddError("Resume Failed", err.Error()) - return - } - // Postgres goes to 'running' state, not 'active' - if err := r.client.WaitForInstanceStatus(ctx, inst.InstanceUid, "running"); err != nil { - resp.Diagnostics.AddError("Wait for Resume Failed", err.Error()) - return - } - resumedInstanceUid = inst.InstanceUid - } else if !isDeleted { - tflog.Info(ctx, fmt.Sprintf("Instance %s is already active. Adopting...", inst.InstanceUid)) - resumedInstanceUid = inst.InstanceUid - } - } - break - } - } - } - - resolvedS3Uid, err := r.resolveS3Uid(ctx, plan.S3Uid.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Failed to resolve s3_uid", err.Error()) - return - } - if resolvedS3Uid != "" { - plan.S3Uid = types.StringValue(resolvedS3Uid) - } - - params := []InstanceParam{ - {SvcOperationCfsParamId: 102, ParamValue: plan.ResourceRealm.ValueString()}, - {SvcOperationCfsParamId: 23, ParamValue: plan.S3Uid.ValueString()}, - {SvcOperationCfsParamId: 82, ParamValue: fmt.Sprintf("%d", plan.Cpu.ValueInt64())}, - {SvcOperationCfsParamId: 81, ParamValue: fmt.Sprintf("%d", plan.Ram.ValueInt64())}, - {SvcOperationCfsParamId: 83, ParamValue: fmt.Sprintf("%d", plan.DiskSize.ValueInt64())}, - {SvcOperationCfsParamId: 80, ParamValue: fmt.Sprintf("%d", plan.NodesCount.ValueInt64())}, - {SvcOperationCfsParamId: 266, ParamValue: fmt.Sprintf("%d", plan.BackupRetention.ValueInt64())}, - {SvcOperationCfsParamId: 265, ParamValue: plan.BackupSchedule.ValueString()}, - {SvcOperationCfsParamId: 310, ParamValue: plan.Version.ValueString()}, - {SvcOperationCfsParamId: 311, ParamValue: plan.Parameters.ValueString()}, - {SvcOperationCfsParamId: 312, ParamValue: strconv.FormatBool(plan.EnablePgPoolerMaster.ValueBool())}, - {SvcOperationCfsParamId: 313, ParamValue: strconv.FormatBool(plan.EnablePgPoolerSlave.ValueBool())}, - {SvcOperationCfsParamId: 314, ParamValue: strconv.FormatBool(plan.AllowNoSSL.ValueBool())}, - {SvcOperationCfsParamId: 329, ParamValue: strconv.FormatBool(plan.AutoScale.ValueBool())}, - {SvcOperationCfsParamId: 330, ParamValue: fmt.Sprintf("%d", plan.AutoScalePercentage.ValueInt64())}, - {SvcOperationCfsParamId: 331, ParamValue: fmt.Sprintf("%d", plan.AutoScaleTechWindow.ValueInt64())}, - {SvcOperationCfsParamId: 339, ParamValue: fmt.Sprintf("%d", plan.AutoScaleQuotaGb.ValueInt64())}, - {SvcOperationCfsParamId: 145, ParamValue: strconv.FormatBool(plan.EnableExternalMaster.ValueBool())}, - {SvcOperationCfsParamId: 146, ParamValue: strconv.FormatBool(plan.EnableExternalSlave.ValueBool())}, - } - - if !plan.IpSpaceMaster.IsNull() { - params = append(params, InstanceParam{SvcOperationCfsParamId: 337, ParamValue: plan.IpSpaceMaster.ValueString()}) - } else { - params = append(params, InstanceParam{SvcOperationCfsParamId: 337, ParamValue: ""}) - } - if !plan.IpSpaceSlave.IsNull() { - params = append(params, InstanceParam{SvcOperationCfsParamId: 338, ParamValue: plan.IpSpaceSlave.ValueString()}) - } else { - params = append(params, InstanceParam{SvcOperationCfsParamId: 338, ParamValue: ""}) - } - - serviceId := 90 - var instanceUid string - - if resumedInstanceUid != "" { - instanceUid = resumedInstanceUid - } else { - svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "create") - if err != nil { - resp.Diagnostics.AddError("Failed to get create operation ID", err.Error()) - return - } - - displayName := plan.Name.ValueString() - var opUid string - instanceUid, opUid, err = r.client.CreateInstance(ctx, displayName, serviceId, svcOperationId, params) - if err != nil { - resp.Diagnostics.AddError("Error creating Postgres", err.Error()) - return - } - - // Wait for operation success - err = r.client.WaitForOperation(ctx, opUid) - if err != nil { - resp.Diagnostics.AddError("Error waiting for Postgres creation", err.Error()) - return - } - } - - plan.ID = types.StringValue(instanceUid) - - // Initialize computed fields to avoid "Unknown" errors if API doesn't return them - plan.AdminUser = types.StringNull() - plan.AdminPassword = types.StringNull() - plan.StandbyUser = types.StringNull() - plan.StandbyPassword = types.StringNull() - plan.InternalHost = types.StringNull() - plan.VaultUserPath = types.StringNull() - plan.VaultUrl = types.StringNull() - plan.MonitoringUrl = types.StringNull() - plan.InternalConnectMaster = types.StringNull() - plan.InternalConnectSlave = types.StringNull() - plan.ExternalConnectMasterIp = types.StringNull() - plan.ExternalConnectMasterFqdn = types.StringNull() - plan.ExternalConnectSlaveIp = types.StringNull() - plan.ExternalConnectSlaveFqdn = types.StringNull() - - // Fetch details to populate Computed fields - stateData, err := r.client.GetInstanceStateDetails(ctx, instanceUid) - if err != nil { - resp.Diagnostics.AddWarning("Failed to populate computed fields", err.Error()) - } else { - // Vault - if vault, ok := stateData["vault"].(map[string]interface{}); ok { - if val, ok := vault["userPath"]; ok { - plan.VaultUserPath = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := vault["url"]; ok { - plan.VaultUrl = types.StringValue(fmt.Sprintf("%v", val)) - } - } - - // Out (Connect details) - if out, ok := stateData["out"].(map[string]interface{}); ok { - // Credentials - if val, ok := out["adminUser"]; ok { - plan.AdminUser = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := out["adminPass"]; ok { - plan.AdminPassword = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := out["standbyUser"]; ok { - plan.StandbyUser = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := out["standbyPass"]; ok { - plan.StandbyPassword = types.StringValue(fmt.Sprintf("%v", val)) - } - - // Monitoring - if monitoring, ok := out["monitoring"].(map[string]interface{}); ok { - if val, ok := monitoring["allDashboards"]; ok { - plan.MonitoringUrl = types.StringValue(fmt.Sprintf("%v", val)) - } - } - - // Internal Connect - if internalConnect, ok := out["internalConnect"].(map[string]interface{}); ok { - if val, ok := internalConnect["master"]; ok && val != "" { - plan.InternalConnectMaster = types.StringValue(fmt.Sprintf("%v", val)) - plan.InternalHost = types.StringValue(fmt.Sprintf("%v", val)) // Sync with master for now - } - if val, ok := internalConnect["slave"]; ok && val != "" { - plan.InternalConnectSlave = types.StringValue(fmt.Sprintf("%v", val)) - } - } - - // External Connect - if externalConnect, ok := out["externalConnect"].(map[string]interface{}); ok { - if master, ok := externalConnect["master"].(map[string]interface{}); ok { - if val, ok := master["ip"]; ok && val != "" { - plan.ExternalConnectMasterIp = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := master["fqdn"]; ok && val != "" { - plan.ExternalConnectMasterFqdn = types.StringValue(fmt.Sprintf("%v", val)) - } - } - if slave, ok := externalConnect["slave"].(map[string]interface{}); ok { - if val, ok := slave["ip"]; ok && val != "" { - plan.ExternalConnectSlaveIp = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := slave["fqdn"]; ok && val != "" { - plan.ExternalConnectSlaveFqdn = types.StringValue(fmt.Sprintf("%v", val)) - } - } - } - } - } - - diags = resp.State.Set(ctx, plan) - resp.Diagnostics.Append(diags...) -} - -func (r *PostgresResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state PostgresResourceModel - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - if !state.S3Uid.IsNull() && !state.S3Uid.IsUnknown() { - resolvedS3Uid, err := r.resolveS3Uid(ctx, state.S3Uid.ValueString()) - if err == nil && resolvedS3Uid != "" { - state.S3Uid = types.StringValue(resolvedS3Uid) - } else if err != nil { - resp.Diagnostics.AddWarning("Failed to resolve s3_uid", err.Error()) - } - } - - stateData, err := r.client.GetInstanceStateDetails(ctx, state.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Error reading Postgres", err.Error()) - return - } - - // Update calculated fields - // Vault - if vault, ok := stateData["vault"].(map[string]interface{}); ok { - if val, ok := vault["userPath"]; ok { - state.VaultUserPath = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := vault["url"]; ok { - state.VaultUrl = types.StringValue(fmt.Sprintf("%v", val)) - } - } - - // Out - if out, ok := stateData["out"].(map[string]interface{}); ok { - // Credentials - if val, ok := out["adminUser"]; ok { - state.AdminUser = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := out["adminPass"]; ok { - state.AdminPassword = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := out["standbyUser"]; ok { - state.StandbyUser = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := out["standbyPass"]; ok { - state.StandbyPassword = types.StringValue(fmt.Sprintf("%v", val)) - } - - // Monitoring - if monitoring, ok := out["monitoring"].(map[string]interface{}); ok { - if val, ok := monitoring["allDashboards"]; ok { - state.MonitoringUrl = types.StringValue(fmt.Sprintf("%v", val)) - } - } - - // Internal Connect - if internalConnect, ok := out["internalConnect"].(map[string]interface{}); ok { - if val, ok := internalConnect["master"]; ok && val != "" { - state.InternalConnectMaster = types.StringValue(fmt.Sprintf("%v", val)) - state.InternalHost = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := internalConnect["slave"]; ok && val != "" { - state.InternalConnectSlave = types.StringValue(fmt.Sprintf("%v", val)) - } - } - - // External Connect - if externalConnect, ok := out["externalConnect"].(map[string]interface{}); ok { - if master, ok := externalConnect["master"].(map[string]interface{}); ok { - if val, ok := master["ip"]; ok && val != "" { - state.ExternalConnectMasterIp = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := master["fqdn"]; ok && val != "" { - state.ExternalConnectMasterFqdn = types.StringValue(fmt.Sprintf("%v", val)) - } - } - if slave, ok := externalConnect["slave"].(map[string]interface{}); ok { - if val, ok := slave["ip"]; ok && val != "" { - state.ExternalConnectSlaveIp = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := slave["fqdn"]; ok && val != "" { - state.ExternalConnectSlaveFqdn = types.StringValue(fmt.Sprintf("%v", val)) - } - } - } - } - - diags = resp.State.Set(ctx, &state) - resp.Diagnostics.Append(diags...) -} - -func (r *PostgresResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan, state PostgresResourceModel - diags := req.Plan.Get(ctx, &plan) - resp.Diagnostics.Append(diags...) - diags = req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - tflog.Info(ctx, "Updating Postgres resource...") - - // Validation: Verify Disk Size reduction - if plan.DiskSize.ValueInt64() < state.DiskSize.ValueInt64() { - resp.Diagnostics.AddError( - "Invalid Disk Upgrade", - fmt.Sprintf("Decreasing disk size is not supported (Current: %d GB, Requested: %d GB)", state.DiskSize.ValueInt64(), plan.DiskSize.ValueInt64()), - ) - return - } - - // Find operation ID for 'modify' - opId, err := r.client.GetOperationIdForInstance(ctx, state.ID.ValueString(), "modify") - if err != nil { - resp.Diagnostics.AddError("Failed to find update operation", err.Error()) - return - } - - // Create Operation - opPayload := map[string]interface{}{ - "instanceUid": state.ID.ValueString(), - "svcOperationId": opId, - "operation": "modify", - } - - var opUid string - opUid, err = r.client.postIgnoreResponse(ctx, "/instanceOperations", opPayload, true) - if err != nil { - resp.Diagnostics.AddError("Failed to create update operation", err.Error()) - return - } - if opUid == "" { - resp.Diagnostics.AddError("Failed to get operation UID", "Empty UID returned") - return - } - - // Fetch Op details to get correct CFS Param IDs for 'modify' - opDetails, err := r.client.GetInstanceOperation(ctx, opUid) - if err != nil { - resp.Diagnostics.AddError("Failed to fetch operation details", err.Error()) - return - } - - paramMapping := make(map[string]int) - for _, p := range opDetails.CfsParams { - paramMapping[p.SvcOperationCfsParam] = p.SvcOperationCfsParamId - } - - type NamedParam struct { - Name string - Value string - } - namedParams := []NamedParam{ - {"resourceCPU", fmt.Sprintf("%d", plan.Cpu.ValueInt64())}, - {"resourceMemory", fmt.Sprintf("%d", plan.Ram.ValueInt64())}, - {"resourceDisk", fmt.Sprintf("%d", plan.DiskSize.ValueInt64())}, - {"resourceInstances", fmt.Sprintf("%d", plan.NodesCount.ValueInt64())}, - {"ext_BACKUP_NUM_TO_RETAIN", fmt.Sprintf("%d", plan.BackupRetention.ValueInt64())}, - {"ext_BACKUP_SCHEDULE", plan.BackupSchedule.ValueString()}, - {"appVersion", plan.Version.ValueString()}, - {"jsonParameters", plan.Parameters.ValueString()}, - {"enablePgPoolerMaster", strconv.FormatBool(plan.EnablePgPoolerMaster.ValueBool())}, - {"enablePgPoolerSlave", strconv.FormatBool(plan.EnablePgPoolerSlave.ValueBool())}, - {"allowNoSSL", strconv.FormatBool(plan.AllowNoSSL.ValueBool())}, - {"autoScale", strconv.FormatBool(plan.AutoScale.ValueBool())}, - {"autoScalePercentage", fmt.Sprintf("%d", plan.AutoScalePercentage.ValueInt64())}, - {"autoScaleTechWindow", fmt.Sprintf("%d", plan.AutoScaleTechWindow.ValueInt64())}, - {"autoScaleQuotaGb", fmt.Sprintf("%d", plan.AutoScaleQuotaGb.ValueInt64())}, - {"needExternalAddressMaster", strconv.FormatBool(plan.EnableExternalMaster.ValueBool())}, - {"needExternalAddressSlave", strconv.FormatBool(plan.EnableExternalSlave.ValueBool())}, - } - - if !plan.IpSpaceMaster.IsNull() { - namedParams = append(namedParams, NamedParam{"ipSpaceNameMaster", plan.IpSpaceMaster.ValueString()}) - } else { - namedParams = append(namedParams, NamedParam{"ipSpaceNameMaster", ""}) - } - if !plan.IpSpaceSlave.IsNull() { - namedParams = append(namedParams, NamedParam{"ipSpaceNameSlave", plan.IpSpaceSlave.ValueString()}) - } else { - namedParams = append(namedParams, NamedParam{"ipSpaceNameSlave", ""}) - } - - // Add Params using dynamic mapping - for _, np := range namedParams { - id, ok := paramMapping[np.Name] - if !ok { - tflog.Debug(ctx, fmt.Sprintf("Param %s not supported for this operation, skipping", np.Name)) - continue - } - - paramPayload := map[string]interface{}{ - "instanceOperationUid": opUid, - "svcOperationCfsParamId": id, - "paramValue": np.Value, - } - if _, err := r.client.postIgnoreResponse(ctx, "/instanceOperationCfsParams", paramPayload, false); err != nil { - resp.Diagnostics.AddError(fmt.Sprintf("Failed to add param %s (%d)", np.Name, id), err.Error()) - return - } - } - - // Run - runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid) - if _, err := r.client.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false); err != nil { - resp.Diagnostics.AddError("Failed to run update", err.Error()) - return - } - - // Wait for operation success - err = r.client.WaitForOperation(ctx, opUid) - if err != nil { - resp.Diagnostics.AddError("Error waiting for Postgres update", err.Error()) - return - } - - // Fetch details to populate Computed fields - stateData, err := r.client.GetInstanceStateDetails(ctx, state.ID.ValueString()) - if err != nil { - tflog.Warn(ctx, fmt.Sprintf("Failed to refresh state after update: %s", err)) - } - - // Initialize all computed fields to avoid 'unknown value' errors after apply - plan.AdminUser = types.StringNull() - plan.AdminPassword = types.StringNull() - plan.StandbyUser = types.StringNull() - plan.StandbyPassword = types.StringNull() - plan.VaultUserPath = types.StringNull() - plan.VaultUrl = types.StringNull() - plan.MonitoringUrl = types.StringNull() - plan.InternalConnectMaster = types.StringNull() - plan.InternalHost = types.StringNull() - plan.InternalConnectSlave = types.StringNull() - plan.ExternalConnectMasterIp = types.StringNull() - plan.ExternalConnectMasterFqdn = types.StringNull() - plan.ExternalConnectSlaveIp = types.StringNull() - plan.ExternalConnectSlaveFqdn = types.StringNull() - - if err == nil { - // Vault - if vault, ok := stateData["vault"].(map[string]interface{}); ok { - if val, ok := vault["userPath"]; ok { - plan.VaultUserPath = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := vault["url"]; ok { - plan.VaultUrl = types.StringValue(fmt.Sprintf("%v", val)) - } - } - - // Out (Connect details) - if out, ok := stateData["out"].(map[string]interface{}); ok { - // Credentials - if val, ok := out["adminUser"]; ok { - plan.AdminUser = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := out["adminPass"]; ok { - plan.AdminPassword = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := out["standbyUser"]; ok { - plan.StandbyUser = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := out["standbyPass"]; ok { - plan.StandbyPassword = types.StringValue(fmt.Sprintf("%v", val)) - } - - // Monitoring - if monitoring, ok := out["monitoring"].(map[string]interface{}); ok { - if val, ok := monitoring["allDashboards"]; ok { - plan.MonitoringUrl = types.StringValue(fmt.Sprintf("%v", val)) - } - } - - // Internal Connect - if internalConnect, ok := out["internalConnect"].(map[string]interface{}); ok { - if val, ok := internalConnect["master"]; ok && val != "" { - plan.InternalConnectMaster = types.StringValue(fmt.Sprintf("%v", val)) - plan.InternalHost = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := internalConnect["slave"]; ok && val != "" { - plan.InternalConnectSlave = types.StringValue(fmt.Sprintf("%v", val)) - } - } - - // External Connect - if externalConnect, ok := out["externalConnect"].(map[string]interface{}); ok { - if master, ok := externalConnect["master"].(map[string]interface{}); ok { - if val, ok := master["ip"]; ok && val != "" { - plan.ExternalConnectMasterIp = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := master["fqdn"]; ok && val != "" { - plan.ExternalConnectMasterFqdn = types.StringValue(fmt.Sprintf("%v", val)) - } - } - if slave, ok := externalConnect["slave"].(map[string]interface{}); ok { - if val, ok := slave["ip"]; ok && val != "" { - plan.ExternalConnectSlaveIp = types.StringValue(fmt.Sprintf("%v", val)) - } - if val, ok := slave["fqdn"]; ok && val != "" { - plan.ExternalConnectSlaveFqdn = types.StringValue(fmt.Sprintf("%v", val)) - } - } - } - } - } - - // fix: plan.ID is Computed (empty in plan), must preserve existing ID from state - plan.ID = state.ID - - diags = resp.State.Set(ctx, plan) - resp.Diagnostics.Append(diags...) -} - -func (r *PostgresResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { - if r.client == nil { - return - } - var plan PostgresResourceModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - if resp.Diagnostics.HasError() { - return - } - if plan.S3Uid.IsNull() || plan.S3Uid.IsUnknown() { - return - } - - resolvedS3Uid, err := r.resolveS3Uid(ctx, plan.S3Uid.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Invalid s3_uid", err.Error()) - return - } - if resolvedS3Uid == "" || resolvedS3Uid == plan.S3Uid.ValueString() { - return - } - - plan.S3Uid = types.StringValue(resolvedS3Uid) - resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...) -} - -func (r *PostgresResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data PostgresResourceModel - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - if data.DeletionProtection.ValueBool() { - tflog.Warn(ctx, "Deletion Protection is ENABLED for Postgres. Resource will remain active in Nubes Cloud. Manual cleanup required.") - return - } - - tflog.Info(ctx, "Deletion Protection is DISABLED for Postgres. Triggering 'suspend'...") - - instanceId := data.ID.ValueString() - - // Выполняем операцию suspend - // Для Postgres используем тот же механизм Instance Run что и для VDC - err := r.triggerSuspend(ctx, instanceId) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend Postgres: %s", err)) - return - } - - tflog.Info(ctx, "Postgres suspended successfully. It will be permanently deleted from the cloud in 14 days.") -} - -func (r *PostgresResource) triggerSuspend(ctx context.Context, instanceId string) error { - // Выполняем операцию suspend через новый унифицированный метод - err := r.client.RunAction(ctx, instanceId, "suspend") - if err != nil { - return err - } - - // Ждем пока статус изменится на suspended - return r.client.WaitForInstanceStatus(ctx, instanceId, "suspended") -} - -func (r *PostgresResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - // Retrieve the full instance data - inst, err := r.client.GetInstanceFull(ctx, req.ID) - if err != nil { - resp.Diagnostics.AddError("Failed to fetch instance for import", err.Error()) - return - } - - var state PostgresResourceModel - state.ID = types.StringValue(req.ID) - - // Top-level fields - if v, ok := inst["displayName"].(string); ok { - state.Name = types.StringValue(v) - } - if v, ok := inst["resourceRealm"].(string); ok { - state.ResourceRealm = types.StringValue(v) - } - - // State params - if stateMap, ok := inst["state"].(map[string]interface{}); ok { - if params, ok := stateMap["params"].(map[string]interface{}); ok { - - // String or Int handling - toInt64 := func(v interface{}) int64 { - switch val := v.(type) { - case float64: - return int64(val) - case int: - return int64(val) - case string: - if i, err := strconv.ParseInt(val, 10, 64); err == nil { - return i - } - } - return 0 - } - - if v, ok := params["resourceCPU"]; ok { - state.Cpu = types.Int64Value(toInt64(v)) - } - if v, ok := params["resourceMemory"]; ok { - state.Ram = types.Int64Value(toInt64(v)) - } - if v, ok := params["resourceDisk"]; ok { - state.DiskSize = types.Int64Value(toInt64(v)) - } - if v, ok := params["resourceInstances"]; ok { - state.NodesCount = types.Int64Value(toInt64(v)) - } - - if v, ok := params["s3Uid"]; ok { - raw := fmt.Sprintf("%v", v) - resolvedS3Uid, err := r.resolveS3Uid(ctx, raw) - if err != nil { - resp.Diagnostics.AddWarning("Failed to resolve s3_uid", err.Error()) - state.S3Uid = types.StringValue(raw) - } else if resolvedS3Uid != "" { - state.S3Uid = types.StringValue(resolvedS3Uid) - } - } - if v, ok := params["appVersion"]; ok { - state.Version = types.StringValue(fmt.Sprintf("%v", v)) - } - - if v, ok := params["ext_BACKUP_SCHEDULE"]; ok { - state.BackupSchedule = types.StringValue(fmt.Sprintf("%v", v)) - } - if v, ok := params["ext_BACKUP_NUM_TO_RETAIN"]; ok { - state.BackupRetention = types.Int64Value(toInt64(v)) - } - - // Booleans - toBool := func(v interface{}) bool { - switch val := v.(type) { - case bool: - return val - case string: - return val == "true" - } - return false - } - - if v, ok := params["enablePgPoolerMaster"]; ok { - state.EnablePgPoolerMaster = types.BoolValue(toBool(v)) - } - if v, ok := params["enablePgPoolerSlave"]; ok { - state.EnablePgPoolerSlave = types.BoolValue(toBool(v)) - } - if v, ok := params["allowNoSSL"]; ok { - state.AllowNoSSL = types.BoolValue(toBool(v)) - } - if v, ok := params["autoScale"]; ok { - state.AutoScale = types.BoolValue(toBool(v)) - } - if v, ok := params["needExternalAddressMaster"]; ok { - state.EnableExternalMaster = types.BoolValue(toBool(v)) - } - if v, ok := params["needExternalAddressSlave"]; ok { - state.EnableExternalSlave = types.BoolValue(toBool(v)) - } - - if v, ok := params["ipSpaceNameMaster"]; ok { - if s := fmt.Sprintf("%v", v); s != "" { - state.IpSpaceMaster = types.StringValue(s) - } - } - if v, ok := params["ipSpaceNameSlave"]; ok { - if s := fmt.Sprintf("%v", v); s != "" { - state.IpSpaceSlave = types.StringValue(s) - } - } - - // Autoscale details - if v, ok := params["autoScalePercentage"]; ok { - state.AutoScalePercentage = types.Int64Value(toInt64(v)) - } - if v, ok := params["autoScaleTechWindow"]; ok { - state.AutoScaleTechWindow = types.Int64Value(toInt64(v)) - } - if v, ok := params["autoScaleQuotaGb"]; ok { - state.AutoScaleQuotaGb = types.Int64Value(toInt64(v)) - } - - // JSON Parameters (map -> json string) - // Нормализуем через json.Compact чтобы совпадало с планом (jsonencode тоже compact) - if v, ok := params["jsonParameters"]; ok { - if jsonBytes, err := json.Marshal(v); err == nil { - var buf bytes.Buffer - if err2 := json.Compact(&buf, jsonBytes); err2 == nil { - state.Parameters = types.StringValue(buf.String()) - } else { - state.Parameters = types.StringValue(string(jsonBytes)) - } - } - } - } - } - - state.DeletionProtection = types.BoolValue(true) - - diags := resp.State.Set(ctx, &state) - resp.Diagnostics.Append(diags...) -} diff --git a/internal/provider/provider.go b/internal/provider/provider.go deleted file mode 100644 index 276c13f..0000000 --- a/internal/provider/provider.go +++ /dev/null @@ -1,199 +0,0 @@ -package provider - -import ( - "context" - "crypto/tls" - "net/http" - "os" - "strings" - "time" - - "github.com/hashicorp/terraform-plugin-framework/datasource" - "github.com/hashicorp/terraform-plugin-framework/provider" - "github.com/hashicorp/terraform-plugin-framework/provider/schema" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/types" - - "terraform-provider-mycloud/internal/core" - "terraform-provider-mycloud/internal/generated" -) - -var _ provider.Provider = &NubesProvider{} - -type NubesProvider struct { - version string -} - -// NubesProviderModel — конфигурация провайдера из HCL-блока provider {}. -// Каждое поле соответствует атрибуту в Schema() ниже. -// Поля Optional: если не указаны — берутся из env-переменных или defaults. -type NubesProviderModel struct { - ApiEndpoint types.String `tfsdk:"api_endpoint"` - ApiToken types.String `tfsdk:"api_token"` - // Insecure отключает проверку TLS-сертификата сервера. - // НЕ использовать в продакшн! Только для dev-стендов с самоподписанным сертом. - // Может быть задан также через env NUBES_INSECURE=true. - Insecure types.Bool `tfsdk:"insecure"` - // LogLevel задаёт уровень вывода этапов операций: "none" (default) | "info" | "debug". - // Может быть переопределён на уровне ресурса через атрибут log_level ресурса. - LogLevel types.String `tfsdk:"log_level"` -} - -type NubesClient struct { - HttpClient *http.Client - ApiEndpoint string - ApiToken string -} - -func New(version string) func() provider.Provider { - return func() provider.Provider { - return &NubesProvider{ - version: version, - } - } -} - -func (p *NubesProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { - resp.TypeName = "nubes" - resp.Version = p.version -} - -func (p *NubesProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { - resp.Schema = schema.Schema{ - Attributes: map[string]schema.Attribute{ - "api_endpoint": schema.StringAttribute{ - MarkdownDescription: "API Gateway endpoint for Nubes Cloud", - Optional: true, - }, - "api_token": schema.StringAttribute{ - MarkdownDescription: "API authentication token", - Optional: true, - Sensitive: true, - }, - // insecure: отключает проверку TLS-сертификата Nubes API. - // По умолчанию false — сертификат проверяется (безопасно). - // Устанавливать true только на dev-стенде с самоподписанным сертом. - // Альтернатива: env NUBES_INSECURE=true (не требует правки .tf файлов). - "insecure": schema.BoolAttribute{ - MarkdownDescription: "Disable TLS certificate verification. Use only for dev environments with self-signed certs. Can also be set via NUBES_INSECURE env var.", - Optional: true, - }, - // log_level: уровень вывода этапов операций во время terraform apply/destroy. - // "none" (default) — не выводить ничего. - // "info" — выводить строку на каждый этап: [OK ] Валидация — 74.8 sec - // "debug" — как info + детали каждого этапа без timestamp-мусора. - // Может быть переопределён на уровне ресурса через атрибут log_level. - "log_level": schema.StringAttribute{ - MarkdownDescription: "Operation stages log level: none (default), info, debug.", - Optional: true, - }, - }, - } -} - -func (p *NubesProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { - var config NubesProviderModel - - resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) - if resp.Diagnostics.HasError() { - return - } - - // Default values - // TODO(api-gateway): default is legacy proxy (index.cfm?endpoint=). - // core.UniversalClient still uses ?endpoint= pattern — needs migration to REST paths. - apiEndpoint := "https://deck-api.ngcloud.ru/api/v1/index.cfm" - apiToken := "" - - if !config.ApiEndpoint.IsNull() { - apiEndpoint = config.ApiEndpoint.ValueString() - } - - if !config.ApiToken.IsNull() { - apiToken = strings.TrimSpace(config.ApiToken.ValueString()) - } else { - // Try to get from environment - if token := os.Getenv("NUBES_API_TOKEN"); token != "" { - apiToken = strings.TrimSpace(token) - } else { - // Try to read from ~/.nubes_token file - homeDir, err := os.UserHomeDir() - if err == nil { - tokenFile := homeDir + "/.nubes_token" - if data, err := os.ReadFile(tokenFile); err == nil { - apiToken = strings.TrimSpace(string(data)) - } - } - } - } - - // --- TLS: определяем нужно ли пропустить проверку сертификата --- - // Приоритет: config.insecure > env NUBES_INSECURE > false (безопасный default). - // Nubes Cloud API использует валидный TLS-сертификат от доверенного CA, - // поэтому в продакшн InsecureSkipVerify должен быть false. - // true оставлен только для совместимости с dev-стендами без нормального сертификата. - insecureSkipVerify := false - if !config.Insecure.IsNull() && !config.Insecure.IsUnknown() { - // Явно задано в provider {} блоке HCL - insecureSkipVerify = config.Insecure.ValueBool() - } else if os.Getenv("NUBES_INSECURE") == "true" { - // Задано через переменную окружения (удобно для CI/CD без правки .tf файлов) - insecureSkipVerify = true - } - - // Custom transport based on DefaultTransport. - // Клонируем DefaultTransport чтобы сохранить все системные настройки (proxy, timeouts). - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.TLSHandshakeTimeout = 60 * time.Second - - // Force HTTP/1.1: Nubes API не поддерживает HTTP/2, принудительно отключаем. - // MinVersion TLS 1.2 — минимально безопасная версия TLS. - transport.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: insecureSkipVerify, // false в prod, true только для dev - NextProtos: []string{"http/1.1"}, - MinVersion: tls.VersionTLS12, - } - transport.ForceAttemptHTTP2 = false - - // Use Core Universal Client - client := &core.UniversalClient{ - HttpClient: &http.Client{ - Transport: transport, - Timeout: 300 * time.Second, - }, - ApiEndpoint: apiEndpoint, - ApiToken: apiToken, - LogLevel: config.LogLevel.ValueString(), - } - - resp.DataSourceData = client - resp.ResourceData = client -} - -func (p *NubesProvider) Resources(ctx context.Context) []func() resource.Resource { - return []func() resource.Resource{ - NewTubulusResource, - NewOrganizationResource, - NewVMResource, - NewVDCResource, - NewEdgeResource, - NewVAppResource, - NewQuickStartResource, - NewPostgresResource, - NewS3BucketResource, - NewPgAdminResource, - // Generated Resources - generated.NewBolvankaResource, - generated.NewBolvankaUniversalResource, - generated.NewBolvankaUniversalLifecycleResource, - } -} - -func (p *NubesProvider) DataSources(ctx context.Context) []func() datasource.DataSource { - return []func() datasource.DataSource{ - NewVDCDataSource, - NewEdgeDataSource, - NewVAppDataSource, - NewServiceInstanceDataSource, - } -} diff --git a/internal/provider/quick_start_resource.go b/internal/provider/quick_start_resource.go deleted file mode 100644 index a6067d7..0000000 --- a/internal/provider/quick_start_resource.go +++ /dev/null @@ -1,491 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var _ resource.Resource = &QuickStartResource{} -var _ resource.ResourceWithImportState = &QuickStartResource{} - -func NewQuickStartResource() resource.Resource { - return &QuickStartResource{} -} - -type QuickStartResource struct { - client *NubesClient -} - -type QuickStartResourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - Description types.String `tfsdk:"description"` - Status types.String `tfsdk:"status"` - - // Параметры из HAR файла - OrgDomain types.String `tfsdk:"org_domain"` // 332: ngcloud.ru - VAppName types.String `tfsdk:"vapp_name"` // 334: vappa - ProviderVDC types.String `tfsdk:"provider_vdc"` // 358: sandbox-v1cl1-pvdc - EdgeCount types.Int64 `tfsdk:"edge_count"` // 359: 1 - ExternalNetwork types.String `tfsdk:"external_network"` // 360: internet-ipv4-v1 - StorageProfiles types.String `tfsdk:"storage_profiles"` // 399: [{"name": "", "size": 1024}] - NetworkPool types.String `tfsdk:"network_pool"` // 400: nsxt-sandbox-geneve-np - CPUAllocationPercent types.Int64 `tfsdk:"cpu_allocation_percent"` // 401: 20 - RAMAllocationPercent types.Int64 `tfsdk:"ram_allocation_percent"` // 402: 20 - ServiceEngineGroup types.String `tfsdk:"service_engine_group"` // 403: SEGROUP-SANDBOX-CL1-SHARED-01 - ThinProvisioning types.Bool `tfsdk:"thin_provisioning"` // 404: true - FastProvisioning types.Bool `tfsdk:"fast_provisioning"` // 405: false - VDCNetworkQuota types.Int64 `tfsdk:"vdc_network_quota"` // 566: 1 - CPUQuotaMhz types.Int64 `tfsdk:"cpu_quota_mhz"` // 567: 10 - RAMQuotaGb types.Int64 `tfsdk:"ram_quota_gb"` // 568: 1 -} - -func (r *QuickStartResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_quick_start" -} - -func (r *QuickStartResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Nubes Quick Start - создаёт полное окружение (Org + VDC + Edge + vApp)", - - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "Quick Start identifier (UUID)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "display_name": schema.StringAttribute{ - MarkdownDescription: "Display name", - Required: true, - }, - "description": schema.StringAttribute{ - MarkdownDescription: "Description", - Optional: true, - }, - "status": schema.StringAttribute{ - MarkdownDescription: "Current status", - Computed: true, - }, - "org_domain": schema.StringAttribute{ - MarkdownDescription: "Organization domain (например: ngcloud.ru)", - Required: true, - }, - "vapp_name": schema.StringAttribute{ - MarkdownDescription: "vApp name", - Required: true, - }, - "provider_vdc": schema.StringAttribute{ - MarkdownDescription: "Provider VDC name", - Required: true, - }, - "edge_count": schema.Int64Attribute{ - MarkdownDescription: "Number of Edge gateways", - Required: true, - }, - "external_network": schema.StringAttribute{ - MarkdownDescription: "External network name", - Required: true, - }, - "storage_profiles": schema.StringAttribute{ - MarkdownDescription: "Storage profiles JSON", - Required: true, - }, - "network_pool": schema.StringAttribute{ - MarkdownDescription: "Network pool name", - Required: true, - }, - "cpu_allocation_percent": schema.Int64Attribute{ - MarkdownDescription: "CPU allocation percent", - Required: true, - }, - "ram_allocation_percent": schema.Int64Attribute{ - MarkdownDescription: "RAM allocation percent", - Required: true, - }, - "service_engine_group": schema.StringAttribute{ - MarkdownDescription: "Service Engine Group", - Required: true, - }, - "thin_provisioning": schema.BoolAttribute{ - MarkdownDescription: "Enable thin provisioning", - Required: true, - }, - "fast_provisioning": schema.BoolAttribute{ - MarkdownDescription: "Enable fast provisioning", - Required: true, - }, - "vdc_network_quota": schema.Int64Attribute{ - MarkdownDescription: "VDC network quota", - Required: true, - }, - "cpu_quota_mhz": schema.Int64Attribute{ - MarkdownDescription: "CPU quota in MHz", - Required: true, - }, - "ram_quota_gb": schema.Int64Attribute{ - MarkdownDescription: "RAM quota in GB", - Required: true, - }, - }, - } -} - -func (r *QuickStartResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - r.client = client -} - -func (r *QuickStartResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data QuickStartResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // Step 1: Create instance - instanceReq := CreateInstanceRequest{ - ServiceId: 113, // Quick Start service ID - DisplayName: data.DisplayName.ValueString(), - Descr: data.Description.ValueString(), - } - - jsonData, err := json.Marshal(instanceReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal instance request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError("API Error", fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body))) - return - } - - location := httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in response") - return - } - instanceId := location[2:] - data.ID = types.StringValue(instanceId) - - // Step 2: Create operation - operationReq := CreateOperationRequest{ - InstanceUid: instanceId, - Operation: "create", - } - - jsonData, err = json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError("API Error", fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body))) - return - } - - location = httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in operation response") - return - } - operationId := location[2:] - - // Step 3: Submit all parameters - params := map[int]string{ - 332: data.OrgDomain.ValueString(), - 334: data.VAppName.ValueString(), - 358: data.ProviderVDC.ValueString(), - 359: fmt.Sprintf("%d", data.EdgeCount.ValueInt64()), - 360: data.ExternalNetwork.ValueString(), - 399: data.StorageProfiles.ValueString(), - 400: data.NetworkPool.ValueString(), - 401: fmt.Sprintf("%d", data.CPUAllocationPercent.ValueInt64()), - 402: fmt.Sprintf("%d", data.RAMAllocationPercent.ValueInt64()), - 403: data.ServiceEngineGroup.ValueString(), - 404: fmt.Sprintf("%t", data.ThinProvisioning.ValueBool()), - 405: fmt.Sprintf("%t", data.FastProvisioning.ValueBool()), - 566: fmt.Sprintf("%d", data.VDCNetworkQuota.ValueInt64()), - 567: fmt.Sprintf("%d", data.CPUQuotaMhz.ValueInt64()), - 568: fmt.Sprintf("%d", data.RAMQuotaGb.ValueInt64()), - } - - for paramId, paramValue := range params { - paramReq := CreateCfsParamRequest{ - InstanceOperationUid: operationId, - SvcOperationCfsParamId: paramId, - ParamValue: paramValue, - } - - jsonData, err = json.Marshal(paramReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal param request: %s", err)) - return - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create param request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit parameter: %s", err)) - return - } - - respBody, _ := io.ReadAll(httpResp.Body) - httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusOK { - resp.Diagnostics.AddError("API Error", fmt.Sprintf("Submit parameter %d failed with status %d: %s", paramId, httpResp.StatusCode, string(respBody))) - return - } - } - - // Step 4: Run operation - httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations/"+operationId+"/run", bytes.NewBuffer([]byte("{}"))) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create run request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to run operation: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK && httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusAccepted { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError("API Error", fmt.Sprintf("Run operation failed with status %d: %s", httpResp.StatusCode, string(body))) - return - } - - // Wait and read status - time.Sleep(5 * time.Second) - - readData, err := r.readInstance(ctx, instanceId) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *QuickStartResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data QuickStartResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) - return - } - - data.Status = types.StringValue(instanceResp.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *QuickStartResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - resp.Diagnostics.AddError("Not Supported", "Quick Start resource cannot be updated") -} - -func (r *QuickStartResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data QuickStartResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - operationReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "delete", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create delete operation: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError("API Error", fmt.Sprintf("Create delete operation failed with status %d: %s", httpResp.StatusCode, string(body))) - return - } - - location := httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in operation response") - return - } - operationId := location[2:] - - // Run delete operation - httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations/"+operationId+"/run", bytes.NewBuffer([]byte("{}"))) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create run request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to run delete operation: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError("API Error", fmt.Sprintf("Run delete operation failed with status %d: %s", httpResp.StatusCode, string(body))) - return - } -} - -func (r *QuickStartResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) -} - -func (r *QuickStartResource) readInstance(ctx context.Context, instanceId string) (*InstanceResponse, error) { - httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+instanceId, nil) - if err != nil { - return nil, err - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - - if httpResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var instanceResp InstanceResponse - if err := json.Unmarshal(body, &instanceResp); err != nil { - return nil, err - } - - return &instanceResp, nil -} diff --git a/internal/provider/s3bucket_resource.go b/internal/provider/s3bucket_resource.go deleted file mode 100644 index 4ab6358..0000000 --- a/internal/provider/s3bucket_resource.go +++ /dev/null @@ -1,255 +0,0 @@ -package provider - -// [НЕ ИЗМЕНЯТЬ !!!!] -// Данный ресурс реализует паттерн "Nubes Flow" для S3 бакета. -// ВАЖНО: Операция Update (изменение) для S3 бакетов в API Nubes отсутствует. -// Любое изменение атрибутов в Terraform приведет к пересозданию ресурса (RequiresReplace). - -import ( - "context" - "fmt" - "strconv" - - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -var _ resource.Resource = &S3BucketResource{} - -type S3BucketResource struct { - client *NubesClient -} - -func NewS3BucketResource() resource.Resource { - return &S3BucketResource{} -} - -type S3BucketResourceModel struct { - ID types.String `tfsdk:"id"` - S3UserUid types.String `tfsdk:"s3_user_uid"` - BucketName types.String `tfsdk:"bucket_name"` - MaxSize types.Int64 `tfsdk:"max_size"` - ReadAll types.Bool `tfsdk:"read_all"` - ListAll types.Bool `tfsdk:"list_all"` - CorsAll types.Bool `tfsdk:"cors_all"` - Placement types.String `tfsdk:"placement"` -} - -func (r *S3BucketResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_s3_bucket" -} - -func (r *S3BucketResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - Description: "Manages an S3 Bucket resource.", - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "s3_user_uid": schema.StringAttribute{ - Required: true, - Description: "UID Корневой услуги S3 (Param 124). Это UUID экземпляра услуги S3 Object Storage (svcId 12). Например: 6d6061cb-b0c1-44b9-8969-a70f08fe673c", - Validators: []validator.String{ - UUIDLike(), - }, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "bucket_name": schema.StringAttribute{ - Required: true, - Description: "The name of the bucket (param 125)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - "max_size": schema.Int64Attribute{ - Optional: true, - Computed: true, - Default: int64default.StaticInt64(-1), - Description: "Max size of the bucket, -1 for unlimited (param 126)", - PlanModifiers: []planmodifier.Int64{ - int64planmodifier.RequiresReplace(), - }, - }, - "read_all": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - Description: "Enable read all (param 127)", - PlanModifiers: []planmodifier.Bool{ - boolplanmodifier.RequiresReplace(), - }, - }, - "list_all": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - Description: "Enable list all (param 128)", - PlanModifiers: []planmodifier.Bool{ - boolplanmodifier.RequiresReplace(), - }, - }, - "cors_all": schema.BoolAttribute{ - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - Description: "Enable CORS all (param 129)", - PlanModifiers: []planmodifier.Bool{ - boolplanmodifier.RequiresReplace(), - }, - }, - "placement": schema.StringAttribute{ - Optional: true, - Computed: true, - Default: stringdefault.StaticString("HOT"), - Description: "Placement strategy (HOT/COLD) (param 130)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, - }, - }, - } -} - -func (r *S3BucketResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError("Unexpected Resource Configure Type", fmt.Sprintf("Expected *NubesClient, got: %T. Please report this issue to the provider developers.", req.ProviderData)) - return - } - r.client = client -} - -func (r *S3BucketResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var plan S3BucketResourceModel - diags := req.Plan.Get(ctx, &plan) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - tflog.Info(ctx, "Creating S3 Bucket resource...") - - // 1. Подготовка параметров для создания. - // Каждый параметр соответствует svcOperationCfsParamId из API. - // Эти ID фиксированы для услуги S3 (ServiceId 13). - params := []InstanceParam{ - {SvcOperationCfsParamId: 124, ParamValue: plan.S3UserUid.ValueString()}, - {SvcOperationCfsParamId: 125, ParamValue: plan.BucketName.ValueString()}, - {SvcOperationCfsParamId: 126, ParamValue: fmt.Sprintf("%d", plan.MaxSize.ValueInt64())}, - {SvcOperationCfsParamId: 127, ParamValue: strconv.FormatBool(plan.ReadAll.ValueBool())}, - {SvcOperationCfsParamId: 128, ParamValue: strconv.FormatBool(plan.ListAll.ValueBool())}, - {SvcOperationCfsParamId: 129, ParamValue: strconv.FormatBool(plan.CorsAll.ValueBool())}, - {SvcOperationCfsParamId: 130, ParamValue: plan.Placement.ValueString()}, - } - - // Service ID 13 для S3 (бакеты) - serviceId := 13 - - // Динамический поиск ID операции "create". - // В Nubes для каждого сервиса свой набор ID операций. - svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "create") - if err != nil { - resp.Diagnostics.AddError("Failed to get create operation ID", err.Error()) - return - } - - // CreateInstance выполняет полный цикл "Nubes Flow": - // 1. POST /instances (создание объекта) - // 2. POST /instanceOperations (инициализация визарда) - // 3. GET /instanceOperations?fields=cfsParams (получение списка ожидаемых параметров) - // 4. POST /instanceOperationCfsParams (синхронизация значений) - // 5. POST /run {BODY: {}} (запуск выполнения) - instanceUid, opUid, err := r.client.CreateInstance(ctx, plan.BucketName.ValueString(), serviceId, svcOperationId, params) - if err != nil { - resp.Diagnostics.AddError("Error creating S3 bucket", err.Error()) - return - } - - // Ожидание завершения операции. - err = r.client.WaitForOperation(ctx, opUid) - if err != nil { - resp.Diagnostics.AddError("Error waiting for S3 bucket ready", err.Error()) - return - } - - plan.ID = types.StringValue(instanceUid) - diags = resp.State.Set(ctx, plan) - resp.Diagnostics.Append(diags...) -} - -func (r *S3BucketResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var state S3BucketResourceModel - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - // Implement Read if necessary. For now, just maintain state. - // Ideally, fetch instance details and update state. - // We can use r.client.GetInstance(ctx, state.ID.ValueString()) to check if it exists or is deleted. - - /* - instance, err := r.client.GetInstance(ctx, state.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Error reading S3 bucket", err.Error()) - return - } - if instance == nil || instance.IsDeleted { - resp.State.RemoveResource(ctx) - return - } - // Update fields if needed - */ - - diags = resp.State.Set(ctx, &state) - resp.Diagnostics.Append(diags...) -} - -func (r *S3BucketResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - // [НЕ ИЗМЕНЯТЬ !!!!] - // API Nubes не поддерживает операцию Modify для S3 бакетов. - // Этот метод оставлен пустым, так как RequiresReplace в схеме должен предотвращать его вызов для критичных полей. -} - -func (r *S3BucketResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var state S3BucketResourceModel - diags := req.State.Get(ctx, &state) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - - // Assume delete operation exists - // Get "delete" operation ID - serviceId := 13 - svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "delete") - if err != nil { - resp.Diagnostics.AddError("Failed to get delete operation ID", err.Error()) - return - } - - err = r.client.DeleteInstance(ctx, state.ID.ValueString(), svcOperationId) - if err != nil { - resp.Diagnostics.AddError("Error deleting S3 bucket", err.Error()) - return - } -} diff --git a/internal/provider/service_instance_data_source.go b/internal/provider/service_instance_data_source.go deleted file mode 100644 index 0430227..0000000 --- a/internal/provider/service_instance_data_source.go +++ /dev/null @@ -1,120 +0,0 @@ -package provider - -import ( - "context" - "fmt" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/datasource" - "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var _ datasource.DataSource = &ServiceInstanceDataSource{} - -func NewServiceInstanceDataSource() datasource.DataSource { - return &ServiceInstanceDataSource{} -} - -type ServiceInstanceDataSource struct { - client *NubesClient -} - -type ServiceInstanceDataSourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - ServiceId types.Int64 `tfsdk:"service_id"` - ServiceName types.String `tfsdk:"service_name"` -} - -func (d *ServiceInstanceDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_service_instance" -} - -func (d *ServiceInstanceDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Data source для получения UUID сервиса (Instance) по его имени (DisplayName). Позволяет использовать human-readable имена (например 's3-111805') вместо UUID.", - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - MarkdownDescription: "UUID найденного сервиса (instanceUid)", - Computed: true, - }, - "display_name": schema.StringAttribute{ - MarkdownDescription: "Имя (DisplayName) для поиска. Например 's3-111805'", - Required: true, - }, - "service_id": schema.Int64Attribute{ - MarkdownDescription: "ID типа сервиса для дополнительной фильтрации (например 12 для S3). Опционально.", - Optional: true, - }, - "service_name": schema.StringAttribute{ - MarkdownDescription: "Название типа сервиса (например 'S3 Object Storage')", - Computed: true, - }, - }, - } -} - -func (d *ServiceInstanceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Data Source Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - d.client = client -} - -func (d *ServiceInstanceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data ServiceInstanceDataSourceModel - - resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - displayName := data.DisplayName.ValueString() - - instances, err := d.client.GetInstances(ctx) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to fetch instances list: %s", err)) - return - } - - var foundInstance *InstanceSummary - - // Поиск по DisplayName - for _, inst := range instances { - if strings.TrimSpace(inst.DisplayName) == strings.TrimSpace(displayName) { - // Если задан service_id, проверяем и его - if !data.ServiceId.IsNull() && !data.ServiceId.IsUnknown() { - if int64(inst.ServiceId) != data.ServiceId.ValueInt64() { - continue - } - } - foundInstance = &inst - break - } - } - - if foundInstance == nil { - resp.Diagnostics.AddError( - "Instance Not Found", - fmt.Sprintf("Could not find instance with DisplayName '%s'", displayName), - ) - return - } - - data.ID = types.StringValue(foundInstance.InstanceUid) - data.ServiceName = types.StringValue(foundInstance.Svc) - data.ServiceId = types.Int64Value(int64(foundInstance.ServiceId)) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} diff --git a/internal/provider/tubulus_ai.go b/internal/provider/tubulus_ai.go deleted file mode 100644 index 0a6f211..0000000 --- a/internal/provider/tubulus_ai.go +++ /dev/null @@ -1,133 +0,0 @@ -package provider - -import ( - "context" - "encoding/json" - "fmt" - "log" - "os" - "strings" - - "github.com/google/generative-ai-go/genai" - "google.golang.org/api/option" -) - -type AIConfig struct { - DurationMs *int64 `json:"duration_ms,omitempty"` - FailAtStart *bool `json:"fail_at_start,omitempty"` - FailInProgress *bool `json:"fail_in_progress,omitempty"` - WhereFail *int64 `json:"where_fail,omitempty"` - BodyMessage *string `json:"body_message,omitempty"` - ResourceRealm *string `json:"resource_realm,omitempty"` - MapExample *string `json:"map_example,omitempty"` - JsonExample *string `json:"json_example,omitempty"` - YamlExample *string `json:"yaml_example,omitempty"` -} - -func (r *TubulusResource) askGemini(ctx context.Context, instruction string) (*AIConfig, error) { - // Приоритет: переменная окружения → файл → ошибка - apiKey := os.Getenv("GEMINI_API_KEY") - if apiKey == "" { - // Пробуем прочитать из файла (для локальной разработки) - keyBytes, err := os.ReadFile("gemini_api_key.txt") - if err == nil { - apiKey = strings.TrimSpace(string(keyBytes)) - } - } - if apiKey == "" { - return nil, fmt.Errorf("GEMINI_API_KEY not found (set env var or create gemini_api_key.txt)") - } - - client, err := genai.NewClient(ctx, option.WithAPIKey(apiKey)) - if err != nil { - return nil, err - } - defer client.Close() - - model := client.GenerativeModel("gemini-2.0-flash") - - // Настройка модели: заставляем её возвращать строго JSON - model.ResponseMIMEType = "application/json" - - systemPrompt := `Ты — эксперт по инфраструктуре и помощник для Terraform провайдера Nubes. -Твоя задача — распарсить пожелания пользователя (даже самые простые и неточные) и превратить их в JSON-конфигурацию для тестового ресурса Tubulus. - -=== ПОЛЯ JSON (ВСЕ обязательны!) === - -1. "duration_ms" (integer): Сколько миллисекунд работает ресурс. - Примеры: "5 секунд" → 5000, "минута" → 60000, "быстро" → 1000, "долго" → 120000, "очень долго" → 300000 - Дефолт если не указано: 5000 - -2. "fail_at_start" (boolean): Сломаться ли сразу при запуске? - Примеры: "сломай сразу" → true, "упади в начале" → true - Дефолт: false - -3. "fail_in_progress" (boolean): Сломаться ли в процессе работы? - Примеры: "упади в середине" → true, "сломай в процессе" → true, "упадет на втором этапе" → true - Дефолт: false - -4. "where_fail" (integer): На каком этапе сломаться? ТОЛЬКО [0, 1, 2, 3] - 0 = не ломается - 1 = подготовка (prepare) - 2 = заполнение данных (data_fill) — ДЕФОЛТ если fail_in_progress=true - 3 = после записи в Vault - Примеры: "первый этап" → 1, "второй этап" → 2, "последний" → 3 - Дефолт: 0, но если fail_in_progress=true, то 2 - -5. "body_message" (string): Текст для записи в Vault (как секрет/пароль). - Примеры: "напиши hello" → "hello", "секрет 123" → "секрет 123", "привет мир" → "привет мир" - Если НЕ указан текст явно — оставь null (не "ai_generated") - Дефолт: null - -6. "resource_realm" (string): Окружение. Всегда "dummy". - Дефолт: "dummy" - -=== ВАЖНЫЕ ПРАВИЛА === -• Возвращай СТРОГО JSON с ВСЕМИ 6 полями -• Понимай разговорный язык: "сделай быстро" = duration_ms: 1000, "пусть долго работает" = duration_ms: 120000 -• Если пользователь написал текст для Vault ("напиши X", "положи Y") — используй его в body_message, иначе null -• Ответ БЕЗ пояснений, только {"duration_ms": ..., "fail_at_start": ..., ...} - -=== ПРИМЕРЫ === -Запрос: "сделай быстро" -→ {"duration_ms": 1000, "fail_at_start": false, "fail_in_progress": false, "where_fail": 0, "body_message": null, "resource_realm": "dummy"} - -Запрос: "пусть работает минуту и напиши привет" -→ {"duration_ms": 60000, "fail_at_start": false, "fail_in_progress": false, "where_fail": 0, "body_message": "привет", "resource_realm": "dummy"} - -Запрос: "сломай на втором этапе" -→ {"duration_ms": 5000, "fail_at_start": false, "fail_in_progress": true, "where_fail": 2, "body_message": null, "resource_realm": "dummy"} - -Инструкция: ` + instruction - - resp, err := model.GenerateContent(ctx, genai.Text(systemPrompt)) - if err != nil { - return nil, err - } - - if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { - return nil, fmt.Errorf("gemini returned no content") - } - - part := resp.Candidates[0].Content.Parts[0] - text, ok := part.(genai.Text) - if !ok { - return nil, fmt.Errorf("gemini returned non-text content") - } - - log.Printf("[DEBUG] Gemini response: %s", string(text)) - - // Some Gemini versions return an array with one object when constrained to JSON - // Let's try to unmarshal as object first, then as array if it fails - var config AIConfig - if err := json.Unmarshal([]byte(text), &config); err != nil { - var configs []AIConfig - if errArray := json.Unmarshal([]byte(text), &configs); errArray == nil && len(configs) > 0 { - config = configs[0] - } else { - return nil, fmt.Errorf("failed to unmarshal gemini response: %w", err) - } - } - - return &config, nil -} diff --git a/internal/provider/tubulus_resource.go b/internal/provider/tubulus_resource.go deleted file mode 100644 index dafe6b2..0000000 --- a/internal/provider/tubulus_resource.go +++ /dev/null @@ -1,1049 +0,0 @@ -package provider - -// [НЕ ИЗМЕНЯТЬ !!!!] -// Данный ресурс реализует паттерн "Nubes Flow" для тестовой болванки (Tubulus). -// ВАЖНО: Ресурс предназначен для тестирования жизненного цикла и не имеет операции Update. -// Любое изменение параметров приведет к пересозданию инстанса. - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "strings" // Added strings - "time" - - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var _ resource.Resource = &TubulusResource{} -var _ resource.ResourceWithImportState = &TubulusResource{} -var _ resource.ResourceWithModifyPlan = &TubulusResource{} - -func NewTubulusResource() resource.Resource { - return &TubulusResource{} -} - -type TubulusResource struct { - client *NubesClient -} - -type TubulusResourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - Description types.String `tfsdk:"description"` - Status types.String `tfsdk:"status"` - DurationMs types.Int64 `tfsdk:"duration_ms"` - FailAtStart types.Bool `tfsdk:"fail_at_start"` - FailInProgress types.Bool `tfsdk:"fail_in_progress"` - WhereFail types.Int64 `tfsdk:"where_fail"` - BodyMessage types.String `tfsdk:"body_message"` - ResourceRealm types.String `tfsdk:"resource_realm"` - MapExample types.String `tfsdk:"map_example"` - JsonExample types.String `tfsdk:"json_example"` - YamlExample types.String `tfsdk:"yaml_example"` -} - -type CreateInstanceRequest struct { - ServiceId int `json:"serviceId"` - DisplayName string `json:"displayName"` - Descr string `json:"descr,omitempty"` -} - -type CreateOperationRequest struct { - InstanceUid string `json:"instanceUid"` - Operation string `json:"operation"` -} - -type InstanceResponse struct { - Uid string `json:"instanceUid"` - DisplayName string `json:"displayName"` - Descr string `json:"descr"` - Status string `json:"explainedStatus"` // Правильное поле из API -} - -type GetInstanceResponse struct { - Instance InstanceResponse `json:"instance"` -} - -// GetOperationResponse and associated types removed as they are now in client_impl.go - -type CreateCfsParamRequest struct { - InstanceOperationCfsParamUid string `json:"instanceOperationCfsParamUid,omitempty"` - InstanceOperationUid string `json:"instanceOperationUid"` - SvcOperationCfsParamId int `json:"svcOperationCfsParamId"` - ParamValue string `json:"paramValue"` -} - -func (r *TubulusResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_tubulus_instance" -} - -func (r *TubulusResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Nubes Tubulus Instance resource with Gemini AI integration", - - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "Instance identifier (UUID)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "display_name": schema.StringAttribute{ - MarkdownDescription: "Instance display name", - Required: true, - }, - "description": schema.StringAttribute{ - MarkdownDescription: "Instance description", - Optional: true, - }, - "status": schema.StringAttribute{ - MarkdownDescription: "Current status of the instance", - Computed: true, - }, - "duration_ms": schema.Int64Attribute{ - MarkdownDescription: "Duration in ms (sleep before completion)", - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.Int64{ - int64planmodifier.UseStateForUnknown(), - }, - }, - "fail_at_start": schema.BoolAttribute{ - MarkdownDescription: "If true, job fails immediately", - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.Bool{ - boolplanmodifier.UseStateForUnknown(), - }, - }, - "fail_in_progress": schema.BoolAttribute{ - MarkdownDescription: "If true, job fails during execution", - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.Bool{ - boolplanmodifier.UseStateForUnknown(), - }, - }, - "where_fail": schema.Int64Attribute{ - MarkdownDescription: "1=prepare, 2=data_fill, 3=after_vault", - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.Int64{ - int64planmodifier.UseStateForUnknown(), - }, - }, - "body_message": schema.StringAttribute{ - MarkdownDescription: "Message to be sent to Vault", - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "resource_realm": schema.StringAttribute{ - MarkdownDescription: "Platform realm (e.g. dummy)", - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "map_example": schema.StringAttribute{ - MarkdownDescription: "Example map input (JSON string)", - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "json_example": schema.StringAttribute{ - MarkdownDescription: "Example JSON input (JSON string)", - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "yaml_example": schema.StringAttribute{ - MarkdownDescription: "Example YAML input", - Optional: true, - Computed: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - }, - } -} - -func (r *TubulusResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { - // AI Logic disabled as per user instruction. -} - -func (r *TubulusResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - r.client = client -} - -// doRequestWithRetry executes an HTTP request with retries for transient errors. -func (r *TubulusResource) doRequestWithRetry(ctx context.Context, httpReq *http.Request) (*http.Response, error) { - var resp *http.Response - var err error - - maxRetries := 3 - baseWait := 2 * time.Second - - for i := 0; i <= maxRetries; i++ { - // Reset body for retry if needed - if i > 0 && httpReq.GetBody != nil { - body, err := httpReq.GetBody() - if err != nil { - log.Printf("[ERROR] Failed to get request body for retry: %s", err) - return nil, err - } - httpReq.Body = body - } - - resp, err = r.client.HttpClient.Do(httpReq) - - // Case 1: Client/Network error - if err != nil { - // Check context cancellation first - if ctx.Err() != nil { - return nil, ctx.Err() - } - - errMsg := err.Error() - if i < maxRetries && (strings.Contains(errMsg, "TLS handshake timeout") || - strings.Contains(errMsg, "connection refused") || - strings.Contains(errMsg, "connection reset") || - strings.Contains(errMsg, "timeout") || - strings.Contains(errMsg, "EOF")) { - - log.Printf("[WARN] Network error '%s' (attempt %d/%d). Retrying in %s...", errMsg, i+1, maxRetries+1, baseWait*time.Duration(i+1)) - time.Sleep(baseWait * time.Duration(i+1)) - continue - } - return nil, err - } - - // Case 2: Server error (5xx) - if i < maxRetries && resp.StatusCode >= 500 && resp.StatusCode < 600 { - log.Printf("[WARN] Server error %d (attempt %d/%d). Retrying in %s...", resp.StatusCode, i+1, maxRetries+1, baseWait*time.Duration(i+1)) - resp.Body.Close() - time.Sleep(baseWait * time.Duration(i+1)) - continue - } - - // If success or 4xx, return - return resp, nil - } - - return resp, err -} - -func (r *TubulusResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data TubulusResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // [NUBES SOFT DELETE LOGIC] - // Сначала проверяем, нет ли уже такого инстанса в статусе suspended. - // Если есть — выполняем resume вместо создания нового. - existingInstance, _ := r.findInstance(ctx, data.DisplayName.ValueString()) - if existingInstance != nil && existingInstance.Status == "suspended" { - log.Printf("[INFO] Found existing suspended instance %s. Triggering RESUME.", existingInstance.Uid) - instanceId := existingInstance.Uid - data.ID = types.StringValue(instanceId) - - operationReq := CreateOperationRequest{ - InstanceUid: instanceId, - Operation: "resume", - } - - jsonData, err := json.Marshal(operationReq) - if err == nil { - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err == nil { - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - httpResp, _ := r.doRequestWithRetry(ctx, httpReq) - if httpResp != nil && (httpResp.StatusCode == http.StatusCreated || httpResp.StatusCode == http.StatusOK) { - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - - // [FIX 2026-01-27]: Ensure params are submitted even for RESUME if needed. - // Use "data" model which contains current plan values. - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - log.Printf("[WARN] Failed to submit params for resume operation (might be non-fatal): %s", err) - } - - // [FIX] User requirement: Bolvanka resume must be fast (< 1 min). - // Increased to 3m as sometimes cloud is slow. - if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 3*time.Minute); err == nil { - data.Status = types.StringValue("running") - - // Ensure computed optional fields are checked for Unknown and set to Null if needed - if data.FailAtStart.IsUnknown() { data.FailAtStart = types.BoolNull() } - if data.FailInProgress.IsUnknown() { data.FailInProgress = types.BoolNull() } - if data.WhereFail.IsUnknown() { data.WhereFail = types.Int64Null() } - if data.BodyMessage.IsUnknown() { data.BodyMessage = types.StringNull() } - if data.ResourceRealm.IsUnknown() { data.ResourceRealm = types.StringNull() } - if data.MapExample.IsUnknown() { data.MapExample = types.StringNull() } - if data.JsonExample.IsUnknown() { data.JsonExample = types.StringNull() } - if data.YamlExample.IsUnknown() { data.YamlExample = types.StringNull() } - if data.DurationMs.IsUnknown() { data.DurationMs = types.Int64Null() } - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) - // CRITICAL FIX: Return immediately after successful resume! - // Do not fall through to Create logic. - return - } else { - // If wait failed (timeout or error), report it. - resp.Diagnostics.AddError("Resume Failed", fmt.Sprintf("Failed to resume existing instance: %s", err)) - return - } - } - httpResp.Body.Close() - } - } - } - // Если resume не удался, попробуем обычное создание (может имя освободилось) - } - - // Step 1: Create instance - createReq := CreateInstanceRequest{ - ServiceId: 1, // Болванка - DisplayName: data.DisplayName.ValueString(), - Descr: data.Description.ValueString(), - } - - jsonData, err := json.Marshal(createReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.doRequestWithRetry(ctx, httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - // Extract instance ID from Location header - location := httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in response") - return - } - // Location format: "./UUID" - instanceId := location[2:] // Remove "./" - - data.ID = types.StringValue(instanceId) - - // Step 2: Create operation - // Инициализируем операцию 'create'. Это создает "визард" в Deck API. - operationReq := CreateOperationRequest{ - InstanceUid: instanceId, - Operation: "create", - } - - jsonData, err = json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.doRequestWithRetry(ctx, httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - // Extract operation ID from Location header - location = httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in operation response") - return - } - operationId := location[2:] // Remove "./" - - // Step 3: Submit operation parameters to trigger execution - // Синхронизация параметров и запуск выполнения (Full Sync + Run). - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - - // Step 4: Wait for completion and read status - // [MODIFIED BY USER REQUEST]: Timeout reduced to 1 minute. - if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 1*time.Minute); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to reach running status: %s", err)) - return - } - - readData, err := r.readInstance(ctx, instanceId) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - // Ensure computed optional fields are checked for Unknown and set to Null if needed - if data.FailAtStart.IsUnknown() { data.FailAtStart = types.BoolNull() } - if data.FailInProgress.IsUnknown() { data.FailInProgress = types.BoolNull() } - if data.WhereFail.IsUnknown() { data.WhereFail = types.Int64Null() } - if data.BodyMessage.IsUnknown() { data.BodyMessage = types.StringNull() } - if data.ResourceRealm.IsUnknown() { data.ResourceRealm = types.StringNull() } - if data.MapExample.IsUnknown() { data.MapExample = types.StringNull() } - if data.JsonExample.IsUnknown() { data.JsonExample = types.StringNull() } - if data.YamlExample.IsUnknown() { data.YamlExample = types.StringNull() } - if data.DurationMs.IsUnknown() { data.DurationMs = types.Int64Null() } - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *TubulusResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data TubulusResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) - return - } - - data.Status = types.StringValue(instanceResp.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *TubulusResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - // [НЕ ИЗМЕНЯТЬ !!!!] - // Ресурс считается иммутабельным в рамках текущих тестов. - // Метод сохранен для совместимости, но использование 'modify' в Tubulus часто не дает результата на бэкенде. - - var data TubulusResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // Trigger modify operation - operationReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "modify", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.doRequestWithRetry(ctx, httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - // Extract operation ID and submit parameters - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - - // Wait for modify to complete using strict logic - if err := r.waitForOperationAndInstanceStatus(ctx, operationId, data.ID.ValueString(), 1*time.Minute); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to complete modify operation: %s", err)) - return - } - } - - // Read updated state (no sleep needed after strict wait) - readData, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - // Ensure computed optional fields are checked for Unknown and set to Null if needed - if data.FailAtStart.IsUnknown() { data.FailAtStart = types.BoolNull() } - if data.FailInProgress.IsUnknown() { data.FailInProgress = types.BoolNull() } - if data.WhereFail.IsUnknown() { data.WhereFail = types.Int64Null() } - if data.BodyMessage.IsUnknown() { data.BodyMessage = types.StringNull() } - if data.ResourceRealm.IsUnknown() { data.ResourceRealm = types.StringNull() } - if data.MapExample.IsUnknown() { data.MapExample = types.StringNull() } - if data.JsonExample.IsUnknown() { data.JsonExample = types.StringNull() } - if data.YamlExample.IsUnknown() { data.YamlExample = types.StringNull() } - if data.DurationMs.IsUnknown() { data.DurationMs = types.Int64Null() } - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *TubulusResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data TubulusResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // [NUBES SOFT DELETE] - // Вместо 'delete' отправляем 'suspend'. Ресурс перейдет в 14-дневный период удержания. - operationReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "suspend", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.doRequestWithRetry(ctx, httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusNotFound { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Suspend operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - // Extract operation ID and wait for completion (without parameters) - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - // Run suspend operation (no params needed usually) - runReq, _ := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations/"+operationId+"/run", bytes.NewBuffer([]byte("{}"))) - runReq.Header.Set("Content-Type", "application/json") - runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - r.doRequestWithRetry(ctx, runReq) - - // Wait for suspend to complete using strict logic (ignore status check) - r.waitForOperationAndInstanceStatus(ctx, operationId, data.ID.ValueString(), 1*time.Minute) - } -} - -func (r *TubulusResource) findInstance(ctx context.Context, name string) (*InstanceResponse, error) { - page := 1 - pageSize := 100 - - for { - url := fmt.Sprintf("%s/instances?page=%d&size=%d", r.client.ApiEndpoint, page, pageSize) - httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.doRequestWithRetry(ctx, httpReq) - if err != nil { - return nil, err - } - - var instancesResp struct { - Total int `json:"total"` - Results []struct { - ID string `json:"instanceUid"` - Name string `json:"displayName"` - Status string `json:"explainedStatus"` - Svc string `json:"svc"` - } `json:"results"` - } - - err = json.NewDecoder(httpResp.Body).Decode(&instancesResp) - httpResp.Body.Close() - - if err != nil { - return nil, err - } - - for _, instance := range instancesResp.Results { - // [FIX] Checking both "Bolvanka" and "Болванка" just in case, but usually it's "Болванка" - if instance.Name == name && (instance.Svc == "Болванка" || instance.Svc == "Bolvanka") { - return &InstanceResponse{ - Uid: instance.ID, - DisplayName: instance.Name, - Status: instance.Status, - }, nil - } - } - - if len(instancesResp.Results) == 0 || page*pageSize >= instancesResp.Total { - break - } - page++ - } - - return nil, fmt.Errorf("instance not found") -} - -func (r *TubulusResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) -} - -func (r *TubulusResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) { - // [FIX 2026-01-27]: Explicitly request explainedStatus to ensure we get the correct status - // Default response might not include computed/expensive fields. - httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id+"?fields=instanceUid,displayName,descr,explainedStatus", nil) - if err != nil { - return nil, err - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.doRequestWithRetry(ctx, httpReq) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - - if httpResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var instanceResp GetInstanceResponse - if err := json.Unmarshal(body, &instanceResp); err != nil { - return nil, err - } - - return &instanceResp.Instance, nil -} - -func (r *TubulusResource) submitOperationParams(ctx context.Context, operationUid string, data TubulusResourceModel) error { - // Step 1: Get operation details with parameters - httpReq, err := http.NewRequestWithContext(ctx, "GET", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil) - if err != nil { - return fmt.Errorf("unable to create request: %s", err) - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.doRequestWithRetry(ctx, httpReq) - if err != nil { - return fmt.Errorf("unable to get operation: %s", err) - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return fmt.Errorf("unable to read response: %s", err) - } - - if httpResp.StatusCode != http.StatusOK { - return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var getResp GetOperationResponse - if err := json.Unmarshal(body, &getResp); err != nil { - return fmt.Errorf("unable to unmarshal response: %s", err) - } - operationResp := getResp.InstanceOperation - - // Step 2: Submit each parameter to ensure they exist (populate UIDs) - // ВАЖНО: Мы ДОЛЖНЫ пройтись по всем параметрам, которые вернул API. - // Бэкенд Nubes инициализирует значения параметров только после их явной отправки (POST). - // Если пропустить параметр, Deck может не разрешить выполнение (Run). - for _, param := range operationResp.CfsParams { - // Determine value to send: existing value > default value > empty string - valToSend := "" - - // Try to match parameter by Code or Name with Terraform data - // Assuming param.Code or param.Name matches the keys we know - // [FIX 2026-01-27]: Added SvcOperationCfsParam as fallback key - key := param.Code - if key == "" { - key = param.Name - } - if key == "" { - key = param.SvcOperationCfsParam - } - - // Helper to override valToSend if TF attribute is set - if key == "durationMs" && !data.DurationMs.IsNull() && !data.DurationMs.IsUnknown() { - valToSend = fmt.Sprintf("%d", data.DurationMs.ValueInt64()) - } else if key == "failAtStart" && !data.FailAtStart.IsNull() && !data.FailAtStart.IsUnknown() { - valToSend = fmt.Sprintf("%t", data.FailAtStart.ValueBool()) - } else if key == "failInProgress" && !data.FailInProgress.IsNull() && !data.FailInProgress.IsUnknown() { - valToSend = fmt.Sprintf("%t", data.FailInProgress.ValueBool()) - } else if key == "whereFail" && !data.WhereFail.IsNull() && !data.WhereFail.IsUnknown() { - valToSend = fmt.Sprintf("%d", data.WhereFail.ValueInt64()) - } else if key == "bodymessage" && !data.BodyMessage.IsNull() && !data.BodyMessage.IsUnknown() { - valToSend = data.BodyMessage.ValueString() - } else if key == "resourceRealm" && !data.ResourceRealm.IsNull() && !data.ResourceRealm.IsUnknown() { - valToSend = data.ResourceRealm.ValueString() - } else if key == "mapExample" && !data.MapExample.IsNull() && !data.MapExample.IsUnknown() { - valToSend = data.MapExample.ValueString() - } else if key == "jsonExample" && !data.JsonExample.IsNull() && !data.JsonExample.IsUnknown() { - valToSend = data.JsonExample.ValueString() - } else if key == "yamlExample" && !data.YamlExample.IsNull() && !data.YamlExample.IsUnknown() { - valToSend = data.YamlExample.ValueString() - } else { - // Fallback to existing logic if not set in TF - if param.ParamValue != nil { - valToSend = *param.ParamValue - } else if param.DefaultValue != nil { - valToSend = *param.DefaultValue - } - } - - // Fix specific data type formatting - // [CRITICAL WARNING]: DO NOT CHANGE THIS LOGIC for MAP/JSON/ARRAY! - // Sending empty string "" for map/json/array triggers 400 Bad Request (invalid format). - // Always send "{}" for empty maps/jsons and "[]" for empty lists. - if valToSend == "" || valToSend == "\"\"" { - if param.DataType == "map" || param.DataType == "json" { - valToSend = "{}" - } else if param.DataType == "array" || param.DataType == "list" { - valToSend = "[]" - } - } - - - // [FIX 2026-01-27]: Если значение не менялось (пустое или дефолтное) и это не обязательный параметр, - // всё равно отправляем POST, чтобы инициализировать параметр на бэкенде. - // НО: для корректного формирования JSON строки, нужно убедиться, что кавычки экранированы, если это JSON внутри JSON. - // В простом случае мы шлем строку. - - paramReq := CreateCfsParamRequest{ - InstanceOperationUid: operationUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: valToSend, - } - - jsonData, err := json.Marshal(paramReq) - if err != nil { - return fmt.Errorf("unable to marshal param request: %s", err) - } - - // USE POST to create/set the parameter - httpReq, err = http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("unable to create param request: %s", err) - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.doRequestWithRetry(ctx, httpReq) - if err != nil { - return fmt.Errorf("unable to submit parameter: %s", err) - } - - respBody, _ := io.ReadAll(httpResp.Body) - httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated && - httpResp.StatusCode != http.StatusOK && - httpResp.StatusCode != http.StatusNoContent { - // Log but maybe continue? No, if we fail to set param, run will likely fail. - return fmt.Errorf("submit parameter id %d failed with status %d: %s", - param.SvcOperationCfsParamId, httpResp.StatusCode, string(respBody)) - } - } - - // Step 3: Run the operation - runReq, err := http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) - if err != nil { - return fmt.Errorf("unable to create run request: %s", err) - } - - runReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - runResp, err := r.doRequestWithRetry(ctx, runReq) - if err != nil { - return fmt.Errorf("unable to run operation: %s", err) - } - defer runResp.Body.Close() - - if runResp.StatusCode != http.StatusOK && - runResp.StatusCode != http.StatusNoContent && - runResp.StatusCode != http.StatusCreated { - runBody, _ := io.ReadAll(runResp.Body) - // debugJSON removed from scope, simplified error - return fmt.Errorf("run operation failed with status %d: %s", - runResp.StatusCode, string(runBody)) - } - - return nil -} - -func (r *TubulusResource) waitForOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - log.Printf("[DEBUG] Starting two-stage polling: operationId=%s, instanceId=%s, timeout=%v", operationId, instanceId, timeout) - - // Шаг 1: Ждём завершения операции -operationLoop: - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - case <-ticker.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for operation to complete") - } - - log.Printf("[DEBUG] Polling operation status: operationId=%s", operationId) - - // Проверяем статус операции через клиент - opResp, err := r.client.GetInstanceOperation(ctx, operationId) - if err != nil { - log.Printf("[WARN] Failed to get operation status: %s", err) - continue // Пробуем еще раз на следующем тике - } - - // Вывод для логов, чтобы видеть что реально приходит - if opResp.DtFinish != nil { - log.Printf("[DEBUG] dtFinish: %s", *opResp.DtFinish) - } - if opResp.IsSuccessful != nil { - log.Printf("[DEBUG] isSuccessful: %v", *opResp.IsSuccessful) - } - - // Извлекаем все доступные данные из ответа (максимальная диагностика) - isSucc := "N/A" - if opResp.IsSuccessful != nil { - isSucc = fmt.Sprintf("%v", *opResp.IsSuccessful) - } - dtFin := "not finished" - if opResp.DtFinish != nil { - dtFin = *opResp.DtFinish - } - submitCode := "N/A" - if opResp.SubmitResult != nil { - submitCode = *opResp.SubmitResult - } - dur := 0.0 - if opResp.Duration != nil { - dur = *opResp.Duration - } - who := "system" - if opResp.UpdaterShortname != nil { - who = *opResp.UpdaterShortname - } - - log.Printf("[DEBUG] Polling: OpID=%s, Status=%s/%s, Success=%s, Finish=%s, SubmitResult=%s, Dur=%.2fs, Initiator=%s", - operationId, - map[bool]string{true: "PENDING", false: "ACTIVE"}[opResp.IsPending], - map[bool]string{true: "WORKING", false: "IDLE"}[opResp.IsInProgress], - isSucc, dtFin, submitCode, dur, who) - - // [FIX 2026-01-27]: STRICT SUCCESS LOGIC - // [CRITICAL WARNING]: DO NOT CHANGE THIS LOGIC! - // This logic is battle-tested against 14+ HAR files. - // 1. dtFinish IS THE ONLY SOURCE OF TRUTH for completion. - // 2. Do NOT rely on status, isInProgress, or isPending for completion check. - // 3. If dtFinish is set -> The operation is DONE. We stop waiting IMMEDIATELY. - // 4. If it is done, we check Success: - // - isSuccessful == true => OK (break loop) - // - isSuccessful == false OR null => ERROR (return error) - // ANY DEVIATION FROM THIS WILL CAUSE TIMEOUTS OR FALSE POSITIVES. - if opResp.DtFinish != nil { - log.Printf("[DEBUG] dtFinish detected: %s. Operation is FINISHED.", *opResp.DtFinish) - - if opResp.IsSuccessful != nil && *opResp.IsSuccessful { - log.Printf("[DEBUG] Operation SUCCESS detected (isSuccessful=true).") - break operationLoop - } - - // If we are here, it means finished but NOT successful (false or null) - errNote := "Operation finished but marked as UNSUCCESSFUL" - if opResp.ErrorLog != nil { - errNote = fmt.Sprintf("%s. LOG: %s", errNote, *opResp.ErrorLog) - } else { - errNote = fmt.Sprintf("%s. (isSuccessful=%s, dtFinish=%s)", errNote, isSucc, *opResp.DtFinish) - } - return fmt.Errorf("%s (initiator: %s, duration: %.2fs)", errNote, who, dur) - } - - // If dtFinish is NOT set, we continue polling until timeout. - // Reordered check: We trust dtFinish as the primary signal of completion. - - // Legacy check for "Cold Start" or weird states where flags are false but nothing happened yet - if !opResp.IsInProgress && !opResp.IsPending && opResp.SubmitResult == nil { - log.Printf("[DEBUG] Operation uninitialized (cold start or slow pending). Waiting...") - continue - } - } - } - - // Шаг 2: Проверяем статус instance - ticker2 := time.NewTicker(5 * time.Second) - defer ticker2.Stop() - - log.Printf("[DEBUG] Starting instance status polling") - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - case <-ticker2.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for instance to become running") - } - - log.Printf("[DEBUG] Polling instance status: instanceId=%s", instanceId) - - instance, err := r.readInstance(ctx, instanceId) - if err != nil { - return fmt.Errorf("failed to check instance status: %s", err) - } - - if instance.Status == "running" { - return nil - } - - if instance.Status == "error" || instance.Status == "failed" { - return fmt.Errorf("instance entered error state: %s", instance.Status) - } - } - } -} diff --git a/internal/provider/validators.go b/internal/provider/validators.go deleted file mode 100644 index 5efa8fd..0000000 --- a/internal/provider/validators.go +++ /dev/null @@ -1,155 +0,0 @@ -package provider - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/schema/validator" -) - -// stringOneOfValidator validates that a string value is one of the allowed values. -type stringOneOfValidator struct { - values []string -} - -func (v stringOneOfValidator) Description(ctx context.Context) string { - return fmt.Sprintf("value must be one of: %v", v.values) -} - -func (v stringOneOfValidator) MarkdownDescription(ctx context.Context) string { - return fmt.Sprintf("value must be one of: %v", v.values) -} - -func (v stringOneOfValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { - if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { - return - } - - value := req.ConfigValue.ValueString() - for _, allowed := range v.values { - if value == allowed { - return - } - } - - resp.Diagnostics.AddAttributeError( - req.Path, - "Invalid value", - fmt.Sprintf("Value must be one of %v, got: %s", v.values, value), - ) -} - -func StringOneOf(values ...string) validator.String { - return stringOneOfValidator{ - values: values, - } -} - -// validJSONArrayValidator проверяет, что строка является валидным JSON массивом -type validJSONArrayValidator struct{} - -func (v validJSONArrayValidator) Description(ctx context.Context) string { - return "value must be a valid JSON array (e.g., [\"item1\", \"item2\"])" -} - -func (v validJSONArrayValidator) MarkdownDescription(ctx context.Context) string { - return "value must be a valid JSON array (e.g., `[\"item1\", \"item2\"]`)" -} - -func (v validJSONArrayValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { - if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { - return - } - - value := req.ConfigValue.ValueString() - if value == "" { - // Пустая строка не валидна - должен быть либо null, либо JSON массив - resp.Diagnostics.AddAttributeError( - req.Path, - "Invalid JSON Array", - "Empty string is not a valid JSON array. Use jsonencode([...]) or omit the attribute.", - ) - return - } - - var arr []interface{} - if err := json.Unmarshal([]byte(value), &arr); err != nil { - resp.Diagnostics.AddAttributeError( - req.Path, - "Invalid JSON Array", - fmt.Sprintf("Value must be a valid JSON array: %s", err), - ) - } -} - -func ValidJSONArray() validator.String { - return validJSONArrayValidator{} -} - -// validJSONArrayOrEmptyValidator проверяет JSON массив или разрешает пустую строку -// Используется для accessIpList где "" означает "доступ отовсюду" -type validJSONArrayOrEmptyValidator struct{} - -func (v validJSONArrayOrEmptyValidator) Description(ctx context.Context) string { - return "value must be a valid JSON array or empty string (empty = access from anywhere)" -} - -func (v validJSONArrayOrEmptyValidator) MarkdownDescription(ctx context.Context) string { - return "value must be a valid JSON array or empty string (empty = access from anywhere)" -} - -func (v validJSONArrayOrEmptyValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { - if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { - return - } - - value := req.ConfigValue.ValueString() - if value == "" { - // Пустая строка разрешена - означает доступ отовсюду - return - } - - var arr []interface{} - if err := json.Unmarshal([]byte(value), &arr); err != nil { - resp.Diagnostics.AddAttributeError( - req.Path, - "Invalid JSON Array", - fmt.Sprintf("Value must be a valid JSON array or empty string: %s", err), - ) - } -} - -func ValidJSONArrayOrEmpty() validator.String { - return validJSONArrayOrEmptyValidator{} -} - -type uuidLikeValidator struct{} - -func (v uuidLikeValidator) Description(ctx context.Context) string { - return "value must be a UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)" -} - -func (v uuidLikeValidator) MarkdownDescription(ctx context.Context) string { - return "value must be a UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)" -} - -func (v uuidLikeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { - if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { - return - } - - value := strings.TrimSpace(req.ConfigValue.ValueString()) - if value == "" || !uuidLikeRegex.MatchString(value) { - resp.Diagnostics.AddAttributeError( - req.Path, - "Invalid UUID", - fmt.Sprintf("Value must be a UUID, got: %q", req.ConfigValue.ValueString()), - ) - } -} - -func UUIDLike() validator.String { - return uuidLikeValidator{} -} diff --git a/internal/provider/vapp_data_source.go b/internal/provider/vapp_data_source.go deleted file mode 100644 index 236c09a..0000000 --- a/internal/provider/vapp_data_source.go +++ /dev/null @@ -1,144 +0,0 @@ -package provider - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/datasource" - "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var _ datasource.DataSource = &VAppDataSource{} - -func NewVAppDataSource() datasource.DataSource { - return &VAppDataSource{} -} - -type VAppDataSource struct { - client *NubesClient -} - -type VAppDataSourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - Status types.String `tfsdk:"status"` - Description types.String `tfsdk:"description"` -} - -func (d *VAppDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_vapp" -} - -func (d *VAppDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Data source для получения информации о существующем vApp по имени", - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - MarkdownDescription: "UUID vApp", - Computed: true, - }, - "display_name": schema.StringAttribute{ - MarkdownDescription: "Имя vApp для поиска", - Required: true, - }, - "status": schema.StringAttribute{ - MarkdownDescription: "Статус vApp", - Computed: true, - }, - "description": schema.StringAttribute{ - MarkdownDescription: "Описание vApp", - Computed: true, - }, - }, - } -} - -func (d *VAppDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Data Source Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - d.client = client -} - -func (d *VAppDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data VAppDataSourceModel - - resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "GET", d.client.ApiEndpoint+"/instances", nil) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) - return - } - - httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(d.client.ApiToken)) - httpReq.Header.Set("Content-Type", "application/json") - - httpResp, err := d.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instances: %s", err)) - return - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read response: %s", err)) - return - } - - if httpResp.StatusCode != http.StatusOK { - resp.Diagnostics.AddError("API Error", fmt.Sprintf("GET /instances returned %d: %s", httpResp.StatusCode, string(body))) - return - } - - var instancesResp struct { - Results []struct { - ID string `json:"id"` - DisplayName string `json:"name"` - Status string `json:"status"` - Description string `json:"desc"` - Service string `json:"svc"` - } `json:"results"` - } - - if err := json.Unmarshal(body, &instancesResp); err != nil { - resp.Diagnostics.AddError("Parse Error", fmt.Sprintf("Unable to parse response: %s", err)) - return - } - - searchName := data.DisplayName.ValueString() - for _, instance := range instancesResp.Results { - if instance.Service == "Виртуальное приложение (vApp)" && instance.DisplayName == searchName { - data.ID = types.StringValue(instance.ID) - data.Status = types.StringValue(instance.Status) - data.Description = types.StringValue(instance.Description) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) - return - } - } - - resp.Diagnostics.AddError( - "vApp Not Found", - fmt.Sprintf("vApp с именем '%s' не найден. Проверьте имя или создайте новый vApp.", searchName), - ) -} diff --git a/internal/provider/vapp_resource.go b/internal/provider/vapp_resource.go deleted file mode 100644 index 54bf112..0000000 --- a/internal/provider/vapp_resource.go +++ /dev/null @@ -1,639 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" - - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var _ resource.Resource = &VAppResource{} -var _ resource.ResourceWithImportState = &VAppResource{} - -func NewVAppResource() resource.Resource { - return &VAppResource{} -} - -type VAppResource struct { - client *NubesClient -} - -type VAppResourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - Description types.String `tfsdk:"description"` - EdgeUID types.String `tfsdk:"edge_uid"` - VAppName types.String `tfsdk:"vapp_name"` - VdcUID types.String `tfsdk:"vdc_uid"` - Status types.String `tfsdk:"status"` -} - -func (r *VAppResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_vapp" -} - -func (r *VAppResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Nubes vApp (Virtual Application Catalog) resource", - - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "vApp identifier (UUID)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "display_name": schema.StringAttribute{ - MarkdownDescription: "vApp display name", - Required: true, - }, - "description": schema.StringAttribute{ - MarkdownDescription: "vApp description", - Optional: true, - }, - "edge_uid": schema.StringAttribute{ - MarkdownDescription: "Edge Gateway UUID (parameter ID 190)", - Required: true, - }, - "vapp_name": schema.StringAttribute{ - MarkdownDescription: "vApp name in Cloud Director (parameter ID 191)", - Required: true, - }, - "vdc_uid": schema.StringAttribute{ - MarkdownDescription: "VDC UUID (parameter ID 623)", - Required: true, - }, - "status": schema.StringAttribute{ - MarkdownDescription: "Current status of the vApp", - Computed: true, - }, - }, - } -} - -func (r *VAppResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - r.client = client -} - -func (r *VAppResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data VAppResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // Step 1: Create instance - createReq := CreateInstanceRequest{ - ServiceId: 26, // VApp service ID - DisplayName: data.DisplayName.ValueString(), - Descr: data.Description.ValueString(), - } - - jsonData, err := json.Marshal(createReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location := httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in response") - return - } - instanceId := location[2:] - - data.ID = types.StringValue(instanceId) - - // Step 2: Create operation - operationReq := CreateOperationRequest{ - InstanceUid: instanceId, - Operation: "create", - } - - jsonData, err = json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location = httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in operation response") - return - } - operationId := location[2:] - - // Step 3: Submit operation parameters and run - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - - // Step 4: Wait for operation completion and instance running status - if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 10*time.Minute); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to reach running status: %s", err)) - return - } - - readData, err := r.readInstance(ctx, instanceId) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *VAppResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data VAppResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) - return - } - - data.Status = types.StringValue(instanceResp.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *VAppResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var data VAppResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - operationReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "modify", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - } - - time.Sleep(5 * time.Second) - readData, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *VAppResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data VAppResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - operationReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "delete", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusNotFound { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Delete operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - } - } -} - -func (r *VAppResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) -} - -func (r *VAppResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) { - httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id, nil) - if err != nil { - return nil, err - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - - if httpResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var instanceResp InstanceResponse - if err := json.Unmarshal(body, &instanceResp); err != nil { - return nil, err - } - - return &instanceResp, nil -} - -func (r *VAppResource) submitOperationParams(ctx context.Context, operationUid string, data VAppResourceModel) error { - // Get operation details - httpReq, err := http.NewRequestWithContext(ctx, "GET", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil) - if err != nil { - return fmt.Errorf("unable to create request: %s", err) - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to get operation: %s", err) - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return fmt.Errorf("unable to read response: %s", err) - } - - if httpResp.StatusCode != http.StatusOK { - return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var getResp GetOperationResponse - if err := json.Unmarshal(body, &getResp); err != nil { - return fmt.Errorf("unable to unmarshal response: %s", err) - } - operationResp := getResp.InstanceOperation - - // Submit each parameter - for _, param := range operationResp.CfsParams { - valToSend := "" - - // Map vApp parameters by ID - switch param.SvcOperationCfsParamId { - case 190: // edgeUid - if !data.EdgeUID.IsNull() && !data.EdgeUID.IsUnknown() { - valToSend = data.EdgeUID.ValueString() - } - case 191: // vappName - if !data.VAppName.IsNull() && !data.VAppName.IsUnknown() { - valToSend = data.VAppName.ValueString() - } - case 623: // vdcUid - if !data.VdcUID.IsNull() && !data.VdcUID.IsUnknown() { - valToSend = data.VdcUID.ValueString() - } - default: - // Use existing or default value for unknown parameters - if param.ParamValue != nil { - valToSend = *param.ParamValue - } else if param.DefaultValue != nil { - valToSend = *param.DefaultValue - } - } - - // Fix specific data type formatting - if valToSend == "" || valToSend == "\"\"" { - if param.DataType == "map" || param.DataType == "json" { - valToSend = "{}" - } else if param.DataType == "array" || param.DataType == "list" { - valToSend = "[]" - } - } - - paramReq := CreateCfsParamRequest{ - InstanceOperationUid: operationUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: valToSend, - } - - - jsonData, err := json.Marshal(paramReq) - if err != nil { - return fmt.Errorf("unable to marshal param request: %s", err) - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("unable to create param request: %s", err) - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to submit parameter: %s", err) - } - - respBody, _ := io.ReadAll(httpResp.Body) - httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated && - httpResp.StatusCode != http.StatusOK && - httpResp.StatusCode != http.StatusNoContent { - return fmt.Errorf("submit parameter id %d failed with status %d: %s", - param.SvcOperationCfsParamId, httpResp.StatusCode, string(respBody)) - } - } - - // Run the operation - runReq, err := http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) - if err != nil { - return fmt.Errorf("unable to create run request: %s", err) - } - - runReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - runResp, err := r.client.HttpClient.Do(runReq) - if err != nil { - return fmt.Errorf("unable to run operation: %s", err) - } - defer runResp.Body.Close() - - if runResp.StatusCode != http.StatusOK && - runResp.StatusCode != http.StatusNoContent && - runResp.StatusCode != http.StatusCreated { - runBody, _ := io.ReadAll(runResp.Body) - return fmt.Errorf("run operation failed with status %d: %s", - runResp.StatusCode, string(runBody)) - } - - return nil -} - -func (r *VAppResource) waitForOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - log.Printf("[DEBUG] Starting two-stage polling: operationId=%s, instanceId=%s, timeout=%v", operationId, instanceId, timeout) - - // Шаг 1: Ждём завершения операции -operationLoop: - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - case <-ticker.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for operation to complete") - } - - log.Printf("[DEBUG] Polling operation status: operationId=%s", operationId) - - // Проверяем статус операции - httpReq, err := http.NewRequestWithContext(ctx, "GET", - r.client.ApiEndpoint+"/instanceOperations/"+operationId, nil) - if err != nil { - return fmt.Errorf("failed to create request: %s", err) - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("failed to check operation status: %s", err) - } - - body, _ := io.ReadAll(httpResp.Body) - httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK { - return fmt.Errorf("get operation failed with status %d", httpResp.StatusCode) - } - - var opResp struct { - InstanceOperation struct { - IsInProgress bool `json:"isInProgress"` - IsPending bool `json:"isPending"` - } `json:"instanceOperation"` - } - - if err := json.Unmarshal(body, &opResp); err != nil { - return fmt.Errorf("failed to parse operation response: %s", err) - } - - log.Printf("[DEBUG] Operation status: isInProgress=%v, isPending=%v", opResp.InstanceOperation.IsInProgress, opResp.InstanceOperation.IsPending) - - // Операция завершена когда isInProgress=false И isPending=false - if !opResp.InstanceOperation.IsInProgress && !opResp.InstanceOperation.IsPending { - log.Printf("[DEBUG] Operation completed, moving to instance status check") - break operationLoop - } - } - } - - // Шаг 2: Проверяем статус instance - ticker2 := time.NewTicker(5 * time.Second) - defer ticker2.Stop() - - log.Printf("[DEBUG] Starting instance status polling") - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - case <-ticker2.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for instance to become running") - } - - log.Printf("[DEBUG] Polling instance status: instanceId=%s", instanceId) - -instance, err := r.readInstance(ctx, instanceId) -if err != nil { -return fmt.Errorf("failed to check instance status: %s", err) -} - -if instance.Status == "running" { -return nil -} - -if instance.Status == "error" || instance.Status == "failed" { -return fmt.Errorf("instance entered error state: %s", instance.Status) -} -} -} -} diff --git a/internal/provider/vdc_data_source.go b/internal/provider/vdc_data_source.go deleted file mode 100644 index de12322..0000000 --- a/internal/provider/vdc_data_source.go +++ /dev/null @@ -1,146 +0,0 @@ -package provider - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/hashicorp/terraform-plugin-framework/datasource" - "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var _ datasource.DataSource = &VDCDataSource{} - -func NewVDCDataSource() datasource.DataSource { - return &VDCDataSource{} -} - -type VDCDataSource struct { - client *NubesClient -} - -type VDCDataSourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - Status types.String `tfsdk:"status"` - Description types.String `tfsdk:"description"` -} - -func (d *VDCDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_vdc" -} - -func (d *VDCDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Data source для получения информации о существующем VDC по имени", - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - MarkdownDescription: "UUID VDC", - Computed: true, - }, - "display_name": schema.StringAttribute{ - MarkdownDescription: "Имя VDC для поиска", - Required: true, - }, - "status": schema.StringAttribute{ - MarkdownDescription: "Статус VDC (running, suspended, etc.)", - Computed: true, - }, - "description": schema.StringAttribute{ - MarkdownDescription: "Описание VDC", - Computed: true, - }, - }, - } -} - -func (d *VDCDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Data Source Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - d.client = client -} - -func (d *VDCDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { - var data VDCDataSourceModel - - resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // Поиск VDC по имени - httpReq, err := http.NewRequestWithContext(ctx, "GET", d.client.ApiEndpoint+"/instances", nil) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) - return - } - - httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(d.client.ApiToken)) - httpReq.Header.Set("Content-Type", "application/json") - - httpResp, err := d.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instances: %s", err)) - return - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read response: %s", err)) - return - } - - if httpResp.StatusCode != http.StatusOK { - resp.Diagnostics.AddError("API Error", fmt.Sprintf("GET /instances returned %d: %s", httpResp.StatusCode, string(body))) - return - } - - var instancesResp struct { - Results []struct { - ID string `json:"id"` - DisplayName string `json:"name"` - Status string `json:"status"` - Description string `json:"desc"` - Service string `json:"svc"` - } `json:"results"` - } - - if err := json.Unmarshal(body, &instancesResp); err != nil { - resp.Diagnostics.AddError("Parse Error", fmt.Sprintf("Unable to parse response: %s", err)) - return - } - - // Поиск VDC с указанным именем - searchName := data.DisplayName.ValueString() - for _, instance := range instancesResp.Results { - if instance.Service == "Виртуальный датацентр (vDC)" && instance.DisplayName == searchName { - data.ID = types.StringValue(instance.ID) - data.Status = types.StringValue(instance.Status) - data.Description = types.StringValue(instance.Description) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) - return - } - } - - resp.Diagnostics.AddError( - "VDC Not Found", - fmt.Sprintf("VDC с именем '%s' не найден. Проверьте имя или создайте новый VDC.", searchName), - ) -} diff --git a/internal/provider/vdc_resource.go b/internal/provider/vdc_resource.go deleted file mode 100644 index 3711f87..0000000 --- a/internal/provider/vdc_resource.go +++ /dev/null @@ -1,766 +0,0 @@ -package provider - -// [НЕ ИЗМЕНЯТЬ !!!!] -// Данный ресурс реализует паттерн "Nubes Flow" для Virtual Data Center (VDC). -// ВАЖНО: VDC является фундаментальным ресурсом инфраструктуры. -// -// ЛОГИКА УДАЛЕНИЯ (Delete): -// Автоматическое удаление через API ОТКЛЮЧЕНО для защиты от случайной потери данных. -// При вызове 'terraform destroy' ресурс просто УДАЛЯЕТСЯ ИЗ СТЕЙТА Terraform, но остается в облаке. -// Для реального удаления юзер должен вручную перевести инстанс в 'suspend' через UI и дождаться удаления (14 дней). -// -// ЛОГИКА ИЗМЕНЕНИЯ (Modify): -// Если ресурс находится в стейте, выполнение 'terraform apply' вызовет метод Update, -// который запустит операцию 'modify' для обновления параметров (квот и т.д.). -// ВНИМАНИЕ: Если вы уже выполнили 'destroy' (удалили из стейта), но ресурс остался в облаке, -// то для его изменения через Terraform вам придется сначала выполнить 'terraform import'. -// -// TODO: Протестировать VDC 'modify' позже. - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" - - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-log/tflog" - - "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" -) - -var _ resource.Resource = &VDCResource{} -var _ resource.ResourceWithImportState = &VDCResource{} - -func NewVDCResource() resource.Resource { - return &VDCResource{} -} - -type VDCResource struct { - client *NubesClient -} - -type VDCResourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - Description types.String `tfsdk:"description"` - OrganizationUID types.String `tfsdk:"organization_uid"` - ProviderVDC types.String `tfsdk:"provider_vdc"` - StorageProfiles types.String `tfsdk:"storage_profiles"` - NetworkPool types.String `tfsdk:"network_pool"` - CpuAllocationPct types.Int64 `tfsdk:"cpu_allocation_pct"` - RamAllocationPct types.Int64 `tfsdk:"ram_allocation_pct"` - CpuQuota types.Int64 `tfsdk:"cpu_quota"` - RamQuota types.Int64 `tfsdk:"ram_quota"` - DeletionProtection types.Bool `tfsdk:"deletion_protection"` - Status types.String `tfsdk:"status"` - Timeouts timeouts.Value `tfsdk:"timeouts"` -} - -func (r *VDCResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_vdc" -} - -func (r *VDCResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Nubes VDC (Virtual Data Center) resource", - Blocks: map[string]schema.Block{ - "timeouts": timeouts.Block(ctx, timeouts.Opts{ - Create: true, - Update: true, - Delete: true, - }), - }, - - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "VDC identifier (UUID)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "display_name": schema.StringAttribute{ - MarkdownDescription: "VDC display name", - Required: true, - }, - "description": schema.StringAttribute{ - MarkdownDescription: "VDC description", - Optional: true, - }, - "organization_uid": schema.StringAttribute{ - MarkdownDescription: "Organization UUID (parameter ID 30)", - Required: true, - }, - "provider_vdc": schema.StringAttribute{ - MarkdownDescription: "Provider VDC name in Cloud Director (parameter ID 335)", - Required: true, - }, - "storage_profiles": schema.StringAttribute{ - MarkdownDescription: "Storage profiles JSON array, e.g. [{\"name\":\"vsan\",\"size\":\"100\"}] (parameter ID 361)", - Required: true, - }, - "network_pool": schema.StringAttribute{ - MarkdownDescription: "Network Pool name in Cloud Director (parameter ID 366)", - Required: true, - }, - "cpu_allocation_pct": schema.Int64Attribute{ - MarkdownDescription: "CPU allocation percentage (parameter ID 397)", - Required: true, - }, - "ram_allocation_pct": schema.Int64Attribute{ - MarkdownDescription: "RAM allocation percentage (parameter ID 398)", - Required: true, - }, - "cpu_quota": schema.Int64Attribute{ - MarkdownDescription: "CPU quota (parameter ID 557)", - Optional: true, - }, - "ram_quota": schema.Int64Attribute{ - MarkdownDescription: "RAM quota (parameter ID 558)", - Optional: true, - }, - "deletion_protection": schema.BoolAttribute{ - MarkdownDescription: "If true, the resource will only be removed from Terraform state upon destroy, but will remain in the cloud. If false, destroy will trigger 'suspend' in Nubes.", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(true), - }, - "status": schema.StringAttribute{ - MarkdownDescription: "Current status of the VDC", - Computed: true, - }, - }, - } -} - -func (r *VDCResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - r.client = client -} - -func (r *VDCResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - // [ЛОГИКА VDC FLOW] - // Ресурс VDC создается по стандартному 7-шаговому алгоритму Nubes. - // Особое внимание уделяется параметрам квот (CPU/RAM) и сетевым пулам. - - var data VDCResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // Apply timeout - createTimeout, diags := data.Timeouts.Create(ctx, 3*time.Minute) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - ctx, cancel := context.WithTimeout(ctx, createTimeout) - defer cancel() - - // Step 1: Create instance - createReq := CreateInstanceRequest{ - ServiceId: 21, // VDC service ID - DisplayName: data.DisplayName.ValueString(), - Descr: data.Description.ValueString(), - } - - jsonData, err := json.Marshal(createReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location := httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in response") - return - } - instanceId := location[2:] - - data.ID = types.StringValue(instanceId) - - // Step 2: Create operation - // Deck API требует явного создания операции 'create' для инстанса. - // Это переводит инстанс в состояние "wizard", где можно настраивать параметры. - operationReq := CreateOperationRequest{ - InstanceUid: instanceId, - Operation: "create", - } - - jsonData, err = json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location = httpResp.Header.Get("Location") - if location == "" { - resp.Diagnostics.AddError("API Error", "No Location header in operation response") - return - } - operationId := location[2:] - - // Step 3: Submit operation parameters and run - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - - // Step 4: Wait for operation to complete, then check instance status - if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 10*time.Minute); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to reach running status: %s", err)) - return - } - - readData, err := r.readInstance(ctx, instanceId) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *VDCResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data VDCResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) - return - } - - data.Status = types.StringValue(instanceResp.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *VDCResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - // [НЕ ИЗМЕНЯТЬ !!!!] - // VDC поддерживает изменение параметров через операцию 'modify'. - // Это позволяет обновлять квоты CPU, RAM и другие параметры без пересоздания ресурса. - - var data VDCResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - operationReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "modify", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - if err := r.submitOperationParams(ctx, operationId, data); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - } - - time.Sleep(5 * time.Second) - readData, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) - return - } - - data.Status = types.StringValue(readData.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *VDCResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - // ЛОГИКА УДАЛЕНИЯ VDC: - // В зависимости от флага deletion_protection: - // 1. Если true (по умолчанию): Только удаляем из стейта. VDC остается работать. - // 2. Если false: Вызываем операцию 'suspend' через API. - // Доступа к VDC больше не будет, данные сохраняются 14 дней, затем авто-удаление. - - var data VDCResourceModel - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - if data.DeletionProtection.ValueBool() { - tflog.Warn(ctx, "Deletion Protection is ENABLED. VDC will remain active in Nubes Cloud. Manual cleanup required.") - return - } - - tflog.Info(ctx, "Deletion Protection is DISABLED. Triggering 'suspend' for VDC...") - - instanceId := data.ID.ValueString() - - // Выполняем операцию suspend - err := r.triggerOperation(ctx, instanceId, "suspend") - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend VDC: %s", err)) - return - } - - tflog.Info(ctx, "VDC suspended successfully. It will be permanently deleted from the cloud in 14 days.") -} - -func (r *VDCResource) triggerOperation(ctx context.Context, instanceId string, operationName string) error { - opReq := InstanceOperationRequest{ - Action: operationName, - Params: struct{}{}, - } - - jsonData, err := json.Marshal(opReq) - if err != nil { - return fmt.Errorf("unable to marshal request: %s", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances/"+instanceId+"/run", bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("unable to create request: %s", err) - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to trigger operation: %s", err) - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK && httpResp.StatusCode != http.StatusAccepted { - body, _ := io.ReadAll(httpResp.Body) - return fmt.Errorf("operation failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - // Ждем пока статус изменится на целевой (например, suspended) - targetStatus := "running" - if operationName == "suspend" { - targetStatus = "suspended" - } - - return r.client.WaitForInstanceStatus(ctx, instanceId, targetStatus) -} - -func (r *VDCResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) -} - -func (r *VDCResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) { - httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id, nil) - if err != nil { - return nil, err - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - - if httpResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var instanceResp InstanceResponse - if err := json.Unmarshal(body, &instanceResp); err != nil { - return nil, err - } - - return &instanceResp, nil -} - -func (r *VDCResource) submitOperationParams(ctx context.Context, operationUid string, data VDCResourceModel) error { - // [ЛОГИКА СИНХРОНИЗАЦИИ ПАРАМЕТРОВ] - // 1. Получаем список параметров операции (GET /instanceOperations/{uid}?fields=cfsParams). - // 2. Для каждого параметра из списка находим значение в Terraform или используем дефолт. - // 3. Отправляем значение обратно в API (POST /instanceOperationCfsParams). - // 4. После синхронизации всех параметров вызываем RUN. - - // Get operation details - httpReq, err := http.NewRequestWithContext(ctx, "GET", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil) - if err != nil { - return fmt.Errorf("unable to create request: %s", err) - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to get operation: %s", err) - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return fmt.Errorf("unable to read response: %s", err) - } - - if httpResp.StatusCode != http.StatusOK { - return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var getResp GetOperationResponse - if err := json.Unmarshal(body, &getResp); err != nil { - return fmt.Errorf("unable to unmarshal response: %s", err) - } - operationResp := getResp.InstanceOperation - - // Submit each parameter - for _, param := range operationResp.CfsParams { - valToSend := "" - - // Map VDC parameters by ID - switch param.SvcOperationCfsParamId { - case 30: // organizationUid - if !data.OrganizationUID.IsNull() && !data.OrganizationUID.IsUnknown() { - valToSend = data.OrganizationUID.ValueString() - } - case 335: // providerVdc - if !data.ProviderVDC.IsNull() && !data.ProviderVDC.IsUnknown() { - valToSend = data.ProviderVDC.ValueString() - } - case 361: // storageProfiles (JSON) - if !data.StorageProfiles.IsNull() && !data.StorageProfiles.IsUnknown() { - valToSend = data.StorageProfiles.ValueString() - } - case 366: // networkPool - if !data.NetworkPool.IsNull() && !data.NetworkPool.IsUnknown() { - valToSend = data.NetworkPool.ValueString() - } - case 397: // cpuAllocationPct - if !data.CpuAllocationPct.IsNull() && !data.CpuAllocationPct.IsUnknown() { - valToSend = fmt.Sprintf("%d", data.CpuAllocationPct.ValueInt64()) - } - case 398: // ramAllocationPct - if !data.RamAllocationPct.IsNull() && !data.RamAllocationPct.IsUnknown() { - valToSend = fmt.Sprintf("%d", data.RamAllocationPct.ValueInt64()) - } - case 557: // cpuQuota - if !data.CpuQuota.IsNull() && !data.CpuQuota.IsUnknown() { - valToSend = fmt.Sprintf("%d", data.CpuQuota.ValueInt64()) - } - case 558: // ramQuota - if !data.RamQuota.IsNull() && !data.RamQuota.IsUnknown() { - valToSend = fmt.Sprintf("%d", data.RamQuota.ValueInt64()) - } - default: - // Use existing or default value for unknown parameters - if param.ParamValue != nil { - valToSend = *param.ParamValue - } else if param.DefaultValue != nil { - valToSend = *param.DefaultValue - } - } - - // Fix specific data type formatting - if valToSend == "" || valToSend == "\"\"" { - if param.DataType == "map" || param.DataType == "json" { - valToSend = "{}" - } else if param.DataType == "array" || param.DataType == "list" { - valToSend = "[]" - } - } - - paramReq := CreateCfsParamRequest{ - InstanceOperationUid: operationUid, - SvcOperationCfsParamId: param.SvcOperationCfsParamId, - ParamValue: valToSend, - } - - jsonData, err := json.Marshal(paramReq) - if err != nil { - return fmt.Errorf("unable to marshal param request: %s", err) - } - - httpReq, err = http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("unable to create param request: %s", err) - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err = r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to submit parameter: %s", err) - } - - respBody, _ := io.ReadAll(httpResp.Body) - httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated && - httpResp.StatusCode != http.StatusOK && - httpResp.StatusCode != http.StatusNoContent { - return fmt.Errorf("submit parameter id %d failed with status %d: %s", - param.SvcOperationCfsParamId, httpResp.StatusCode, string(respBody)) - } - } - - // Run the operation - runReq, err := http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) - if err != nil { - return fmt.Errorf("unable to create run request: %s", err) - } - - runReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - runResp, err := r.client.HttpClient.Do(runReq) - if err != nil { - return fmt.Errorf("unable to run operation: %s", err) - } - defer runResp.Body.Close() - - if runResp.StatusCode != http.StatusOK && - runResp.StatusCode != http.StatusNoContent && - runResp.StatusCode != http.StatusCreated { - runBody, _ := io.ReadAll(runResp.Body) - return fmt.Errorf("run operation failed with status %d: %s", - runResp.StatusCode, string(runBody)) - } - - return nil -} - -func (r *VDCResource) waitForOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - log.Printf("[DEBUG] Starting two-stage polling: operationId=%s, instanceId=%s, timeout=%v", operationId, instanceId, timeout) - - // Шаг 1: Ждём завершения операции -operationLoop: - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - case <-ticker.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for operation to complete") - } - - log.Printf("[DEBUG] Polling operation status: operationId=%s", operationId) - - // Проверяем статус операции - httpReq, err := http.NewRequestWithContext(ctx, "GET", - r.client.ApiEndpoint+"/instanceOperations/"+operationId, nil) - if err != nil { - return fmt.Errorf("failed to create request: %s", err) - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("failed to check operation status: %s", err) - } - - body, _ := io.ReadAll(httpResp.Body) - httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK { - return fmt.Errorf("get operation failed with status %d", httpResp.StatusCode) - } - - var opResp struct { - InstanceOperation struct { - IsInProgress bool `json:"isInProgress"` - IsPending bool `json:"isPending"` - } `json:"instanceOperation"` - } - - if err := json.Unmarshal(body, &opResp); err != nil { - return fmt.Errorf("failed to parse operation response: %s", err) - } - - log.Printf("[DEBUG] Operation status: isInProgress=%v, isPending=%v", opResp.InstanceOperation.IsInProgress, opResp.InstanceOperation.IsPending) - - // Операция завершена когда isInProgress=false И isPending=false - if !opResp.InstanceOperation.IsInProgress && !opResp.InstanceOperation.IsPending { - log.Printf("[DEBUG] Operation completed, moving to instance status check") - break operationLoop - } - } - } - - // Шаг 2: Проверяем статус instance - ticker2 := time.NewTicker(5 * time.Second) - defer ticker2.Stop() - - log.Printf("[DEBUG] Starting instance status polling") - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - case <-ticker2.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for instance to become running") - } - - log.Printf("[DEBUG] Polling instance status: instanceId=%s", instanceId) - - instance, err := r.readInstance(ctx, instanceId) - if err != nil { - return fmt.Errorf("failed to check instance status: %s", err) - } - - log.Printf("[DEBUG] Instance status: %s", instance.Status) - - if instance.Status == "running" { - log.Printf("[DEBUG] Instance is running, success!") - return nil - } - - if instance.Status == "error" || instance.Status == "failed" { - return fmt.Errorf("instance entered error state: %s", instance.Status) - } - } - } -} - diff --git a/internal/provider/vm_resource.go b/internal/provider/vm_resource.go deleted file mode 100644 index ec6ad4e..0000000 --- a/internal/provider/vm_resource.go +++ /dev/null @@ -1,939 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -var _ resource.Resource = &VMResource{} -var _ resource.ResourceWithImportState = &VMResource{} - -func NewVMResource() resource.Resource { - return &VMResource{} -} - -type VMResource struct { - client *NubesClient -} - -type VMResourceModel struct { - ID types.String `tfsdk:"id"` - DisplayName types.String `tfsdk:"display_name"` - Description types.String `tfsdk:"description"` - Status types.String `tfsdk:"status"` - VappUid types.String `tfsdk:"vapp_uid"` - VmName types.String `tfsdk:"vm_name"` - VmCpu types.Int64 `tfsdk:"vm_cpu"` - VmRam types.Int64 `tfsdk:"vm_ram"` - AccessPortList types.String `tfsdk:"access_port_list"` - ImageVm types.String `tfsdk:"image_vm"` - UserLogin types.String `tfsdk:"user_login"` - UserPublicKey types.String `tfsdk:"user_public_key"` - NeedAddZabbixTemplate types.Bool `tfsdk:"need_add_zabbix_template"` - AccessIpList types.String `tfsdk:"access_ip_list"` - VmDisk types.Int64 `tfsdk:"vm_disk"` - IpSpaceName types.String `tfsdk:"ip_space_name"` - CloudInit types.String `tfsdk:"cloud_init"` - ResourceRealm types.String `tfsdk:"resource_realm"` - DeletionProtection types.Bool `tfsdk:"deletion_protection"` -} - -func (r *VMResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = req.ProviderTypeName + "_vm_instance" -} - -func (r *VMResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "Nubes VM Instance resource", - - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "Instance identifier (UUID)", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "display_name": schema.StringAttribute{ - MarkdownDescription: "Instance display name", - Required: true, - }, - "description": schema.StringAttribute{ - MarkdownDescription: "Instance description", - Optional: true, - }, - "status": schema.StringAttribute{ - MarkdownDescription: "Current status of the instance", - Computed: true, - }, - "vapp_uid": schema.StringAttribute{ - MarkdownDescription: "UUID vApp, в которой будет создаваться виртуальная машина", - Required: true, - }, - "vm_name": schema.StringAttribute{ - MarkdownDescription: "Уникальное имя виртуальной машины", - Required: true, - }, - "vm_cpu": schema.Int64Attribute{ - MarkdownDescription: "Количество ядер процессора. Должно быть больше 0", - Required: true, - Validators: []validator.Int64{ - int64validator.AtLeast(1), - }, - }, - "vm_ram": schema.Int64Attribute{ - MarkdownDescription: "Объем оперативной памяти в гигабайтах. Должно быть больше 0", - Required: true, - Validators: []validator.Int64{ - int64validator.AtLeast(1), - }, - }, - "access_port_list": schema.StringAttribute{ - MarkdownDescription: "Белый список портов для доступа к виртуальной машине извне в формате JSON. Пример: jsonencode([\"22\", \"80\"])", - Required: true, - Validators: []validator.String{ - ValidJSONArray(), - }, - }, - "image_vm": schema.StringAttribute{ - MarkdownDescription: "Образ операционной системы для развёртывания (RockyLinux_9-16G-cloudinit, Ubuntu_22-20G, Debian_13-20G)", - Required: true, - }, - "user_login": schema.StringAttribute{ - MarkdownDescription: "Логин пользователя для SSH-доступа", - Required: true, - }, - "user_public_key": schema.StringAttribute{ - MarkdownDescription: "Публичный SSH-ключ пользователя в формате OpenSSH", - Required: true, - }, - "need_add_zabbix_template": schema.BoolAttribute{ - MarkdownDescription: "Значение истина\\ложь, определяющее будет ли добавлен хост в zabbix", - Required: true, - }, - "access_ip_list": schema.StringAttribute{ - MarkdownDescription: "Белый список IP-адресов для доступа к виртуальной машине в формате JSON. Пример: jsonencode([\"1.2.3.4\", \"10.0.0.0/8\"]). Пустая строка или не указан = доступ отовсюду", - Optional: true, - Computed: true, - Validators: []validator.String{ - ValidJSONArrayOrEmpty(), - }, - }, - "vm_disk": schema.Int64Attribute{ - MarkdownDescription: "Размер дополнительного диска в гигабайтах. Должно быть больше 0", - Optional: true, - Validators: []validator.Int64{ - int64validator.AtLeast(1), - }, - }, - "ip_space_name": schema.StringAttribute{ - MarkdownDescription: "Внешний IP-адрес. Если не требуется — укажите значение 'no-needed'", - Optional: true, - }, - "cloud_init": schema.StringAttribute{ - MarkdownDescription: "YAML-скрипт для кастомизации системы через cloud-init", - Optional: true, - }, - "resource_realm": schema.StringAttribute{ - MarkdownDescription: "Платформа ресурса (например, vcd, openstack)", - Optional: true, - }, - "deletion_protection": schema.BoolAttribute{ - MarkdownDescription: "Если true, при удалении ресурса из Terraform он просто удаляется из стейта. Если false, отправляется команда suspend (карантин 14 дней).", - Optional: true, - Computed: true, - Default: booldefault.StaticBool(true), - }, - }, - } -} - -func (r *VMResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - if req.ProviderData == nil { - return - } - - client, ok := req.ProviderData.(*NubesClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData), - ) - return - } - - r.client = client -} - -func (r *VMResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data VMResourceModel - - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // Get service operation ID for 'create' - serviceId := 28 // VM service - - var instanceUid string - - svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "create") - if err != nil { - resp.Diagnostics.AddError("Failed to get create operation ID", err.Error()) - return - } - - // Prepare parameters - params := r.prepareVMParams(&data) - - // Use client.CreateInstance like Postgres does - displayName := data.DisplayName.ValueString() - var opUid string - instanceUid, opUid, err = r.client.CreateInstance(ctx, displayName, serviceId, svcOperationId, params) - if err != nil { - resp.Diagnostics.AddError("Error creating VM", err.Error()) - return - } - - // Wait for operation success (like Postgres) - err = r.client.WaitForOperation(ctx, opUid) - if err != nil { - resp.Diagnostics.AddError("Error waiting for VM creation", err.Error()) - return - } - - // Wait for instance to be running (like Postgres does) - err = r.client.WaitForInstanceStatus(ctx, instanceUid, "running") - if err != nil { - resp.Diagnostics.AddError("Error waiting for VM running status", err.Error()) - return - } - - data.ID = types.StringValue(instanceUid) - - // Read final state - readData, err := r.readInstance(ctx, instanceUid) - if err != nil { - resp.Diagnostics.AddWarning("Unable to read instance after creation", err.Error()) - } else { - data.Status = types.StringValue(readData.Status) - } - - // Fix "Provider returned invalid result object" for Computed fields - // AccessIpList is Computed + Optional. If it was not provided, we sent "", so we must set it to "" - if data.AccessIpList.IsNull() || data.AccessIpList.IsUnknown() { - data.AccessIpList = types.StringValue("") - } - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -// prepareVMParams собирает параметры для VM (выделено в отдельную функцию для переиспользования) -func (r *VMResource) prepareVMParams(data *VMResourceModel) []InstanceParam { - params := []InstanceParam{} - - // Hardcoded parameter mapping (от HAR анализа) - paramMapping := map[string]int{ - "vappUid": 407, // vApp UUID - "vmName": 408, // VM name - "vmCpu": 409, // CPU count - "vmRam": 410, // RAM in GB - "vmDisk": 411, // Additional disk in GB - "ipSpaceName": 412, // IP space name - "accessIpList": 413, // JSON array of allowed IPs (or empty string for all) - "imageVm": 414, // OS image name - "cloudInit": 415, // Cloud-init script - "userLogin": 416, // SSH username - "userPublicKey": 417, // SSH public key - "accessPortList": 448, // JSON array of ports - "needAddZabbixTemplate": 449, // Boolean for Zabbix monitoring - } - - // Собираем все параметры в том же порядке что и UI (по возрастанию ID) - // vappUid (407) - if !data.VappUid.IsNull() && !data.VappUid.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vappUid"], ParamValue: data.VappUid.ValueString()}) - } - // vmName (408) - if !data.VmName.IsNull() && !data.VmName.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vmName"], ParamValue: data.VmName.ValueString()}) - } - // vmCpu (409) - if !data.VmCpu.IsNull() && !data.VmCpu.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vmCpu"], ParamValue: fmt.Sprintf("%d", data.VmCpu.ValueInt64())}) - } - // vmRam (410) - if !data.VmRam.IsNull() && !data.VmRam.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vmRam"], ParamValue: fmt.Sprintf("%d", data.VmRam.ValueInt64())}) - } - // vmDisk (411) - ВСЕГДА отправляем (пустая строка если не задан) - if !data.VmDisk.IsNull() && !data.VmDisk.IsUnknown() && data.VmDisk.ValueInt64() > 0 { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vmDisk"], ParamValue: fmt.Sprintf("%d", data.VmDisk.ValueInt64())}) - } else { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["vmDisk"], ParamValue: ""}) - } - // ipSpaceName (412) - if !data.IpSpaceName.IsNull() && !data.IpSpaceName.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["ipSpaceName"], ParamValue: data.IpSpaceName.ValueString()}) - } - // accessIpList (413) - ВСЕГДА отправляем (пустая строка если не задан) - if !data.AccessIpList.IsNull() && !data.AccessIpList.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["accessIpList"], ParamValue: data.AccessIpList.ValueString()}) - } else { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["accessIpList"], ParamValue: ""}) - } - // imageVm (414) - if !data.ImageVm.IsNull() && !data.ImageVm.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["imageVm"], ParamValue: data.ImageVm.ValueString()}) - } - // cloudInit (415) - ВСЕГДА отправляем (пустая строка если не задан) - if !data.CloudInit.IsNull() && !data.CloudInit.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["cloudInit"], ParamValue: data.CloudInit.ValueString()}) - } else { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["cloudInit"], ParamValue: ""}) - } - // userLogin (416) - if !data.UserLogin.IsNull() && !data.UserLogin.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["userLogin"], ParamValue: data.UserLogin.ValueString()}) - } - // userPublicKey (417) - if !data.UserPublicKey.IsNull() && !data.UserPublicKey.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["userPublicKey"], ParamValue: data.UserPublicKey.ValueString()}) - } - // accessPortList (448) - if !data.AccessPortList.IsNull() && !data.AccessPortList.IsUnknown() { - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["accessPortList"], ParamValue: data.AccessPortList.ValueString()}) - } - // needAddZabbixTemplate (449) - ВСЕГДА отправляем "true" (как в UI) - params = append(params, InstanceParam{SvcOperationCfsParamId: paramMapping["needAddZabbixTemplate"], ParamValue: "true"}) - - return params -} - -func (r *VMResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data VMResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - instanceResp, err := r.readInstance(ctx, data.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err)) - return - } - - data.Status = types.StringValue(instanceResp.Status) - - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} - -func (r *VMResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var plan VMResourceModel - resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) - if resp.Diagnostics.HasError() { - return - } - - var state VMResourceModel - resp.Diagnostics.Append(req.State.Get(ctx, &state)...) - if resp.Diagnostics.HasError() { - return - } - - // Trigger modify operation - operationReq := CreateOperationRequest{ - InstanceUid: plan.ID.ValueString(), - Operation: "modify", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - // Extract operation ID and submit parameters - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - - // Wait for backend to possibly populate params - tflog.Info(ctx, "Waiting 5 seconds for operation parameters to initialize...") - time.Sleep(5 * time.Second) - - // Discovery parameters for modify to debug 400 error - tflog.Info(ctx, fmt.Sprintf("Discovering parameters for operation %s", operationId)) - op, err := r.client.GetInstanceOperation(ctx, operationId) - if err == nil { - for _, p := range op.CfsParams { - tflog.Info(ctx, fmt.Sprintf("Allowed Param: %s (ID: %d)", p.SvcOperationCfsParam, p.SvcOperationCfsParamId)) - } - } - - if err := r.submitVMOperationParams(ctx, operationId, plan, state); err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err)) - return - } - } - - // Read updated state - time.Sleep(5 * time.Second) - readData, err := r.readInstance(ctx, plan.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err)) - return - } - - plan.Status = types.StringValue(readData.Status) - - // Fix "Provider returned invalid result object" for Computed fields - if plan.AccessIpList.IsNull() || plan.AccessIpList.IsUnknown() { - plan.AccessIpList = types.StringValue("") - } - - resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) -} - -func (r *VMResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { - var data VMResourceModel - - resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { - return - } - - // Deletion Logic: - // If DeletionProtection is TRUE (default) -> Remove from state, do not touch cloud resource. - // If DeletionProtection is FALSE -> Send 'suspend' operation (quarantine) + remove from state. - - if !data.DeletionProtection.IsUnknown() && data.DeletionProtection.ValueBool() { - tflog.Info(ctx, "DeletionProtection is enabled. Resource will be removed from state but kept in cloud.") - return - } - - tflog.Info(ctx, "DeletionProtection is disabled. Initiating 'suspend' operation (quarantine).") - - // Trigger suspend operation (instead of delete) - operationReq := CreateOperationRequest{ - InstanceUid: data.ID.ValueString(), - Operation: "suspend", - } - - jsonData, err := json.Marshal(operationReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err)) - return - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData)) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err)) - return - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend instance: %s", err)) - return - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusNotFound { - body, _ := io.ReadAll(httpResp.Body) - resp.Diagnostics.AddError( - "API Error", - fmt.Sprintf("Suspend operation failed with status %d: %s", httpResp.StatusCode, string(body)), - ) - return - } - - // Extract operation ID and run operation - location := httpResp.Header.Get("Location") - if location != "" { - operationId := location[2:] - - // For suspend, we typically don't need parameters. Just Run. - tflog.Info(ctx, fmt.Sprintf("Running suspend operation %s", operationId)) - - runReq, err := http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperations/"+operationId+"/run", bytes.NewBuffer([]byte("{}"))) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create run request: %s", err)) - return - } - - runReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - runResp, err := r.client.HttpClient.Do(runReq) - if err != nil { - resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to run run request: %s", err)) - return - } - defer runResp.Body.Close() - - if runResp.StatusCode != http.StatusOK && runResp.StatusCode != http.StatusNoContent && runResp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(runResp.Body) - resp.Diagnostics.AddWarning("Client Warning", fmt.Sprintf("Run suspend failed with status %d: %s", runResp.StatusCode, string(body))) - } - } -} - -func (r *VMResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { - resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) -} - -func (r *VMResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) { - httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id, nil) - if err != nil { - return nil, err - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - - if httpResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body)) - } - - var response struct { - Instance InstanceResponse `json:"instance"` - } - if err := json.Unmarshal(body, &response); err != nil { - tflog.Error(ctx, fmt.Sprintf("Failed to unmarshal instance details: %s, Body: %s", err, string(body))) - return nil, err - } - - tflog.Info(ctx, fmt.Sprintf("Read Instance %s: Status='%s', ExplainedStatus='%s'", id, response.Instance.Status, response.Instance.Status)) - return &response.Instance, nil -} - -func (r *VMResource) submitVMOperationParams(ctx context.Context, operationUid string, plan VMResourceModel, state VMResourceModel) error { - // Step 1: Get operation details with parameters to build dynamic mapping - op, err := r.client.GetInstanceOperation(ctx, operationUid) - if err != nil { - return fmt.Errorf("unable to get operation parameters: %s", err) - } - - // Step 2: Build parameter mapping dynamically - type ParamInfo struct { - Id int - Uid string - CurrentValue string - } - paramMapping := make(map[string]ParamInfo) - for _, p := range op.CfsParams { - // Map param name (e.g. "vmCpu") to info (ID + InstanceParamUID) - curVal := "" - if p.ParamValue != nil { - curVal = *p.ParamValue - } - paramMapping[p.SvcOperationCfsParam] = ParamInfo{ - Id: p.SvcOperationCfsParamId, - Uid: p.InstanceOperationCfsParamUid, - CurrentValue: curVal, - } - } - - tflog.Info(ctx, fmt.Sprintf("Built dynamic parameter mapping for operation %s: %v", operationUid, paramMapping)) - - // Step 3: Build named parameters list - type NamedParam struct { - Name string - Value string // Must be strictly string - } - - namedParams := []NamedParam{} - - // Add ALL potential parameters. The mapping check will filter out those not applicable to the current operation. - - // vmName - /* - if !plan.VmName.IsNull() && !plan.VmName.IsUnknown() { - // Only send if changed - if !plan.VmName.Equal(state.VmName) { - namedParams = append(namedParams, NamedParam{"vmName", plan.VmName.ValueString()}) - } - } - */ - - // vmCpu - if !plan.VmCpu.IsNull() && !plan.VmCpu.IsUnknown() { - if !plan.VmCpu.Equal(state.VmCpu) { - namedParams = append(namedParams, NamedParam{"vmCpu", fmt.Sprintf("%d", plan.VmCpu.ValueInt64())}) - } - } - - // vmRam - /* - if !plan.VmRam.IsNull() && !plan.VmRam.IsUnknown() { - if !plan.VmRam.Equal(state.VmRam) { - namedParams = append(namedParams, NamedParam{"vmRam", fmt.Sprintf("%d", plan.VmRam.ValueInt64())}) - } - } - - // vmDisk - if !plan.VmDisk.IsNull() && !plan.VmDisk.IsUnknown() { - val := plan.VmDisk.ValueInt64() - if val > 0 && !plan.VmDisk.Equal(state.VmDisk) { - namedParams = append(namedParams, NamedParam{"vmDisk", fmt.Sprintf("%d", val)}) - } - } - - // ipSpaceName - if !plan.IpSpaceName.IsNull() && !plan.IpSpaceName.IsUnknown() { - if !plan.IpSpaceName.Equal(state.IpSpaceName) { - namedParams = append(namedParams, NamedParam{"ipSpaceName", plan.IpSpaceName.ValueString()}) - } - } - */ - - // accessIpList - /* - if !plan.AccessIpList.IsNull() && !plan.AccessIpList.IsUnknown() { - val := plan.AccessIpList.ValueString() - - // If ipSpaceName is "no-needed", SKIP sending this parameter - isNoNeeded := !plan.IpSpaceName.IsNull() && plan.IpSpaceName.ValueString() == "no-needed" - if !isNoNeeded { - if !plan.AccessIpList.Equal(state.AccessIpList) { - namedParams = append(namedParams, NamedParam{"accessIpList", val}) - } - } - } - - // accessPortList - if !plan.AccessPortList.IsNull() && !plan.AccessPortList.IsUnknown() { - val := plan.AccessPortList.ValueString() - - // If ipSpaceName is "no-needed", SKIP sending this parameter - isNoNeeded := !plan.IpSpaceName.IsNull() && plan.IpSpaceName.ValueString() == "no-needed" - if !isNoNeeded { - if !plan.AccessPortList.Equal(state.AccessPortList) { - namedParams = append(namedParams, NamedParam{"accessPortList", val}) - } - } - } - */ - - // needAddZabbixTemplate - // Skip sending to avoid duplicates unless we really know it changed. - // Since we don't track it in state (usually), we omit it. - - // Create-only params (will be filtered out for Modify automatically but good to skip anyway) - // Generally they shouldn't change in Modify. - // imageVm, cloudInit, userLogin, userPublicKey -> usually ForceNew? - // If they are not ForceNew, check change. - // Assuming ForceNew behavior for image/user details, but if they are Updateable: - /* - if !plan.ImageVm.IsNull() && !plan.ImageVm.IsUnknown() && !plan.ImageVm.Equal(state.ImageVm) { - namedParams = append(namedParams, NamedParam{"imageVm", plan.ImageVm.ValueString()}) - } - if !plan.CloudInit.IsNull() && !plan.CloudInit.IsUnknown() && !plan.CloudInit.Equal(state.CloudInit) { - namedParams = append(namedParams, NamedParam{"cloudInit", plan.CloudInit.ValueString()}) - } - if !plan.UserLogin.IsNull() && !plan.UserLogin.IsUnknown() && !plan.UserLogin.Equal(state.UserLogin) { - namedParams = append(namedParams, NamedParam{"userLogin", plan.UserLogin.ValueString()}) - } - if !plan.UserPublicKey.IsNull() && !plan.UserPublicKey.IsUnknown() && !plan.UserPublicKey.Equal(state.UserPublicKey) { - namedParams = append(namedParams, NamedParam{"userPublicKey", plan.UserPublicKey.ValueString()}) - } - */ - - // Step 4: Submit parameters - if len(namedParams) == 0 { - tflog.Info(ctx, "No parameters to submit. Skipping parameter submission.") - return nil - } - - // Submit parameters - for i := len(namedParams) - 1; i >= 0; i-- { - np := namedParams[i] - info, ok := paramMapping[np.Name] - if !ok { - // Parameter not allowed in this operation - continue - } - - // Check if value is unchanged - if np.Value == info.CurrentValue { - tflog.Info(ctx, fmt.Sprintf("Skipping parameter %s (ID %d) as value '%s' is unchanged", np.Name, info.Id, np.Value)) - continue - } - - tflog.Info(ctx, fmt.Sprintf("Submitting parameter: %s (ID %d) = %v [ParamUID: %s]", np.Name, info.Id, np.Value, info.Uid)) - - var method string - var payload map[string]interface{} - - if info.Uid != "" { - // Update existing parameter -> PUT - method = "PUT" - payload = map[string]interface{}{ - "instanceOperationCfsParamUid": info.Uid, - "svcOperationCfsParamId": info.Id, - "instanceOperationUid": operationUid, - "paramValue": np.Value, - } - } else { - // Create new parameter -> POST - method = "POST" - payload = map[string]interface{}{ - "instanceOperationUid": operationUid, - "svcOperationCfsParamId": info.Id, - "paramValue": np.Value, - } - } - - jsonData, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("unable to marshal param request: %s", err) - } - - tflog.Info(ctx, fmt.Sprintf("Param Request Payload (%s): %s", method, string(jsonData))) - - url := r.client.ApiEndpoint + "/instanceOperationCfsParams" - httpReq, err := http.NewRequestWithContext(ctx, method, url, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("unable to create param request: %s", err) - } - - httpReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("unable to submit parameter: %s", err) - } - - respBody, _ := io.ReadAll(httpResp.Body) - httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusCreated && - httpResp.StatusCode != http.StatusOK && - httpResp.StatusCode != http.StatusNoContent { - return fmt.Errorf("submit parameter %s (id %d) failed with status %d: %s", - np.Name, info.Id, httpResp.StatusCode, string(respBody)) - } - } - - // Step 5: Run the operation - runReq, err := http.NewRequestWithContext(ctx, "POST", - r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}"))) - if err != nil { - return fmt.Errorf("unable to create run request: %s", err) - } - - runReq.Header.Set("Content-Type", "application/json") - if r.client.ApiToken != "" { - runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - runResp, err := r.client.HttpClient.Do(runReq) - if err != nil { - return fmt.Errorf("unable to run operation: %s", err) - } - defer runResp.Body.Close() - - if runResp.StatusCode != http.StatusOK && - runResp.StatusCode != http.StatusNoContent && - runResp.StatusCode != http.StatusCreated { - runBody, _ := io.ReadAll(runResp.Body) - return fmt.Errorf("run operation failed with status %d: %s", - runResp.StatusCode, string(runBody)) - } - - // Step 6: Wait for operation completion - tflog.Info(ctx, fmt.Sprintf("Waiting for operation %s to complete...", operationUid)) - if err := r.client.WaitForOperation(ctx, operationUid); err != nil { - return fmt.Errorf("wait for operation %s failed: %s", operationUid, err) - } - - return nil -} - -type VMOperationStage struct { - Name string `json:"stage"` - IsSuccessful bool `json:"isSuccessful"` -} - -type VMOperation struct { - IsInProgress bool `json:"isInProgress"` - IsPending bool `json:"isPending"` - IsSuccessful *bool `json:"isSuccessful"` - DtFinish *string `json:"dtFinish"` - ErrorLog *string `json:"errorLog"` - Stages []VMOperationStage `json:"stages"` -} - -type GetVMOperationResponse struct { - InstanceOperation VMOperation `json:"instanceOperation"` -} - -func (r *VMResource) waitForVMOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - - // Phase 1: Wait for operation completion - opTicker := time.NewTicker(5 * time.Second) - defer opTicker.Stop() - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - case <-opTicker.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for operation %s", operationId) - } - - // Check operation status - httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instanceOperations/"+operationId, nil) - if err != nil { - return fmt.Errorf("failed to create request: %s", err) - } - - if r.client.ApiToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken) - } - - httpResp, err := r.client.HttpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("failed to check operation status: %s", err) - } - - body, _ := io.ReadAll(httpResp.Body) - httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK { - return fmt.Errorf("check operation failed: %d", httpResp.StatusCode) - } - - var opResp GetVMOperationResponse - if err := json.Unmarshal(body, &opResp); err != nil { - return fmt.Errorf("failed to parse operation response: %s", err) - } - - op := opResp.InstanceOperation - - // Detect Failure - if op.IsSuccessful != nil && !*op.IsSuccessful { - // Scan stages for detail - failedStage := "unknown" - for _, stage := range op.Stages { - if !stage.IsSuccessful { - failedStage = stage.Name - } - } - return fmt.Errorf("operation failed at stage '%s' (isSuccessful=false)", failedStage) - } - - // Detect Success (как в client_impl.go WaitForOperation) - if !op.IsInProgress && !op.IsPending && op.DtFinish != nil && *op.DtFinish != "" { - if op.IsSuccessful != nil && *op.IsSuccessful { - // Operation finished successfully - // Move to Phase 2 - goto Phase2 - } - // Если не successful но finished - это ошибка (уже обработана выше) - } - - // Still running, continue waiting... - } - } - -Phase2: - // Phase 2: Wait for Instance to be Running - instTicker := time.NewTicker(10 * time.Second) - defer instTicker.Stop() - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - case <-instTicker.C: - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for instance running state") - } - - instance, err := r.readInstance(ctx, instanceId) - if err != nil { - return fmt.Errorf("failed to check instance: %s", err) - } - - if instance.Status == "running" { - return nil - } - - if instance.Status == "error" || instance.Status == "failed" { - return fmt.Errorf("instance entered error state: %s", instance.Status) - } - } - } -} diff --git a/name_check.txt b/name_check.txt deleted file mode 100644 index bb0e7af..0000000 --- a/name_check.txt +++ /dev/null @@ -1,18 +0,0 @@ -tf-s3-test-01 NOT_FOUND >> /home/naeel/terra/name_check.txt - else - echo tf-s3-test-01 FOUND -tf-edge-test-01 NOT_FOUND >> /home/naeel/terra/name_check.txt - else - echo tf-edge-test-01 FOUND -tf-redis-test-01 NOT_FOUND >> /home/naeel/terra/name_check.txt - else - echo tf-redis-test-01 FOUND -tf-dns-zone-01 NOT_FOUND >> /home/naeel/terra/name_check.txt - else - echo tf-dns-zone-01 FOUND -tf-dns-record-01 NOT_FOUND >> /home/naeel/terra/name_check.txt - else - echo tf-dns-record-01 FOUND -tf-grafana-tenant-01 NOT_FOUND >> /home/naeel/terra/name_check.txt - else - echo tf-grafana-tenant-01 FOUND diff --git a/prompt_for_ds_pro.md b/prompt_for_ds_pro.md deleted file mode 100644 index fc89ceb..0000000 --- a/prompt_for_ds_pro.md +++ /dev/null @@ -1,226 +0,0 @@ -# Запрос на глубокий анализ — Terraform Provider for Nubes Cloud - -## Контекст - -Проект — **Terraform Provider** для облачной платформы **Nubes Cloud** (Taffy/Deck API). -Провайдер написан на Go с использованием `terraform-plugin-framework` (SDK v2). - -**Версия:** 5.0.52 -**Тип:** Universal Provider — один core-движок, ресурсы генерируются из YAML-спек. - ---- - -## Структура проекта - -### Папки (корень: `/home/naeel/tf_provider/`) - -``` -internal/ -├── core/ # Универсальный клиент API (client.go, instance_ops.go, instance_lookup.go) -├── provider/ # Реализация провайдера и НЕ-сгенерированных ресурсов (VM, Edge, VDC, VApp, Postgres, S3 и др.) -├── generated/ # Сгенерированные bolvan-ресурсы -└── registrykeys/ # Ключи для GPG-подписи - -universal_rebuild/ # Новая универсальная архитектура (ядро, генераторы, YAML-спеки) -├── internal/ -│ ├── core/ # Универсальный клиент (client.go) — v6 API flow -│ ├── provider/ # Конфиг провайдера -│ ├── resources_core/ # CRUD-логика (crud.go), хелперы, валидация, реф-параметры -│ └── resources_gen/ # АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЕ ресурсы (~100 файлов) -├── tools/ -│ ├── gen/ # Go-генератор (читает YAML → генерирует ресурсы + registry.go) -│ ├── gen_v2/ # Версия 2 генератора -│ ├── service_params_gen/ # YAML-генератор (API → YAML) -│ ├── docs_template_gen/ # Генератор документации -│ └── service_spec_gen/ # Генератор спек -└── resources_yaml/ # YAML-спеки (сейчас только embed.go — встраиваются в бинарник) - -devops/ # Скрипты сборки и деплоя -├── config/ -│ ├── services_list.txt # Список всех сервисов (30+ строк) -│ └── operation_timeouts.json # Таймауты операций по сервисам -├── 01_generate_yamls.sh # Шаг 1: YAML из API -├── 02_generate_resources_and_docs*.sh # Шаг 2: генерация Go + docs -└── 03_build_and_upload_provider.sh # Шаг 3: сборка + публикация - -docs/ # Документация -├── 00_overview/ # Обзор -├── 20_discovery/ # Discovery сервисов -├── 30_registry/ # Реестр ресурсов -├── 40_analysis/ # Анализы и форензика -├── 50_history/ # История (23 файла — пошагово весь процесс разработки) -├── 60_strategy/ # Стратегия и философия провайдера -├── 70_api/ # API-документация и дампы -└── help/ # Справка -``` - ---- - -## Что уже сделано и известно - -### Архитектура API (Taffy/Deck) - -**Базовый URL:** `https://deck-api.ngcloud.ru/api/v1/index.cfm` - -**Жизненный цикл операции:** -1. `POST /instances` — создать placeholder инстанса → получаем `instanceUid` -2. `POST /instanceOperations` — создать операцию (create/modify/suspend/resume/delete) → `instanceOperationUid` -3. `GET /instanceOperations/{uid}?fields=cfsParams` — получить схему параметров -4. `POST /instanceOperationCfsParams` — отправить значения параметров (каждый параметр по `svcOperationCfsParamId`) -5. `GET /instanceOperations/{uid}/validate-cfs` — валидация -6. `POST /instanceOperations/{uid}/run` — запуск операции -7. `GET /instanceOperations/{uid}` — поллинг до `dtFinish` (критерий завершения) - -**Получение состояния инстанса:** `GET /instances/{instanceUid}` → `explainedStatus`, `isDeleted`, `availableOperations` - -**Получение списка инстансов:** `GET /instances?page=N&size=100` - -### Список сервисов (30+) -Сервисы с service_id от 1 до 150: -- Инфраструктурные: vcOrg(19), vc_vdc(21), vc_nsxt(22), vc_vm(23/27/28), vapp(26), vcexternalip(25), vcVdcGroup(29) -- Базы данных: postgres(90), mariadb(115), mongodb(92), redis(91), clickhouse(120), vmpostgre(32), kafka(116) -- Хранилища: s3(12), s3bucket(13) -- Приложения: nextcloud(50), superset(81), harbor(82), flask(89), lucee(94), nodejs(95), pgadmin(96), nodered(97), http(98), gitea(99), rabbitmq(93), nifi(117), openwhisk(100) -- DNS: dnszone(110), dnsrecord(111) -- K8s: k8s_sthutrval_cluster(150) -- Прочее: dummy(1), template(2), tenant(112), vcComplex(113), GiteaComplex(114), akhq(119), valoTenant(149) - -### Lifecycle-логика (для сервисов с suspend/resume) -- **Флаги:** `adopt_existing_on_create` (default: false), `suspend_on_destroy` (default: true) -- **Apply/Create:** проверка облака по `resource_name` → статус-матрица принятия решений -- **Destroy:** по умолчанию suspend, при `suspend_on_destroy=false` — только state -- **Modify:** через `RunInstanceOperationUniversal` с params по `svcOperationCfsParamId` -- **Replace:** запрещён для suspend-сервисов - -### Проблемы, известные из истории -1. **Case-sensitivity имён** (docs/60_strategy/terraform_case_sensitivity_fix.md) -2. **Ref-параметры и валидация на adopt** (docs/60_strategy/adopt_ref_validation.md) -3. **VApp UID inconsistency / displayName resolve bug** (история 23) -4. **Subresource generation** — не все подресурсы корректно генерируются -5. **Params normalisation** — CamelCase → snake_case может ломаться -6. **Postgres immutables** — некоторые параметры нельзя менять после создания (ForceNew) -7. **Operation timeouts** — разные сервисы требуют разных таймаутов -8. **Deleted instances** — определение deleted через `explainedStatus` и `isDeleted` -9. **Валидация статусов** — `not created`, `pending`, `failed` должны блокировать adopt -10. **ResourceAlreadyExists diagnostics** — обязательны два варианта в сообщении - -### Архитектура universal_rebuild (ключевые особенности) -- **Ядро (core)**: UniversalClient — универсальный клиент, `CreateGenericInstanceUniversalV6`, `RunInstanceOperationUniversal`, `FindInstanceByDisplayName` -- **Resources_core**: `CreateResource`, `UpdateResource`, `DeleteResource`, `RunOperationByCode` — обёртки CRUD с timeout override -- **Resources_gen**: ~100 автогенерированных файлов ресурсов (все сервисы) -- **Генераторы**: `service_params_gen` → YAML, `gen/gen_v2` → Go код, `docs_template_gen` → документация -- **YAML-спеки**: сейчас встраиваются через embed.go, но есть YAML-генератор - -### Генерация (devops pipeline) -1. `01_generate_yamls.sh` — получает YAML из API для всех сервисов из `services_list.txt` -2. `02_generate_resources_and_docs*.sh` — запускает Go-генератор + docs-генератор -3. `03_build_and_upload_provider.sh` — сборка под 3 ОС + GPG-подпись + загрузка в S3 -4. `04_build_and_publish_docs.sh` — сборка mkdocs + публикация в S3 - -### Документация -- MkDocs Material theme -- Документация генерируется из тех же YAML-спек -- Публикуется в S3 bucket `terraform-registry` - ---- - -## Что НУЖНО изучить подробно - -### 1. API: неизвестные эндпойнты и возможности -- Есть ли другие эндпойнты, кроме задокументированных? -- Как работает `/services/{svcId}/default` (deprecated — что вместо)? -- Есть ли batch-операции? -- Пагинация: какие лимиты? Есть ли курсор? -- Rate limiting: есть ли? Какие лимиты? -- Есть ли эндпойнт для массового получения всех инстансов с параметрами? - -### 2. Параметры сервисов (svcOperationCfsParamId) -Для каждого сервиса: -- Полный список `cfsParams` (все операции: create, modify, suspend, resume, delete, action) -- Какие параметры required, какие optional, какие read-only (outputs) -- Какие параметры имеют `refSvcId` (ссылки на другие сервисы) -- Какие параметры имеют `valueList` (выпадающие списки) -- DataType: какие бывают (string, boolean, list, uuid, int, float, json?) -- Default values — откуда берутся? -- Какие параметры immutable (create-only) -- Какие параметры зависят от других (dependency chain) - -### 3. Статусы и состояния инстансов -- Все возможные значения `explainedStatus` -- Все возможные значения `isDeleted` -- Матрица переходов (state machine): - - `creating` → `running` - - `running` → `suspend` → `running` - - `running` → `deleted` - - `failed` → ? - - `not created` → ? -- Что происходит при `pending` / `operation in progress`? -- Как долго длятся типичные операции? - -### 4. Операции (kinds и их семантика) -- `kind: instance` — полный CRUD -- `kind: subresource` — операции внутри инстанса (create_user, delete_database и т.д.) -- `kind: action` — одноразовые операции (restart, redeploy, recovery) -- Для каждого сервиса: какие subresource и action операции доступны? -- Как subresource идентифицируются? (через parent instanceUid? свой instanceUid?) -- Есть ли операции, которые не вписываются в схему? - -### 5. Схема YAML-спек -- Нужно восстановить формат YAML для примера одного-двух сервисов (dummy, postgres, s3bucket) -- Какие поля обязательны? Какие опциональны? -- Как описываются operations с kind-ами? -- Как описываются timeout-ы? -- Как описываются outputs? -- Как описываются service_man (руководство пользователя)? -- Как описываются lifecycle-правила (suspend_on_destroy, adopt_existing_on_create)? - -### 6. Ошибки и проблемы продакшена -- Все известные баги из истории (23 файла в docs/50_history/) -- Какие ресурсы сейчас не работают? (VApp UID, Postgres immutables и т.д.) -- Какие тесты падают или неполны? -- Есть ли проблемы с GPG-ключами / реестром? -- Есть ли проблемы с S3-публикацией? - -### 7. Тестовое покрытие -- Какие тесты написаны в `universal_rebuild/internal/resources_core/`? -- Есть ли интеграционные тесты с реальным API? -- Какие тесты нужны, но отсутствуют? -- Есть ли моки для API? - -### 8. Безопасность -- Как управляются токены? Какой механизм refresh? -- Есть ли поддержка разных эндпойнтов (prod/dev)? -- Как обрабатываются чувствительные данные (пароли БД, ключи)? -- Есть ли валидация входящих параметров на injection? - -### 9. Производительность -- Сколько времени занимает типичный apply? -- Есть ли узкие места в поллинге? -- Оптимальные интервалы поллинга для разных операций? -- Есть ли кэширование? - -### 10. Документация (MkDocs) -- Как генерируется документация из YAML? -- Как работает шаблонизация? -- Что надо исправить/дополнить? - ---- - -## Формат ответа - -Пожалуйста, предоставь детальный анализ по каждому из 10 пунктов. Для каждого пункта: -1. **Текущее понимание** (что уже известно из контекста) -2. **Пробелы в знаниях** (чего не хватает) -3. **Гипотезы** (что можно предположить на основе имеющихся данных) -4. **Что нужно сделать** (конкретные шаги: какие файлы прочитать, какие API вызвать, какие тесты запустить) -5. **Риски** (какие проблемы могут возникнуть) - -Особое внимание удели: -- Формату YAML-спек (п.5) — это основа всей генерации -- Полному списку cfsParams для топ-10 сервисов (п.2) -- Матрице состояний (п.3) — критично для lifecycle-логики -- Ошибкам из истории (п.6) — чтобы не повторять - -Если для какого-то пункта информации в предоставленном контексте недостаточно — укажи это и предложи, где искать недостающие данные (какие файлы прочитать, какие curl-запросы выполнить). - -**Важно:** Ничего НЕ ДЕЛАЙ в файловой системе. Никаких изменений кода, файлов, конфигов. Только анализ. diff --git a/prompt_for_opus48.md b/prompt_for_opus48.md deleted file mode 100644 index e4a2094..0000000 --- a/prompt_for_opus48.md +++ /dev/null @@ -1,289 +0,0 @@ -# Промпт для Opus 4.8: Анализ Terraform Provider для Nubes Cloud - -> **Цель:** глубокий анализ архитектуры, генерационного пайплайна, API-взаимодействия и проблем проекта. Не просто описать, а найти слабые места, предложить улучшения, оценить риски. -> **Инструкция:** читай файлы по мере необходимости, не пытайся прочитать всё сразу. Ниже — карта проекта с указанием что где лежит и на что обратить внимание. - ---- - -## 1. Общая архитектура — два провайдера в одном репозитории - -| Компонент | Путь | Версия | Registry | Характер | -|-----------|------|--------|----------|----------| -| **Legacy** | `/main.go`, `internal/provider/` | 5.0.52 | `registry.terraform.io/nubes/nubes` | Ручной код, 13 ресурсов | -| **Universal Rebuild** | `universal_rebuild/main.go`, `universal_rebuild/...` | 5.0.51 | `terra.k8c.ru/nubes/nubes` | Полностью генерируемый, ~50 ресурсов | - -Оба используют `terraform-plugin-framework`. Legacy — ручной, Universal — продукт генерационного конвейера. Legacy всё ещё жив, Universal — целевой. - -### Ключевые файлы для понимания архитектуры: -- `/home/naeel/tf_provider/devops/ARCHITECTURE.md` — архитектурные принципы (YAML как source of truth, никаких ручных правок сгенерированного кода) -- `/home/naeel/tf_provider/docs/CODEBASE_ANALYSIS_AND_ROADMAP.md` — полный анализ кодовой базы от 13.03.2026 -- `/home/naeel/tf_provider/docs/MIGRATION_PLAN_FOR_AGENT.md` — план миграции в Managed K8s - -**Вопросы к Opus:** -- Насколько оправдано существование двух провайдеров? Когда и как Legacy должен быть выведен? -- Есть ли архитектурные проблемы в разделении `internal/` vs `universal_rebuild/internal/`? - ---- - -## 2. Генерационный конвейер (CRITICAL PATH) - -### Полный пайплайн: - -``` -API Nubes (deck-api.ngcloud.ru) - │ - ▼ -[Шаг 1] devops/01_generate_yamls.sh - │ └─ запускает: universal_rebuild/tools/service_spec_gen/generate_service_spec.go - │ └─ читает: devops/config/services_list.txt (30+ сервисов) - │ └─ для каждого сервиса: GET /services/{id} → получает операции → GET детали каждой операции → cfsParams - │ └─ пишет: {id}_{name}.yaml в resources_yaml/ - │ - ▼ -[Шаг 2] devops/02_generate_resources_and_docs_v2.sh - │ ├─ Go-генератор: universal_rebuild/tools/gen_v2/generate_resources_v2.go - │ │ └─ читает YAML → классифицирует операции (instance/subresource/action) → генерирует .go файлы - │ │ └─ пишет: internal/resources_gen/*.go (50+ файлов) + registry.go - │ │ - │ └─ Доку-генератор: universal_rebuild/tools/docs_template_gen_v2/ - │ └─ читает YAML → генерирует .md документацию - │ - ▼ -[Шаг 3] devops/03_build_and_upload_provider.sh - │ └─ сборка под linux/windows/darwin → GPG-подпись → S3 - │ - ▼ -[Шаг 4] devops/04_build_and_publish_docs.sh - └─ mkdocs build → S3 -``` - -### Ключевые файлы генераторов: -- **service_spec_gen** (API→YAML): `/home/naeel/tf_provider/universal_rebuild/tools/service_spec_gen/generate_service_spec.go` -- **gen_v2** (YAML→Go): `/home/naeel/tf_provider/universal_rebuild/tools/gen_v2/generate_resources_v2.go` -- **services_list.txt**: `/home/naeel/tf_provider/devops/config/services_list.txt` — 30+ сервисов (dummy, s3, postgres, kafka, clickhouse, k8s, ...) -- **operation_timeouts.json**: `/home/naeel/tf_provider/devops/config/operation_timeouts.json` - -### Профили (стенды): -- `/home/naeel/tf_provider/devops/profiles/test/`, `prod/`, `dev/` -- Каждый профиль содержит: `profile.env`, `services_list.txt`, `operation_timeouts.json`, `generated/` - -**Вопросы к Opus:** -- Насколько надёжен конвейер? Что произойдёт если API изменит формат ответа? -- Достаточно ли валидации на каждом шаге? Нет ли риска генерации битого кода? -- Почему генераторы на Go, а не на Python (учитывая что скрипты на bash)? -- Как обрабатываются ошибки API (retry, rate limiting)? См. `service_spec_gen` — есть `ATTEMPTS=3`, `REQUEST_DELAY=0.5` -- Комментирование сервисов в `services_list.txt` — это единственный способ исключить сервис? Не приведёт ли к рассинхрону? - ---- - -## 3. Core: UniversalClient и API-взаимодействие - -### Файл: `/home/naeel/tf_provider/universal_rebuild/internal/core/client.go` - -**API Flow V6 (create):** -1. `POST /instances` → получаем instanceUid -2. `POST /instanceOperations` (operation="create") → получаем instanceOperationUid -3. `GET /instanceOperations/{uid}?fields=cfsParams` → получаем параметры с дефолтами -4. `resolveRefSvcParamValues` — разрешение ref-параметров (UUID других сервисов) -5. Для каждого параметра: `POST /instanceOperationCfsParams` -6. Валидация + запуск операции - -**Методы:** -- `CreateGenericInstanceUniversalV6` — полный create flow -- `FindInstanceByDisplayName` — поиск по displayName с фильтрацией deleted/дубликатов -- `GetInstanceState` / `GetInstanceStateRaw` — чтение состояния -- `RunInstanceOperationUniversal` / `RunInstanceOperationUniversalWithDefaults` — modify/suspend/resume/delete -- `RunInstanceOperationUniversalByCode` — операции по коду (для action/subresource) - -### HTTP-транспорт: `/home/naeel/tf_provider/universal_rebuild/internal/provider/provider.go` -- Force HTTP/1.1 (API не поддерживает HTTP/2) -- InsecureSkipVerify опционально (для dev-стендов) -- Timeout 300s -- TLS 1.2 minimum - -### Поддержка core: -- `/home/naeel/tf_provider/universal_rebuild/internal/core/instance_params.go` — маппинг параметров -- `/home/naeel/tf_provider/universal_rebuild/internal/core/instance_outputs.go` — чтение outputs -- `/home/naeel/tf_provider/universal_rebuild/internal/core/refsvc_resolve.go` — разрешение ref_svc_id -- `/home/naeel/tf_provider/universal_rebuild/internal/core/operation_timeouts.go` — таймауты операций - -**Вопросы к Opus:** -- API Flow V6 — есть ли проблемы с идемпотентностью? Что при обрыве на шаге 4 или 5? -- `doRequest` внутри — есть ли retry logic? Как обрабатываются 429/503? -- Почему HTTP/1.1 принудительно? Это ограничение API или обход бага? -- 300s timeout на всём HTTP-клиенте — не мало ли для длинных операций (создание VM может идти 10+ минут)? - ---- - -## 4. CRUD-логика и Lifecycle - -### Файл: `/home/naeel/tf_provider/universal_rebuild/internal/resources_core/crud.go` - -**CreateResource:** -1. `FindInstanceByDisplayName` — поиск существующего -2. Если найден → `adoptExistingInstanceOnCreate`: - - Проверка `operation_in_progress`/`operation_pending` - - Если `adopt_existing_on_create=false` → ошибка - - Статус `not_created` → ошибка - - Статус `running` → валидация ref-параметров → adopt (возврат UUID) - - Статус `suspended` → проверка required-params → resume → валидация ref → adopt -3. Если не найден → `CreateGenericInstanceUniversalV6` - -**DeleteResource:** -- `suspend` → вызов suspend -- `state_only`/`detach` → только удаление из state - -**UpdateResource:** -- `RunInstanceOperationUniversalWithDefaults("modify")` - -### Поддерживающие файлы в `resources_core/` (все в `/home/naeel/tf_provider/universal_rebuild/internal/resources_core/`): -- `ref_validation.go` — валидация ref-параметров при adopt -- `required_params.go` + `required_params_compare.go` — проверка обязательных параметров -- `params_compare.go` — сравнение параметров для detect changes -- `params_mapping.go` + `params_ref_mapping.go` — маппинг параметров из/в API -- `params_validation_mapping.go` — маппинг для plan validation -- `state_refresh.go` — RefreshResourceState (чтение state после apply) -- `outputs.go` — FetchInstanceOutputs -- `operation_ids.go` — маппинг operation ID → имя -- `json_normalize.go` + `json_planmodifier.go` — нормализация JSON-параметров -- `uuid_planmodifier.go` — план-модификатор для UUID -- `domain_collision.go` — проверка коллизий доменов -- `resource_diagnostics.go` + `resource_diagnostics_required.go` — форматирование диагностик -- `subresource_guard.go` — защита подресурсов -- `crud_test.go` — тесты - -**Вопросы к Opus:** -- `adoptExistingInstanceOnCreate` — не слишком ли сложная логика? 100+ строк, много ветвлений. Есть ли риск не покрытых кейсов? -- Что происходит при concurrent apply из двух разных Terraform-окружений? -- `state_only` destroy — не остаются ли orphaned ресурсы в облаке? -- Как определяется `suspend_on_destroy` по умолчанию? Где логика `hasSuspend → suspendOnDestroyDefault`? - ---- - -## 5. Генерация Go-кода из YAML — КРИТИЧЕСКИ - -### Файл: `/home/naeel/tf_provider/universal_rebuild/tools/gen_v2/generate_resources_v2.go` - -**На входе:** YAML-спек каждого сервиса -**На выходе:** 3 типа Go-ресурсов: -- **Instance** (kind=instance) — CRUD + lifecycle (suspend/resume) -- **Subresource** (kind=subresource) — CRUD для подобъектов (users, databases, topics) -- **Action** (kind=action) — одноразовые операции (restart, redeploy, reconcile) - -**Логика генератора:** -1. Парсит все YAML → `ServiceSpec` (name, service_id, outputs, lifecycle, operations) -2. Для instance: собирает create/modify params → computeCreateOnly → mergeParams → генерирует `.go` -3. Для subresource: группирует по subresource name → create/modify/delete params -4. Для action: каждый action → отдельный ресурс с trigger-полем -5. Генерирует `registry.go` — `AllResources()` со всеми New* функциями - -### Пример сгенерированного файла: `/home/naeel/tf_provider/universal_rebuild/internal/resources_gen/90_postgres_resource.go` - -**Вопросы к Opus:** -- Шаблоны генерятся через `text/template` — где сами шаблоны? Они вшиты в `gen_v2` как константы? Насколько они поддерживаемы? -- Что произойдёт если в YAML появится новый kind операции, не известный генератору? -- `createOnly` параметры — как определяется что параметр immutable? Из API или эвристика? -- JSON-параметры (`data_type: json`) — как обрабатываются? Есть `IsJson` + `JsonNormalize` план-модификатор. Это надёжно? -- Для subresource — как определяется identity (какие параметры идентифицируют ресурс)? - ---- - -## 6. Известные проблемы (из 23 файлов истории) - -Архив: `/home/naeel/tf_provider/docs/50_history/` (23 файла, от `00_system_mechanics.md` до `23_vapp_uid_inconsistency_displayname_resolve_bug.md`) - -### Ключевые баги (прочитай эти файлы): -- **23** — `vapp_uid_inconsistency`: `FindInstanceByDisplayName` возвращает deleted инстанс (нет фильтра isDeleted) -- **22** — `adopt_ref_validation`: ref-параметры не валидировались при adopt → ссылки на deleted ресурсы -- **21** — `plan_validation_ref_svc_filter`: план показывал deleted/suspended инстансы в ref-списках -- **16** — `create_only_params`: create-only параметры не блокировались при modify -- **13** — `universal_flow_param_normalization`: нормализация параметров между API и Terraform -- **06** — `postgres_update_immutable_params`: immutable параметры при модификации -- **05** — `polling_fixes`: проблемы с поллингом длительных операций -- **04** — `vm_hang_fix_and_500_error`: 500 ошибка при модификации VM (см. также DEBUG_REPORT_VM_FIX.md) - -### Другие важные документы: -- `/home/naeel/tf_provider/docs/INSTANCE_STATES.md` — полная матрица состояний инстансов -- `/home/naeel/tf_provider/docs/STATE_TRANSITIONS.md` — все переходы состояний (NOT_CREATED, CREATING, RUNNING, SUSPENDED, DELETED, ...) -- `/home/naeel/tf_provider/docs/DEBUG_REPORT_VM_FIX.md` — отчёт о попытке исправить 500 при modify VM - -**Вопросы к Opus:** -- Какие баги из истории до сих пор актуальны (не исправлены)? -- Есть ли системные проблемы, которые порождают целые классы багов (например, отсутствие фильтрации deleted инстансов)? -- Насколько полна матрица состояний? Все ли переходы обрабатываются? -- 500 ошибка при modify VM — правильно ли был сделан анализ? Может быть проблема в API, а не в провайдере? - ---- - -## 7. HAR-трассировки — источник правды об API - -Файлы в `/home/naeel/tf_provider/HAR/`: -- `OK.har`, `goodmodify.har`, `baddelete.har` — реальные HTTP-трассировки -- `deck.ngcloud.ru.har`, `deck.ngcloud1.ru.har` — полные сессии -- `keycloak.nubes.ru.har`, `lucee.har`, `pguser.har` — специфичные сервисы - -**Вопросы к Opus:** -- HAR-файлы — ценный источник для верификации API-контракта. Используются ли они в тестах? -- Можно ли автоматизировать проверку что провайдер соответствует реальному API на основе HAR? - ---- - -## 8. Тестирование - -### Где тесты: -- `/home/naeel/tf_provider/universal_rebuild/internal/resources_core/crud_test.go` -- `/home/naeel/tf_provider/universal_rebuild/internal/core/client_test.go` - -**Вопросы к Opus:** -- Достаточно ли тестов? Какие пробелы? -- Как тестировать сгенерированные ресурсы без реального API? -- Нужны ли integration-тесты против реального API (с мок-сервером или тестовым стендом)? - ---- - -## 9. Безопасность - -- Токен аутентификации: env `NUBES_API_TOKEN`, файл `~/.nubes_token`, или в HCL (`api_token` — sensitive) -- GPG-ключ для подписи провайдера: `/home/naeel/tf_provider/secrets/private_key.asc` -- Токены: `/home/naeel/tf_provider/secrets/dev.token`, `prod.token`, `test.token` -- InsecureSkipVerify для dev-стендов - -**Вопросы к Opus:** -- Есть ли риск утечки токена через логи/диагностики? -- `Sensitive: true` на api_token — достаточно ли этого? -- GPG-ключ в репозитории — это нормально? (в `.gitignore` ли он?) - ---- - -## 10. Что нужно оценить Opus (итоговый список) - -1. **Архитектура:** два провайдера → стратегия перехода на Universal -2. **Конвейер:** надёжность, валидация, обработка ошибок API -3. **Генератор кода:** поддерживаемость шаблонов, расширяемость -4. **Core/API:** retry logic, таймауты, идемпотентность, HTTP/1.1 vs HTTP/2 -5. **Lifecycle:** полнота coverage состояний, краевые кейзы adopt/resume -6. **Баги:** какие системные, какие ещё не исправлены -7. **Тесты:** пробелы в покрытии -8. **Безопасность:** токены, GPG, secrets management -9. **Производительность:** поллинг, параллельные запросы, кэширование -10. **Документация:** насколько генерируемые доки соответствуют реальности - -### На что обратить ОСОБОЕ внимание: -- `adoptExistingInstanceOnCreate` в `crud.go` — самая сложная функция, много ветвлений -- `FindInstanceByDisplayName` — была переписана, но всё ещё есть риск не найти/найти не тот -- Генератор `gen_v2` — если сломается, сломается ВЕСЬ провайдер -- Immutable параметры — как определяется неизменяемость? Из API или из YAML? -- Конкурентный доступ — что если два `terraform apply` одновременно? - ---- - -## Инструкция Opus - -1. Начни с чтения `devops/ARCHITECTURE.md` и `docs/CODEBASE_ANALYSIS_AND_ROADMAP.md` -2. Затем прочитай ключевые файлы конвейера: `service_spec_gen/generate_service_spec.go` и `gen_v2/generate_resources_v2.go` -3. Затем core: `client.go` и `crud.go` -4. Затем историю проблем: выборочно 2-3 последних файла из `docs/50_history/` -5. После этого — дай развёрнутый анализ по ВСЕМ 10 пунктам выше -6. В анализе на каждый пункт: текущее состояние → что хорошо → что плохо → конкретные предложения → риски - -**НЕ читай все 50+ сгенерированных файлов подряд — они генерируются и не содержат уникальной логики.** diff --git a/s.sh b/s.sh deleted file mode 100755 index 0bfbdb4..0000000 --- a/s.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -VM_HOST="naeel@5.172.178.213" -VM_PATH="/home/naeel/tf_provider/TEST_STAND" -SSH_KEY="$HOME/.ssh/naeel_vm_id_ed25519" -SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10" - -rsync -avz --delete \ - --exclude '.terraform/' \ - --exclude '*.tfstate' \ - --exclude '*.tfstate.*' \ - --exclude '.terraform.lock.hcl' \ - -e "ssh -i $SSH_KEY $SSH_OPTS" \ - /home/naeel/tf_provider/TEST_STAND/ \ - "${VM_HOST}:${VM_PATH}/" diff --git a/services.tsv b/services.tsv deleted file mode 100644 index 088b905..0000000 --- a/services.tsv +++ /dev/null @@ -1,24 +0,0 @@ -1 dummy -114 GiteaComplex -115 mariadb -116 kafka -117 nifi -12 s3 -13 s3bucket -21 vc_vdc -22 vc_nsxt -25 vcexternalip -26 vapp -28 vc_vm_v3 -81 superset -82 harbor -89 flask -90 postgres -91 redis -92 mongodb -93 rabbitmq -94 lucee -95 nodejs -96 pgadmin -97 nodered -99 gitea diff --git a/services_raw.json b/services_raw.json deleted file mode 100644 index 0981fba..0000000 --- a/services_raw.json +++ /dev/null @@ -1 +0,0 @@ -{"queryDurationMs":64,"total":44,"resultSetSize":44,"pageSize":100,"page":1,"orderBy":"","size":44,"results":[{"isProductionReady":true,"man":"Перед разворачиванием убедиться, что в облаке есть 2 свободных невыделенных IP в пуле","dtUpdated":"2025-08-26T15:48:07.904+0300","version":"1.0.0","sort":null,"dtCreated":"2025-06-09T15:37:50.547+0300","svcShort":"k8sSthutrvalCluster","resourceRealmTypeId":3,"code":"paas.k8s.stw","orchestratorName":"SthutrvalCluster","svcId":150,"svc":"Kubernetes кластер Штурвал","svcExtendedName":"Kubernetes кластер Штурвал k8sSthutrvalCluster ","descr":"Платформа управления K8s
","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по развертыванию Тенант Valo через платформу

## 1. Общая информация
Тенант Valo — логический изолированный экземпляр сервиса VALO Cloud, предоставляющий защищённое облачное хранилище, синхронизацию данных и доступ к документам с различных устройств. При создании тенант автоматически разворачивается в тестовом режиме сроком на 2 месяца. Перевод в промышленную эксплуатацию выполняется отдельной операцией.

### Доступные операции

* **create** — создание нового тенанта в тестовом режиме сроком на 2 месяца.
* **modify** — изменение настроек тенанта, включая перевод из ТЕСТ в ПРОД.
* **suspend** — выключение (приостановка работы) тенанта.
* **resume** — включение ранее приостановленного тенанта.
* **delete** — удаление тенанта; доступно только после выполнения операции suspend.

---

## 2. Параметры развертывания

Параметры входных данных операции **create**:

### Платформа для развертывания
Описание: платформа, на которой будет создан тенант.
Пример значения: `valo.ngcloud.ru`.

### DNS-Префикс
Описание: Будет добавлено в зону valo.ngcloud.ru
Пример значения: `mysimpledomain`.

### Параметры хранилища
Описание: Сколько места будет выделено под использование в тенанте. Данные хранятся в S3. Размер указывается в Gigabytes
Пример значения: `10`.

### Настройки тенанта
Описание: Ряд конфигурации Valo-тенанта, такие как настройки пользователя и доступного функционала
Пример значения: `опционально в рамках настройки`.

---

## 3. Рекомендованные характеристики

### Тестовое окружение
Достаточно развернуть тенант в ТЕСТ. Срок предоставляется в 2 месяца. По истечению срока тенант автоматически переводится в `suspend` на стороне ресурсной платформы

### Промышленное окружение
Для разблокировки используйте операцию `modify`

---

## 4. Выходные параметры

Услуга предоставляет следующие выходные параметры (формируются в процессе операций):

### Статус тенанта
Отражает текущее состояние и параметры.

---

## 5. Дополнительная информация
Тенант всегда создаётся в режиме ТЕСТ и не может быть сразу переведён в ПРОД без операции modify.
Удаление тенанта возможно только после его выключения с помощью операции suspend.
Срок тестового режима составляет 2 месяца с момента создания.
Операции suspend и resume используются для временного управления доступностью сервиса без потери конфигурации.
","dtUpdated":"2026-01-30T11:52:53.792+0300","version":"0.0.1","sort":null,"dtCreated":"2025-12-04T18:48:01.093+0300","svcShort":"valoTenant","resourceRealmTypeId":8,"code":null,"orchestratorName":"Tenant","svcId":149,"svc":"VALO Cloud","svcExtendedName":"VALO Cloud valoTenant ","descr":"Корпоративная система обмена и синхронизации файлов","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по управлению кластерами и пользователями ClickHouse

---

## Общая информация

ClickHouse — это колоночная система управления базами данных, предназначенная для обработки аналитических запросов в реальном времени. Платформа позволяет создавать, удалять, приостанавливать и возобновлять кластеры ClickHouse, а также управлять базами данных и пользователями.

Кластер создаётся через оператор Altinity (`https://github.com/Altinity/clickhouse-operator`) в ресурсной платформе типа `k8s`

Кластеры могут создаваться шардированными с произвольным количеством реплик. Для обеспечения отказоустойчивости и координации обязательно включение параметра `Создать ClickHouse-Keeper`.

Кластера создаются с обязательным бекапом. Для этого необходимо предварительно создать услугу `S3 Object Storage`.
---

## Доступные операции

* **create** — Создание кластера
* **delete** — Удаление кластера
* **suspend** — Приостановка работы кластера
* **resume** — Возобновление работы кластера
* **create_database** — Создание базы данных
* **delete_database** — Удаление базы данных
* **create_user** — Создание пользователя и настройка прав доступа к базе данных
* **delete_user** — Удаление пользователя

## Процесс создания и первого подключения

1. Выполнить операцию `create` - будет создан кластер
2. Выполнить операцию `create_database` - будет создана база данных
3. Выполнить операцию `create_user` - будет создан пользователь (выбрать доступ подключения к БД, созданной в п.2)

## Примеры подключения

### Подключение через HTTP-интерфейс

```bash
# Проверка доступности кластера
curl "http://<username>:<password>@<host>:8123/" -d "SELECT 1"

# Выполнение запроса
curl "http://<username>:<password>@<host>:8123/" -d "SHOW DATABASES"
```

## Простые кейсы
*Создать БД и обычной таблицы*
```clickhouse
CREATE TABLE database.table
(
id UInt64,
value String
)
ENGINE = MergeTree()
ORDER BY id;

INSERT INTO database.table SELECT number, toString(number) FROM numbers(10000000);

SELECT count() FROM database.table;
```
Базы и таблицы не реплицируются без специального ключа (`ON CLUSTER 'clickhousek8s'`)
В случае создания в стиле, описанным выше, необходимо будет выполнять действия на всех шардах/репликах


*Создание БД и шардированной таблицы*
```clickhouse
CREATE TABLE database.localUsers
(
id UInt64,
value String
)
ENGINE = MergeTree()
ORDER BY id;

CREATE TABLE database.shardedTable
AS localUsers
ENGINE = Distributed('clickhousek8s', 'database', 'localUsers', id);

INSERT INTO shardedTable SELECT number, toString(number) FROM numbers(10000000);

SELECT count() FROM shardedTable;
```
Важный момент - в данном примере таблицы придётся также выполнять на всех нодах ClickHouse

*Создание реплицируемой и шардированной таблицы*
```clickhouse
CREATE TABLE IF NOT EXISTS database.localUsers ON cluster 'clickhousek8s'
(
user_id UInt64,
name String,
email String
)
ENGINE = ReplicatedMergeTree(
'/clickhouse/tables/{shard}/localUsers',
'{replica}'
)
ORDER BY user_id;

CREATE TABLE IF NOT EXISTS database.users ON cluster 'clickhousek8s'
AS database.localUsers
ENGINE = Distributed('clickhousek8s', 'database', 'localUsers', rand());

INSERT INTO database.users VALUES (1, 'Alice', 'alice@example.com');
INSERT INTO database.users VALUES (2, 'Bob', 'bob@example.com');
INSERT INTO database.users VALUES (3, 'Charlie', 'charlie@example.com');
INSERT INTO database.users VALUES (4, 'David', 'david@example.com');
INSERT INTO database.users VALUES (5, 'Gosha', 'gosha@example.com');
INSERT INTO database.users VALUES (6, 'Eve', 'eve@example.com');
INSERT INTO database.users VALUES (7, 'Frank', 'frank@example.com');
INSERT INTO database.users VALUES (8, 'Grace', 'grace@example.com');
INSERT INTO database.users VALUES (9, 'Hannah', 'hannah@example.com');
INSERT INTO database.users VALUES (10, 'Ivan', 'ivan@example.com');

SELECT * FROM database.localUsers;
SELECT * FROM database.users;

SELECT COUNT(*) AS total_users FROM database.users;
SELECT COUNT(*) AS total_users_local FROM database.localUsers;
```","dtUpdated":"2026-01-30T11:43:54.356+0300","version":"0.0.3","sort":null,"dtCreated":"2025-11-13T13:14:48.087+0300","svcShort":"clickhouse","resourceRealmTypeId":3,"code":null,"orchestratorName":"ClickHouse","svcId":120,"svc":"ClickHouse","svcExtendedName":"ClickHouse clickhouse ","descr":"Управляемая база данных ClickHouse","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по развертыванию Apache Kafka через платформу

---
## 1. Общая информация
AKHQ — это веб-интерфейс для работы с кластерами Apache Kafka.
Позволяет просматривать топики, consumer group'ы, сообщения, ACL, схему безопасности, состояние брокеров и метаданные.
Поддерживает работу с несколькими кластерами Kafka одновременно, включая внутренние и внешние (TLS) подключения.

## Доступные операции
- **create** — Создание ресурса AKHQ
- **suspend** — Остановка ресурса
- **resume** — Возобновление работы ресурса
- **modify** — Модификация настроек
- **delete** — Удаление ресурса

---
## 2. Параметры развертывания кластера
Ниже перечислены параметры, которые необходимо указать при создании ресурса Kafka:

* **Кластер Kafka к которому нужно настроить подключение**
Для использования AKHQ необходимо предварительно развернуть сервис Kafka. Пример: `kafka-external`.

* **Платформа для развертывания:**
Платформа для развертывания (среда, в которой будет развернут кластер). Пример: `k8s-3.ext.nubes.ru`.

* **Количество узлов в сети:**
Количество узлов для работы Akhq. Пример `2`

* **Квота (megabyte) памяти пода:**
Квота памяти пода в мегабайтах. Пример: `512`

* **Квота (millicore ) ядра пода:**
Квота CPU пода в миликорах (1000 mCPU = 1 vCPU). Пример: `500`

* **Имя домена:**
Dns Имя по которому будет происходить обращение к сервису. Указать необходимо уникальное имя до . ( то есть если необходимо получить dns ui) Пример: `kafka-external`

---
## 4. Выходные параметры
После успешного развертывания ресурса Kafka платформа возвращает ряд параметров.
Ключевым из них является:

**connectionUrl**
Это полное доменное имя (FQDN), по которому будет доступен интерфейс AKHQ. После развёртывания подождите несколько минут — UI станет доступен по HTTPS, как только все сервисы полностью запустятся.

**Учетные данные**
Здесь представлены поля для ввода логина и пароля, с помощью которых вы сможете авторизоваться в сервисе.

---
## 5. Дополнительная информация
Учётные записи, созданные для работы с AKHQ, предоставляют доступ только для просмотра данных. Для управления Kafka, пожалуйста, используйте возможности личного кабинета.
---","dtUpdated":"2026-01-30T11:45:52.760+0300","version":"0.0.1","sort":null,"dtCreated":"2025-10-22T15:24:29.300+0300","svcShort":"Akhq","resourceRealmTypeId":3,"code":null,"orchestratorName":"Akhq","svcId":119,"svc":"Akhq","svcExtendedName":"Akhq Akhq ","descr":"Web интерфейс Akhq для работы с Apache Kafka","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":false,"man":null,"dtUpdated":"2026-01-30T11:57:58.051+0300","version":"null","sort":null,"dtCreated":"2025-12-22T18:06:47.213+0300","svcShort":"nifi","resourceRealmTypeId":3,"code":null,"orchestratorName":"Nifi","svcId":117,"svc":"Nifi","svcExtendedName":"Nifi nifi ","descr":"ETL-система","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по развертыванию Организация в Cloud Director через платформу

## 1. Общая информация

Сервис **Организация в Cloud Director** предназначен для управления изолированными организационными единицами (тенантами) внутри инфраструктуры Cloud Director. Он обеспечивает создание, модификацию и администрирование организаций, предоставление им облачных ресурсов, а также контроль уровня доступа. Сервис используется для организации рабочих окружений потребителей, управления жизненным циклом организаций и их сетевой инфраструктурой.

### Доступные операции

**create** — Создание новой организации с заданными характеристиками и типом доступа.
**delete** — Удаление организации после перехода в состояние suspend; приводит к удалению всех связанных сущностей.
**modify** — Изменение параметров внутри организации (на текущий момент поддерживается изменение ipSpaces).
**suspend** — Заморозка организации, ограничение возможности изменения и добавления сущностей.
**resume** — Разморозка организации, восстановление функций управления.

---

## 2. Параметры развертывания

Параметры входных данных операции **create**:

### Платформа для развертывания
Описание: платформа, на которой будет создана организация.
Пример значения: `ngcloud.ru`.

### Тип организации
Описание: тип создаваемой организации.
Варианты:
- `iaas` — организация с прямым доступом к облаку и авторизацией через Keycloak.
- `saas` — организация под управлением Nubes, без прямого доступа к облаку.

---

## 3. Рекомендованные характеристики

### Тестовое окружение
- Тип организации: `iaas` для обеспечения полного доступа к облачным ресурсам и самостоятельного управления.

### Промышленное окружение
- Тип организации: `saas` (упрощённая модель использования).

---

## 4. Выходные параметры

Услуга предоставляет следующие выходные параметры (формируются в процессе операций):

### Идентификатор организации
Уникальный идентификатор созданной организации. Используется для всех дальнейших API-операций.

### Статус организации
Отражает текущее состояние: active, suspended, modifying, deleting.

### Параметры сети
Выходные данные при наличии ipSpaces: список выделенных внешних IP, имя ip_space, количество IP.

---

## 5. Дополнительная информация

- Для конфигурации внешних IP внутри организации необходимо предварительно создать:
-- хотя бы один **Виртуальный датацентр (VDC)**;
-- хотя бы один **Сетевой шлюз периметра (Edge Gateway)**, привязанный к этой организации.
- Удаление организации возможно только через 14 дней после перевода в состояние suspend.
- Удаление организации приводит к удалению всех связанных сущностей: VDC, Edge Gateway, пулов IP, сетей, ВМок и иных объектов.
- Операция suspend не останавливает виртуальные машины, но блокирует любые изменения инфраструктуры внутри организации.
- Для iaas-организаций доступ осуществляется через Keycloak.

","dtUpdated":"2025-12-10T18:40:39.469+0300","version":"0.0.2","sort":null,"dtCreated":"2025-09-22T14:07:55.358+0300","svcShort":"kafka","resourceRealmTypeId":3,"code":null,"orchestratorName":"Kafka","svcId":116,"svc":"ApacheKafka","svcExtendedName":"ApacheKafka kafka ","descr":"Сервис создания кластера Apache Kafka","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"Mariadb кластер. Ресурсная платформа: k8s. Автоматизация: k8s operator.
Автоматическое резервное копирование, ручное восстановление.","dtUpdated":"2025-11-11T13:37:48.549+0300","version":"0.0.4","sort":null,"dtCreated":"2025-08-26T12:01:05.074+0300","svcShort":"mariadb","resourceRealmTypeId":3,"code":"paas.mysql.0","orchestratorName":"Mariadb","svcId":115,"svc":"Mariadb","svcExtendedName":"Mariadb mariadb ","descr":"Управляемая база данных MySQL","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":false,"man":"Комплексная услуга из ["gittea", "postgres"]","dtUpdated":"2025-08-28T12:12:50.814+0300","version":"master","sort":null,"dtCreated":"2025-06-30T12:16:12.397+0300","svcShort":"GiteaComplex","resourceRealmTypeId":3,"code":null,"orchestratorName":"GiteaComplex","svcId":114,"svc":"Комплексная услуга по созданию gitea","svcExtendedName":"Комплексная услуга по созданию gitea GiteaComplex ","descr":"Комплексная услуга из ["gittea", "postgres"]","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"# Создает окружение для ВМ.

## Создает сервисы:
**Организация**
**Виртуальный датацентр**
**Каталог**
**Сетевой шлюз периметра**

","dtUpdated":"2025-10-03T01:32:21.969+0300","version":"0.0.5","sort":null,"dtCreated":"2025-05-28T11:58:56.391+0300","svcShort":"vcComplex","resourceRealmTypeId":2,"code":null,"orchestratorName":"Complex","svcId":113,"svc":"Быстрый старт","svcExtendedName":"Быстрый старт vcComplex ","descr":"Создание готовой иерархии ресурсов","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":false,"man":null,"dtUpdated":"2026-01-30T13:19:30.993+0300","version":"0.0.7","sort":null,"dtCreated":"2025-03-10T17:47:48.965+0300","svcShort":"tenant","resourceRealmTypeId":6,"code":null,"orchestratorName":"Tenant","svcId":112,"svc":"Тенант в Grafana","svcExtendedName":"Тенант в Grafana tenant ","descr":"Организация доступов к мониторингу","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"Необходима DNS зона, в которой будет создана DNS запись.
Выберите
* Зону в которой будет создана запись
* Тип записи
* Имя записи
* Значение записи","dtUpdated":"2025-08-26T15:54:29.943+0300","version":"1.0.4","sort":null,"dtCreated":"2024-09-17T14:22:05.028+0300","svcShort":"dnsrecord","resourceRealmTypeId":4,"code":null,"orchestratorName":"Records","svcId":111,"svc":"DNS запись","svcExtendedName":"DNS запись dnsrecord ","descr":"DNS запись в созданной зоне","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"## ![image](https://img.icons8.com/?size=50&id=41400&format=png) Необходимо делегировать зону у регистратора на Авторитетные DNS (Сервера зон)

ns3.ngcloud.ru
``` bash
185.247.187.83
2a10:aa80:0:5::3
```

ns4.ngcloud.ru
``` bash
81.22.46.43
2a10:aa80:0:6::3
```","dtUpdated":"2025-08-26T15:33:39.433+0300","version":"1.0.3","sort":null,"dtCreated":"2024-09-16T18:52:52.357+0300","svcShort":"dnszone","resourceRealmTypeId":4,"code":null,"orchestratorName":"Zones","svcId":110,"svc":"DNS зона","svcExtendedName":"DNS зона dnszone ","descr":"Хостинг DNS зоны","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":false,"man":"Добавление и удаления пользователя для работы с Openwhisk","dtUpdated":"2025-04-21T09:24:43.629+0300","version":null,"sort":null,"dtCreated":"2024-09-12T13:43:55.166+0300","svcShort":"openwhisk","resourceRealmTypeId":5,"code":null,"orchestratorName":"OpenWhisk","svcId":100,"svc":"Serverless Openwhisk (openwhisk_tenant)","svcExtendedName":"Serverless Openwhisk (openwhisk_tenant) openwhisk ","descr":"Добавление и удаления пользователя (учетной записи, тенанта) для работы с Openwhisk","resourceRealmCnt":0,"synonyms":null},{"isProductionReady":true,"man":"Развертывает сервис контроля версий Gitea
Зависит от сервиса postgresql который должен быть развернут заранее.","dtUpdated":"2025-08-26T17:02:22.326+0300","version":"0.0.7","sort":null,"dtCreated":"2025-02-27T11:03:34.956+0300","svcShort":"gitea","resourceRealmTypeId":3,"code":null,"orchestratorName":"Gitea","svcId":99,"svc":"Gitea","svcExtendedName":"Gitea gitea ","descr":"Сервис контроля версий","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":null,"dtUpdated":"2025-08-26T17:02:46.347+0300","version":"0.0.8","sort":null,"dtCreated":"2025-02-25T11:39:20.377+0300","svcShort":"http","resourceRealmTypeId":3,"code":null,"orchestratorName":"SimpleHttpContainer","svcId":98,"svc":"Простой HTTP контейнер","svcExtendedName":"Простой HTTP контейнер http ","descr":null,"resourceRealmCnt":2,"synonyms":null},{"isProductionReady":false,"man":null,"dtUpdated":"2025-12-08T11:45:13.148+0300","version":"0.0.1","sort":null,"dtCreated":"2025-02-05T14:31:17.734+0300","svcShort":"nodered","resourceRealmTypeId":3,"code":null,"orchestratorName":"NodeRed","svcId":97,"svc":"NodeRed","svcExtendedName":"NodeRed nodered ","descr":"Low-code платформа для IoT","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"Инструмент с графическим интерфейсом для управления базами данных PostgreSQL","dtUpdated":"2025-10-03T01:45:00.194+0300","version":"0.0.7","sort":null,"dtCreated":"2024-11-26T15:31:21.713+0300","svcShort":"pgadmin","resourceRealmTypeId":3,"code":null,"orchestratorName":"Pgadmin","svcId":96,"svc":"pgAdmin","svcExtendedName":"pgAdmin pgadmin ","descr":"Административный интерфейс для PostgreSQL","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"Развертывает контейнер в k8s кластере с node:16, который можно использовать для запуска своих приложений.
Приложение должно быть доступно по ссылки в git репозитории, которая является входным параметром.","dtUpdated":"2025-08-26T15:24:09.869+0300","version":"0.0.10","sort":null,"dtCreated":"2024-11-22T10:47:39.940+0300","svcShort":"nodejs","resourceRealmTypeId":3,"code":null,"orchestratorName":"Node","svcId":95,"svc":"NodeJS","svcExtendedName":"NodeJS nodejs ","descr":"Cервер приложений","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":null,"dtUpdated":"2025-08-26T15:23:32.093+0300","version":"0.0.15","sort":null,"dtCreated":"2024-10-02T17:33:55.713+0300","svcShort":"lucee","resourceRealmTypeId":3,"code":null,"orchestratorName":"Lucee","svcId":94,"svc":"Lucee","svcExtendedName":"Lucee lucee ","descr":"Cервер приложений","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по развертыванию RabbitMQ через платформу

---
## 1. Общая информация
RabbitMQ — это брокер сообщений с открытым исходным кодом, который обеспечивает надёжную доставку сообщений между приложениями.
Позволяет надёжно передавать сообщения между сервисами и системами, интегрироваться с внешними решениями и использовать как внутренние, так и защищённые TLS-подключения. Развёртывание полностью автоматизировано платформой.

## Доступные операции
- **create** — Создание кластера
- **modify** — Изменение параметров кластера
- **delete** — Удаление кластера
- **suspend** — Остановка кластера
- **resume** — Запуск кластера


---
## 2. Параметры развертывания кластера
Ниже перечислены параметры, которые необходимо указать при создании ресурса RabbitMQ:

* **Платформа для развертывания:**
Платформа для развертывания (среда, в которой будет развернут кластер). Пример: `k8s-3.ext.nubes.ru`.

* **Количество узлов в сети:**
Количество реплик в кластере RabbitMQ. При значении больше 1 создаётся кластер с репликацией для обеспечения высокой доступности.
Пример: `1` (single node) или `3` (кластер с репликацией)

* **Квота (megabyte) памяти пода:**
Квота памяти пода в мегабайтах. Пример: `2048`

* **Квота (millicore) ядра пода:**
Квота CPU пода в миликорах (1000 mCPU = 1 vCPU). Пример: `1000`

* **Размер (gigabyte) диска для базы данных:**
Размер диска для хранения данных RabbitMQ в гигабайтах. Пример: `50`

* **Выделяет внешний IP для доступа к master:**
Флаг, определяющий, нужно ли выделять внешний IP для доступа к master-узлу. При включении создаётся Service типа LoadBalancer и настраивается VIP на порту 5672.
Пример: `true` или `false`

* **Имя ipSpace для публикации внешнего IP master:**
Если включен предыдущий параметр, то поле необходимо заполнить. Пример: `internet-no-antiddos-v1`


---
## 3. Модификация кластера
Операция **modify** позволяет изменить параметры существующего кластера RabbitMQ.

**Изменяемые параметры:**
* **Квота (megabyte) памяти пода** — можно увеличить или уменьшить
* **Квота (millicore) ядра пода** — можно увеличить или уменьшить
* **Размер (gigabyte) диска для базы данных** — можно только увеличить (уменьшение не поддерживается)
* **Количество узлов в сети** — можно только увеличить (уменьшение не поддерживается)
* **Выделяет внешний IP для доступа к master** — можно включить или выключить
* **Имя ipSpace для публикации внешнего IP master** — можно изменить при включенном внешнем IP

**Ограничения:**
* Уменьшение размера диска не поддерживается
* Уменьшение количества реплик не поддерживается

---
## 4. Выходные параметры
После успешного развертывания ресурса RabbitMQ платформа возвращает ряд параметров в разделе "Поля состояния (сгенерированные значения)":

**clusterDomain**
Домен кластера Kubernetes, в котором развернут RabbitMQ.
Пример: `cluster.local`

**inernalConnect**
Внутренние эндпоинты для подключения к кластеру RabbitMQ из других сервисов, развернутых внутри той же сети.
Содержит:
* **master** — FQDN для подключения к кластеру. Пример: `rabbitmqk8s.{namespace}.svc.{clusterDomain}`

**externalConnect**
Внешние эндпоинты для подключения к кластеру RabbitMQ извне.
Содержит объекты с полями:
* **admin** — доступ к веб-интерфейсу управления:
* `fqdn` — полное доменное имя для веб-интерфейса. Пример: `admin.{namespace}.{domain}`
* `ip` — IP-адрес
* **master** — доступ к AMQP протоколу (если включен внешний IP):
* `fqdn` — полное доменное имя для AMQP. Пример: `write.{namespace}.{domain}`
* `ip` — IP-адрес

**monitoring**
Информация о дашбордах мониторинга:
* **allDashboards** — URL для доступа ко всем дашбордам Grafana

Для подключения используются следующие порты:

* **5672** — AMQP протокол (внутренний и внешний доступ, если включен внешний IP)
* **15672** — веб-интерфейс управления (доступен через Ingress по HTTPS)

**Учётные данные**
Данные для подключения к RabbitMQ доступны в разделе "Учётные данные":
* **adminUser** — имя пользователя администратора
* **adminPass** — пароль администратора


---
## 5. Примеры подключения через консоль (rabbitmqadmin)

### Работа с очередями через rabbitmqadmin

Для работы с RabbitMQ через консоль используется утилита `rabbitmqadmin`. Установите её или используйте образ с предустановленной утилитой.

Задайте переменные окружения с учётными данными:

```bash
export RABBITMQ_USERNAME=<adminUser>
export RABBITMQ_PASSWORD=<adminPass>
export RABBIT_HOST=<host>
```

Где:
* `<adminUser>` и `<adminPass>` — учётные данные из раздела "Учётные данные"
* `<host>` — адрес для подключения:
* Для внешнего подключения: `externalConnect.admin.fqdn` или `externalConnect.master.fqdn`

**Создание очереди:**

```bash
rabbitmqadmin \\
-H "$RABBIT_HOST" -P 15672 \\
-u "$RABBITMQ_USERNAME" -p "$RABBITMQ_PASSWORD" \\
declare queue name=healthcheck durable=false
```

**Листинг очередей:**

```bash
rabbitmqadmin \\
-H "$RABBIT_HOST" -P 15672 \\
-u "$RABBITMQ_USERNAME" -p "$RABBITMQ_PASSWORD" \\
list queues
```

**Отправка сообщения:**

```bash
rabbitmqadmin \\
-H "$RABBIT_HOST" -P 15672 \\
-u "$RABBITMQ_USERNAME" -p "$RABBITMQ_PASSWORD" \\
publish exchange=amq.default routing_key=healthcheck payload="ping-$(date +%s)"
```

**Чтение сообщения:**

```bash
rabbitmqadmin \\
-H "$RABBIT_HOST" -P 15672 \\
-u "$RABBITMQ_USERNAME" -p "$RABBITMQ_PASSWORD" \\
get queue=healthcheck ackmode=ack_requeue_false
```

---
## 6. Дополнительная информация
Для подключения к кластеру RabbitMQ используйте полученные эндпоинты из раздела "Поля состояния (сгенерированные значения)" и учётные данные из раздела "Учётные данные".

Веб-интерфейс управления доступен по адресу, указанному в параметре `externalConnect.admin.fqdn` через HTTPS.

Для внутреннего подключения используйте адрес из `inernalConnect.master` на порту 5672.

---","dtUpdated":"2025-12-23T17:55:10.562+0300","version":"1.0.0","sort":null,"dtCreated":"2024-09-06T18:34:29.581+0300","svcShort":"rabbitmq","resourceRealmTypeId":3,"code":null,"orchestratorName":"Rabbitmq","svcId":93,"svc":"RabbitMQ","svcExtendedName":"RabbitMQ rabbitmq ","descr":"Сервер очередей","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по развертыванию MongoDB через платформу

## 1. Общая информация

MongoDB — документоориентированная распределённая база данных, обеспечивающая высокую доступность, горизонтальное масштабирование и гибкую модель хранения.

### Доступные операции с кластером

* **create** — создание кластера
* **delete** — удаление кластера
* **suspend** — остановка кластера
* **resume** — запуск кластера

### Доступные операции с пользователями

* **create_user** — создание пользователя
* **modify_user** — модификация пользователя
* **delete_user** — удаление пользователя

---

## 2. Параметры развертывания кластера

Эти параметры необходимо указать при создании кластера.

### Платформа для развертывания

Среда, в которой будет развернут кластер. Выбирается из предложенного списка.

### Количество узлов

Оптимально выбирать нечётное количество узлов в кластере MongoDB, так как это обеспечивает корректный процесс выбора ведущего узла и сохраняет работоспособность кластера даже при отказе одной из нод.

### Квота памяти пода (MB)

Объём памяти для одного пода в мегабайтах.
Пример: `2048`

### Квота CPU пода (mCPU)

CPU-квота для пода в миликорах (1000 mCPU = 1 vCPU).
Пример: `1000`

### Размер диска для базы данных (GB)

Объём диска для хранения данных в гигабайтах.
Пример: `20`

### Выделение внешнего IP для доступа к master

Флаг, определяющий необходимость выделения внешнего IP.
Допустимые значения: `true` или `false`

### Имя ipSpace для публикации внешнего IP

Указывается только если включено выделение внешнего IP.
Пример: `internet-no-antiddos-v1`

---

## 3. Параметры создания пользователя

### Имя пользователя

Указывается имя пользователя (любой регистр).
Пример: `readApp`

### Имя базы данных

Название базы, в которой будет создан пользователь.
Если база отсутствует, она будет создана автоматически.
Пример: `Apps_9392`

### Имя роли

* **read** — доступ только на чтение
* **readWrite** — чтение и изменение данных
* **dbOwner** — полный контроль над базой данных

---

## 4. Параметры модификации пользователя

### Имя пользователя

Необходимо выбрать существующего пользователя.
Если список пуст, требуется предварительно создать пользователя.

### Имя базы данных

База данных, к которой относится пользователь.
Если база отсутствует, она будет создана автоматически.
Пример: `Apps_9392`

### Имя роли

* **read**
* **readWrite**
* **dbOwner**

---

## 5. Параметры удаления пользователя

### Имя пользователя

Выберите пользователя из списка для удаления.

---

## 6. Выходные параметры

После успешного развертывания кластера платформа возвращает ключевые параметры.

### internalConnect

Внутренний эндпоинт для подключения к кластеру из сервисов, работающих в той же сети.
Используется для внутренних интеграций и системных сервисов.

### externalConnect

Внешний FQDN для подключения к кластеру извне.
Порт подключения — **27017**.

---

## 7. Пример подключения

Перед подключением необходимо установить **mongosh**.

Команда подключения к базе данных:

```
mongosh "mongodb://<username>:<password>@<endpoint_db>:27017/<dbName>"
```

Где:

* `<username>` — имя пользователя
* `<password>` — пароль
* `<endpoint_db>` — internalConnect или externalConnect
* `<dbName>` — имя базы данных

## 8. Дополнительная информация

Сейчас соединение с базой работает без шифрования, и доступ к кластеру осуществляется только по логину и паролю.","dtUpdated":"2025-12-01T12:55:06.401+0300","version":"0.0.1","sort":null,"dtCreated":"2024-09-06T12:20:04.433+0300","svcShort":"mongodb","resourceRealmTypeId":3,"code":"paas.mngo.0","orchestratorName":"MongoDB","svcId":92,"svc":"MongoDB","svcExtendedName":"MongoDB mongodb ","descr":"Управляемая база данных MongoDB","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"Redis кластер. Под кеши.
Ресурсная платформа: k8s.
Автоматизация: k8s operator
port 6379","dtUpdated":"2025-08-26T15:40:53.298+0300","version":"0.0.10","sort":null,"dtCreated":"2024-07-09T18:41:46.951+0300","svcShort":"redis","resourceRealmTypeId":3,"code":null,"orchestratorName":"Redis","svcId":91,"svc":"Redis","svcExtendedName":"Redis redis ","descr":"Управляемая база данных Redis","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"PostgreSQL кластер. Ресурсная платформа: k8s. Автоматизация: k8s operator.
Автоматическое резервное копирование, ручное восстановление.","dtUpdated":"2025-09-24T13:18:13.443+0300","version":"0.0.10","sort":null,"dtCreated":"2024-07-08T11:22:38.929+0300","svcShort":"postgres","resourceRealmTypeId":3,"code":"paas.pg.0","orchestratorName":"Pgsql","svcId":90,"svc":"PostgreSQL","svcExtendedName":"PostgreSQL postgres ","descr":"Управляемая база данных PostgreSQL","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":null,"dtUpdated":"2025-09-29T17:22:19.947+0300","version":"0.0.4","sort":null,"dtCreated":"2025-04-03T13:26:27.166+0300","svcShort":"flask","resourceRealmTypeId":3,"code":"paas.flsk.0","orchestratorName":"Flask","svcId":89,"svc":"Flask","svcExtendedName":"Flask flask ","descr":null,"resourceRealmCnt":2,"synonyms":null},{"isProductionReady":false,"man":null,"dtUpdated":"2025-07-08T17:38:52.475+0300","version":"0.0.1","sort":null,"dtCreated":"2025-05-14T16:06:14.130+0300","svcShort":"k8sZitiController","resourceRealmTypeId":3,"code":null,"orchestratorName":"ZitiController","svcId":88,"svc":"k8sZitiController","svcExtendedName":"k8sZitiController k8sZitiController ","descr":"Контроллер управления Client to service & service to service Zero trust network
","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"Расширенный реестр для управления Docker-образами с enterprise-функциями безопасности, репликации и контроля доступа.
Container Registry позволяет хранить, развертывать и управлять Docker-образами контейнеров в инфраструктуре NGcloud.
Сервис можно использовать в процессе разработки контейнеров. Например, вы можете настроить систему автоматизации на работу с реестром в Container Registry в рамках процесса непрерывной интеграции. Контейнеры идеально подходят для запуска небольших, независимых и легко изменяемых сервисов.
Чтобы начать работу, создайте реестр и загрузите в него Docker-образ
Авторизация: keycloak","dtUpdated":"2025-10-03T01:41:06.043+0300","version":"0.0.9","sort":null,"dtCreated":"2024-06-19T23:07:00.955+0300","svcShort":"harbor","resourceRealmTypeId":3,"code":null,"orchestratorName":"Registry","svcId":82,"svc":"Container Registry","svcExtendedName":"Container Registry harbor ","descr":"Реестр для хранения образов контейнеров","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"Apache Superset как сервис.
Ресурсная платформа: k8s.
Автоматизация: k8s operator.
Авторизация: 2FA keycloak (для доменного логина @mgmt.nubes.ru)","dtUpdated":"2025-08-26T15:01:07.187+0300","version":"0.0.8","sort":null,"dtCreated":"2024-06-11T11:05:39.952+0300","svcShort":"superset","resourceRealmTypeId":3,"code":null,"orchestratorName":"Superset","svcId":81,"svc":"Apache Superset","svcExtendedName":"Apache Superset superset ","descr":"Сервер отчетов","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"Ресурсная платформа: k8s. Автоматизация: helm chart","dtUpdated":"2025-08-26T15:02:57.739+0300","version":"0.0.9","sort":null,"dtCreated":"2024-07-04T22:23:46.807+0300","svcShort":"nextcloud","resourceRealmTypeId":3,"code":null,"orchestratorName":"Nextcloud","svcId":50,"svc":"Nextcloud","svcExtendedName":"Nextcloud nextcloud ","descr":"Платформа совместной работы","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":false,"man":"Сервис настройки PostgreSQL для виртуальной машины.
Требует наличия ВМ","dtUpdated":"2025-04-21T09:26:12.228+0300","version":null,"sort":null,"dtCreated":"2024-08-05T12:47:46.275+0300","svcShort":"vmpostgre","resourceRealmTypeId":2,"code":null,"orchestratorName":"VmPostgre","svcId":32,"svc":"vc_vm_postgresql_std","svcExtendedName":"vc_vm_postgresql_std vmpostgre ","descr":"Сервис настройки PostgreSQL для виртуальной машины.
Требует наличия ВМ","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по развертыванию Группа виртуальных датацентров (groupvDC) через платформу

## 1. Общая информация

Сервис **Группа виртуальных датацентров (groupvDC)** предназначен для логического объединения нескольких виртуальных датацентров (vDC) в единую группу. Это позволяет растягивать SDN между разными vDC и централизованно управлять сетевой связностью. Управление инфраструктурой осуществляется через Cloud Director.
Сервис применяется в случаях, когда требуется обеспечить единое сетевое пространство для распределенных виртуальных датацентров.

### Доступные операции

**create** — Создание новой группы виртуальных датацентров.
**delete** — Удаление существующей группы vDC.
**add_vdc** — Добавление дополнительного виртуального датацентра в группу.
**remove_vdc** — Удаление ранее добавленного виртуального датацентра из группы.

---

## 2. Параметры развертывания

Параметры операции **create**:

### UUID VDC
Идентификатор виртуального датацентра, который будет являться первичным в группе.
Описание: требуется предварительно создать виртуальный датацентр с помощью услуги `Виртуальный датацентр (vDC)`.

---

## 3. Рекомендованные характеристики

### Тестовое окружение
- Для тестового окружения создавать группу VDC нет необходимости, так как это создаёт дополнительный слой абстракции в Cloud Director

### Промышленное окружение
- Для промышленного окружения, если в тенанте существуют разные VDC, создание groupvdc позволит снизить сетевые издержки (за счёт общей сети)

---

## 4. Выходные параметры

### Параметры groupvdc
Набор параметров, указанных пользователем при создании

---

## 5. Дополнительная информация
- Группа vDC используется для растягивания SDN между несколькими виртуальными датацентрами. Управление всеми сетевыми элементами внутри группы выполняется Cloud Director.
- Удалить первичный vDC (тот, что был указан при операции **create**) невозможно. Для его удаления необходимо удалить всю группу и создать новую.
- Перед добавлением любого vDC в группу он должен быть предварительно развернут через сервис `Виртуальный датацентр (vDC)`.
- Сети создаются не в этой услуге, а в услуге `Сетевой шлюз периметра (Edge)`. В ней необходимо будет выбрать тип `groupvdc`","dtUpdated":"2026-01-30T12:32:41.484+0300","version":"0.0.1","sort":null,"dtCreated":"2025-11-18T13:43:02.522+0300","svcShort":"vcVdcGroup","resourceRealmTypeId":2,"code":null,"orchestratorName":"VdcGroup","svcId":29,"svc":"Группа датацентров","svcExtendedName":"Группа датацентров vcVdcGroup ","descr":"Обеспечение сетевой доступности между датацентрами","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"# Документация по разворачиванию ВМ в Личном кабинете

Данный сервис позволяет разворачивать виртуальные машины (ВМ) непосредственно из Личного кабинета (ЛК).

## Доступные операции
- **create** — Создание виртуальной машины
- **delete** — Удаление виртуальной машины
- **resume** — Запуск виртуальной машины
- **suspend** — Остановка виртуальной машины

---

## Параметры для развёртывания ВМ

### Обязательные параметры:

**vappUid**
UUID vApp, в которой будет создаваться виртуальная машина.
*Пример: WZ00400*

**vmName**
Уникальное имя виртуальной машины.
*Пример: test-vm*

**vmCpu**
Количество ядер процессора. Должно быть больше 0.
*Пример: 1*

**vmRam**
Объем оперативной памяти в гигабайтах. Должно быть больше 0.
*Пример: 1*


**accessPortList**
Белый список портов для доступа к виртуальной машине извне в формате JSON.
*Пример: ["22","10050]*

**imageVm**
Образ операционной системы для развёртывания.
*Доступные значения: RockyLinux_9-16G-cloudinit, Ubuntu_22-20G, Debian_13-20G*

**userLogin**
Логин пользователя для SSH-доступа.
*По умолчанию: myuser*

**userPublicKey**
Публичный SSH-ключ пользователя в формате OpenSSH.
*Пример: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC...*

**needAddZabbixTemplate**
Значение истина\\ложь, определяющее будет ли добавлен хост в zabbix.
*Пример: true*

### Опциональные параметры:

**accessIpList**
Белый список IP-адресов для доступа к виртуальной машине в формате JSON. Если оставить пустым, вм будет доступена со всех адресов.
*Пример: ["192.168.1.1", "10.0.0.5"]*

**vmDisk**
Размер дополнительного диска в гигабайтах. Должно быть больше 0.
*Пример: 50*

**ipSpaceName**
Внешний IP-адрес. Если не требуется — укажите значение "no-needed" (В нем не будет работать сбор метрик zabbix).

**cloudInit**
YAML-скрипт для кастомизации системы через cloud-init.

---

## Доступные образы операционных систем

### RockyLinux_9-16G-cloudinit
Базовый образ на основе Rocky Linux 9 (Rocky-9.6-x86_64-minimal) для серверных задач.
- Размер системного диска: 16 ГБ
- Поддержка cloud-init
- Минимальные требования: 1 vCPU, 1 ГБ RAM

### Ubuntu_22-20G
Образ на основе Ubuntu Server 22.04 LTS для облачного окружения.
- Размер системного диска: 20 ГБ
- Поддержка cloud-init
- Минимальные требования: 1 vCPU, 1 ГБ RAM

### Debian_13-20G
Образ на основе Debian 13 (Trixie) для стабильной серверной работы.
- Размер системного диска: 20 ГБ
- Поддержка cloud-init
- Минимальные требования: 1 vCPU, 1 ГБ RAM

---

## Пример использования Cloud-init

```yaml
users:
- name: myuser
sudo: ALL=(ALL) NOPASSWD:ALL
groups: sudo
shell: /bin/bash
ssh-authorized-keys:
- ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC...

packages:
- nginx

runcmd:
- systemctl enable --now nginx
- systemctl restart nginx
```

---

## Результат выполнения

После успешного развёртывания в Личном кабинете появится новая виртуальная машина с заданными характеристиками. SSH-доступ осуществляется с использованием указанных логина и публичного ключа.

---

## Важные примечания

### Особенности работы:
- Для отключения внешнего IP укажите значение "no-needed" в параметре ipSpaceName
- Параметр cloudInit является опциональным

### Ограничения:
- Не рекомендуется использовать продолжительные инструкции в cloud-init (таймаут 5 минут)
- Не поддерживается запись файлов через cloud-init
- Не реализовано создание резервных копий ВМ

---

## Диагностика проблем с cloud-init

### Основной лог-файл:
```bash
/var/log/cloud-init.log
```

### Пакет не устанавливается:
- Проверьте доступность пакета в репозитории
- Убедитесь в правильности написания имени пакета
- Попробуйте установить вручную: `apt install <имя_пакета>` (Debian/Ubuntu) или `dnf install <имя_пакета>` (Rocky Linux)

### Команда не выполняется:
- Проверьте наличие требуемой утилиты в системе
- Убедитесь в правильности синтаксиса команды
- Попробуйте выполнить команду вручную для диагностики","dtUpdated":"2025-12-11T16:03:13.558+0300","version":"0.0.8","sort":260,"dtCreated":"2025-08-08T17:25:18.609+0300","svcShort":"vc_vm_v3","resourceRealmTypeId":2,"code":null,"orchestratorName":"VmV3","svcId":28,"svc":"Виртуальная машина","svcExtendedName":"Виртуальная машина vc_vm_v3 ","descr":"Создание виртуальной машины","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":false,"man":null,"dtUpdated":"2025-08-26T16:18:40.865+0300","version":"0.0.1","sort":240,"dtCreated":"2025-05-14T16:04:15.732+0300","svcShort":"vc_vm_v2","resourceRealmTypeId":2,"code":null,"orchestratorName":"VmV2","svcId":27,"svc":"VM в Cloud Director (vc_vmV2)","svcExtendedName":"VM в Cloud Director (vc_vmV2) vc_vm_v2 ","descr":"Виртуальная машина с возможностью настройки Cloud Init
Можно развернуть в услуге несколько ВМ
В зависимости от настроек можно выдавать на ВМ vip + dns","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по развертыванию Виртуальный каталог ВМ (vApp) через платформу

## 1. Общая информация

**Виртуальный каталог ВМ (vApp)** — это логическая каталог, объединённая в единый управляемый объект. Сервис предназначен для совместного развертывания, управления жизненным циклом и сетевого взаимодействия набора связанных ВМ. Основные функции включают:
- централизованное управление включением, выключением и приостановкой группы ВМ;
- унифицированный сетевой доступ через привязанный Edge-шлюз;

### Доступные операции

**create** — Создание нового vApp с привязкой к заданным услугам VDC и Edge
**delete** — Удаление существующего vApp. Требуется предварительный перевод vApp в состояние *suspend*. Полное удаление возможно через 14 дней.
**suspend** — Остановка vApp (выключение всех ВМ внутри каталога)
**resume** — Запуск vApp (включение всех ВМ внутри каталога)

---

## 2. Параметры развертывания

Параметры берутся из операции **create**.

### UUID Услуги VDC
Уникальный идентификатор услуги «Виртуальный датацентр (vDC)».
Используется для определения вычислительной инфраструктуры, в которой будет развернут vApp.

### UUID Услуги Edge
Уникальный идентификатор услуги «Сетевой шлюз периметра (Edge)».
Обеспечивает маршрутизацию и сетевое подключение. Может быть привязан к группе VDC, поэтому выбирается отдельно.

### Имя vApp
Строгое имя, соответствующее маске формирования DNS-имени.
Используется как часть в DNS-имени при создании виртуальных машин.

---

## 3. Рекомендованные характеристики
Так как является промежуточным этапом для создания ВМ, все характеристики описываются в услуге `Виртуальная машина`

---

## 4. Выходные параметры

### Параметры vApp
Заданные при создании vApp

---

## 5. Дополнительная информация

- Привязка к VDC и Edge осуществляется раздельно, поскольку один Edge может обслуживать несколько VDC (через услугу `Группа виртуальных датацентров (groupvDC)`). Это гарантирует возможность развернуть ВМ в единой сети, но в разных виртуальных датацентрах.
- Перед удалением vApp необходимо вручную перевести его в состояние **suspend**. Удаление возможно только спустя 14 дней после этого шага.
- Операция delete удаляет весь vApp полностью, включая все виртуальные машины, связанные с ним.
- Рекомендуется заранее планировать сетевые связи и назначения DNS-имён для обеспечения корректной работы сервисов, развернутых внутри vApp.
- При проектировании инфраструктуры следует учитывать зависимости между Edge-шлюзом, VDC и размещаемыми ВМ, чтобы избежать ограничений при масштабировании и интеграции.","dtUpdated":"2025-12-11T15:37:54.155+0300","version":"0.0.7","sort":230,"dtCreated":"2024-12-18T13:41:42.516+0300","svcShort":"vapp","resourceRealmTypeId":2,"code":null,"orchestratorName":"Vapp","svcId":26,"svc":"Виртуальный каталог ВМ (vApp)","svcExtendedName":"Виртуальный каталог ВМ (vApp) vapp ","descr":"Группировка виртуальных машин","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"**Обычно эта услуга разворачивается автоматически при развёртывании более сложных услуг**
Вручную создавать можно, однако услуга требует много параметров, о которых можно просто не знать

Резервировать публичные IP адреса можно только после создания NGcloud Organization, NGcloud Виртуальный датацентр, NGcloud Каталог, Сетевой шлюз периметра.
Вы можете назначить IP адрес для существующего экземпляра сервиса.","dtUpdated":"2025-11-21T12:46:13.588+0300","version":"0.0.9","sort":270,"dtCreated":"2024-11-14T17:26:51.661+0300","svcShort":"vcexternalip","resourceRealmTypeId":2,"code":null,"orchestratorName":"Ip","svcId":25,"svc":"Публичные IP адреса","svcExtendedName":"Публичные IP адреса vcexternalip ","descr":"Резервирование IP адреса","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":false,"man":"Правила маршрутизации в Cloud Director. Требует наличие сетей, ВМ
DEPRECATED","dtUpdated":"2024-12-09T14:45:51.227+0300","version":null,"sort":null,"dtCreated":"2024-08-02T16:55:59.120+0300","svcShort":null,"resourceRealmTypeId":null,"code":null,"orchestratorName":null,"svcId":24,"svc":"DEPRECATED Правила маршрутизации для VM (vc_nat)","svcExtendedName":"DEPRECATED Правила маршрутизации для VM (vc_nat) ","descr":"Правила маршрутизации в Cloud Director. Требует наличие сетей, ВМ
DEPRECATED","resourceRealmCnt":0,"synonyms":null},{"isProductionReady":false,"man":"Виртуальная машина без каких-либо особенностей.
Данная услуга запускает vc_ip для выдачи белого адреса и подключения к ВМ","dtUpdated":"2025-08-26T16:15:24.964+0300","version":"0.0.3","sort":250,"dtCreated":"2024-08-02T12:53:13.869+0300","svcShort":"vc_vm","resourceRealmTypeId":2,"code":null,"orchestratorName":"Vm","svcId":23,"svc":"VM в Cloud Director (старый формат) (vc_vm)","svcExtendedName":"VM в Cloud Director (старый формат) (vc_vm) vc_vm ","descr":"Виртуальная машина без каких-либо особенностей.
Данная услуга запускает vc_ip для выдачи белого адреса и подключения к ВМ","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по развертыванию Сетевой шлюз периметра (Edge) через платформу

## 1. Общая информация

Сервис **Сетевой шлюз периметра (Edge)** предназначен для создания периметрового сетевого шлюза в рамках одного виртуального датацентра (vDC) или группы виртуальных датацентров (groupvDC).
При создании автоматически разворачивается routed-сеть с адресным пространством `10.10.102.0/24`.
Сервис обеспечивает сетевую изоляцию, маршрутизацию, а также может включать функциональность балансировщика нагрузки AVI (ALB) для последующей интеграции, включая поддержку кластеров Штурвал.

### Доступные операции

**create** — Создание нового Edge с привязкой к vDC или groupvDC и развёртыванием routed-сети.
**delete** — Удаление ранее созданного Edge. Недоступно при наличии зависимых услуг (vApp, VM, Кластер Штурвал).
**modify** — Изменение параметров и сетевых настроек существующего Edge.

---

## 2. Параметры развертывания

Ниже приведены параметры операции **create**.

### Тип родительской услуги
Определяет контекст размещения Edge.
Допустимые значения: `vdc`, `groupvdc`.
Использование:
- При выборе `vdc` обязателен параметр **UUID VDC**.
- При выборе `groupvdc` обязателен параметр **UUID Группы VDC**.

### UUID VDC
Идентификатор виртуального датацентра.
Необходимо предварительно создать vDC через услугу «Виртуальный датацентр (vDC)».
Указывается только при выборе родительского типа `vdc`.

### UUID Группы VDC
Идентификатор группы виртуальных датацентров.
Создаётся через услугу «Группа виртуальных датацентров (groupvDC)».
Используется при выборе родительского типа `groupvdc`.

### Включить ALB
Флаг активации AVI Load Balancer в Cloud Director.
Пример значения: `true`/`false`.
Нужен для развёртывания кластера Штурвал и возможности создания Virtual Services.

### Наименование segroup
Имя сервиса групп (segroup) на уровне ресурсной платформы.
Обязательно при включённом ALB.
Определяет группу, в которой будут выделяться пулы AVI для Virtual Services.

### virtualServicesCount
Количество резервируемых виртуальных сервисов (Virtual Services) на AVI.
Типичное значение: 1–10 в зависимости от нагрузки.

---

## 3. Рекомендованные характеристики

### Тестовое окружение (Test)

- Тип родительской услуги: `vdc`
- virtualServicesCount: 1–2
- ALB: выключен по умолчанию (включать только при необходимости тестирования Штурвала)
- segroup: задаётся только при включённом ALB
- VDC гарантии CPU/RAM: минимальные (поскольку изменить их после создания нельзя)

### Промышленное окружение (Production)

- Тип родительской услуги: `groupvdc` при работе с распределёнными нагрузками или `vdc` для локализованных проектов
- virtualServicesCount: 3–10, исходя из предполагаемого количества публикаций
- ALB: включён, если требуется высокая доступность сервисов или используется кластер Штурвал
- segroup: рекомендуется использовать выделенную группу под проект
- VDC гарантии CPU/RAM: повышенные, с учётом того, что изменить их после создания невозможно без пересоздания vDC

---

## 4. Выходные параметры

### Имя Edge
Уникальное название созданного сетевого шлюза, используется для ссылок и дальнейших операций.

### Имя routed-сети
Автоматически созданная routed-сеть с подсетью `10.10.102.0/24`, используется для подключения ресурсов.

### Параметры Edge
Набор параметров, указанных пользователем при создании: тип услуги, ALB, segroup, virtualServicesCount и др.
Используются в операциях modify и для анализа состояния сервиса.

---

## 5. Дополнительная информация

- В рамках сервиса **один раз при создании задаётся гарантированная доля CPU/RAM vDC**. Изменить её невозможно — требуется пересоздание vDC.
- Удаление Edge недоступно при наличии зависимых ресурсов (vApp, VM, кластер Штурвал).
- При использовании ALB необходимо корректно указывать segroup, чтобы обеспечить корректное выделение пулов AVI.
- При выборе режима `groupvdc` следует учитывать распределение нагрузки между несколькими vDC.
- SNAT для всех ресурсов внутри Edge можно активировать через операцию modify (параметры выделения VIP и ipSpace).

","dtUpdated":"2025-12-11T12:43:51.799+0300","version":"0.0.8","sort":220,"dtCreated":"2024-05-23T09:31:52.029+0300","svcShort":"vc_nsxt","resourceRealmTypeId":2,"code":null,"orchestratorName":"Nsxt","svcId":22,"svc":"Сетевой шлюз периметра (Edge)","svcExtendedName":"Сетевой шлюз периметра (Edge) vc_nsxt ","descr":"Обеспечение сетевой доступности в тенанте","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по развертыванию Виртуальный датацентр (vDC) через платформу

## 1. Общая информация

**Виртуальный датацентр (vDC)** — это логическая изолированная среда в VMware Cloud Director, предназначенная для размещения и управления виртуальными машинами, сетевой инфраструктурой и ресурсами хранения. Сервис обеспечивает выделение вычислительных ресурсов под конкретную организацию и предоставляет гибкие параметры масштабирования, контроля потребления и разделения ресурсов.

### Доступные операции

**create** — Создание виртуального датацентра (vDC) с заданными параметрами CPU, RAM, Storage и сетевой конфигурацией.
**delete** — Удаление vDC. Требуется предварительный перевод vDC в состояние *suspend*. Полное удаление возможно через 14 дней.
**modify** — Внесение изменений в параметры ресурсов (CPU/RAM/Storage), выделенных для vDC.
**suspend** — Заморозка vDC. Новые ресурсы или изменения внутри vDC становятся невозможными, работа ВМ не останавливается.
**resume** — Разморозка vDC. Восстановление возможности управления сущностями внутри vDC.

---

## 2. Параметры развертывания

Ниже перечислены входные параметры операции **create**.

### UUID Организации
Уникальный идентификатор организации в Cloud Director. Перед созданием vDC требуется предварительное создание услугу `Организация в Cloud Director`.

### Provider VDC в Cloud Director
Свойство ресурсной платформы, определяющее, какой пул физических ресурсов будет использоваться.

### Network Pool в Cloud Director
Определяет возможности сетевой конфигурации, включая создание Edge Gateway и выход во внешние сети.

### Гарантированная доля ЯДЕР
Гарантированный процент вычислительных ресурсов (CPU), закрепленный за vDC. Значение устанавливается разово при создании.

### Гарантированная доля ОЗУ
Гарантированный процент оперативной памяти, закрепленный за vDC. Также задаётся один раз при создании.

### Сколько аллоцировать на VDC ЯДЕР
Количество виртуальных ядер, выделяемых в рамках vDC.

### Сколько аллоцировать на VDC ОЗУ
Количество оперативной памяти в GB, выделяемой для vDC.

### Конфигурация Storage
Набор дисковых политик и их объёмов, выделяемых для vDC. Указывается имя политики и требуемый размер (GB).

---

## 3. Рекомендованные характеристики

### Тестовое окружение

- Количество ядер CPU: 10–20
- Аллоцируемая RAM: 16–64 GB
- Гарантированная доля CPU: 20%
- Гарантированная доля RAM: 20%
- Storage:
- Политика `SATA`
- Объём: 100–300 GB

### Промышленное окружение

- Количество ядер CPU: 16–128
- Аллоцируемая RAM: 64–512 GB
- Гарантированная доля CPU: 50%
- Гарантированная доля RAM: 50%
- Storage:
- Политики уровня `SSD`
- Объём: 1–10 TB, выбор несколько политик под разные нагрузки

---

## 4. Выходные параметры

Выходные параметры формируются операцией **create** (и используются другими операциями):

### Имя vDC
Уникальный идентификатор созданного виртуального датацентра. Используется в дальнейших операциях (modify, suspend, delete).

### Параметры выделенных ресурсов
Подтверждение итогового объёма CPU/RAM/Storage, выделенных vDC. Используются для учёта и контроля потребления.

---

## 5. Дополнительная информация

- Перед созданием vDC необходимо обеспечить наличие корректно созданной организации и доступных пула ресурсов ресурсной платформы.
- Гарантированная доля CPU и RAM задаётся **однократно при создании vDC**. После развертывания изменить эти значения невозможно; для изменения необходимо пересоздать vDC.
- Удаление vDC возможно только через **14 дней после перевода в состояние *suspend***. Удаление очищает все связанные сущности (ВМ, сети, каталоги), поэтому необходимо убедиться, что данные перенесены.
- Платформа Cloud Director использует концепцию изолированных организационных ресурсов, поэтому корректная настройка Provider VDC и Network Pool является обязательной при создании vDC.
- При изменении параметров (modify) изменяются только показатели CPU, RAM и Storage. Сетевые параметры и гарантированные доли остаются неизменными.
","dtUpdated":"2025-12-11T12:33:52.619+0300","version":"1.0.4","sort":210,"dtCreated":"2024-05-23T09:31:52.029+0300","svcShort":"vc_vdc","resourceRealmTypeId":2,"code":null,"orchestratorName":"Vdc","svcId":21,"svc":"Виртуальный датацентр (vDC)","svcExtendedName":"Виртуальный датацентр (vDC) vc_vdc ","descr":"Выделение вычислительных ресурсов","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":false,"man":"Верхнеуровневая сущность создаваемая для Заказчика.
Внутри Организация вы можете создать: Виртуальные датацентры, Каталоги.","dtUpdated":"2025-10-29T22:52:10.890+0300","version":"1.0.0","sort":200,"dtCreated":"2024-05-23T09:31:52.029+0300","svcShort":"vcOrgSaas","resourceRealmTypeId":2,"code":null,"orchestratorName":"OrgSaas","svcId":20,"svc":"Организация [DEPRECATED]","svcExtendedName":"Организация [DEPRECATED] vcOrgSaas ","descr":"Без интеграции Keycloak","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"# Инструкция по развертыванию Организация в Cloud Director через платформу

## 1. Общая информация

Сервис **Организация в Cloud Director** предназначен для управления изолированными организационными единицами (тенантами) внутри инфраструктуры Cloud Director. Он обеспечивает создание, модификацию и администрирование организаций, предоставление им облачных ресурсов, а также контроль уровня доступа. Сервис используется для организации рабочих окружений потребителей, управления жизненным циклом организаций и их сетевой инфраструктурой.

### Доступные операции

**create** — Создание новой организации с заданными характеристиками и типом доступа.
**delete** — Удаление организации после перехода в состояние suspend; приводит к удалению всех связанных сущностей.
**modify** — Изменение параметров внутри организации (на текущий момент поддерживается изменение ipSpaces).
**suspend** — Заморозка организации, ограничение возможности изменения и добавления сущностей.
**resume** — Разморозка организации, восстановление функций управления.

---

## 2. Параметры развертывания

Параметры входных данных операции **create**:

### Платформа для развертывания
Описание: платформа, на которой будет создана организация.
Пример значения: `ngcloud.ru`.

### Тип организации
Описание: тип создаваемой организации.
Варианты:
- `iaas` — организация с прямым доступом к облаку и авторизацией через Keycloak.
- `saas` — организация под управлением Nubes, без прямого доступа к облаку.

---

## 3. Рекомендованные характеристики

### Тестовое окружение
- Тип организации: `iaas` для обеспечения полного доступа к облачным ресурсам и самостоятельного управления.

### Промышленное окружение
- Тип организации: `saas` (упрощённая модель использования).

---

## 4. Выходные параметры

Услуга предоставляет следующие выходные параметры (формируются в процессе операций):

### Url организации
Для быстрого доступа в тенант

### Статус организации
Отражает текущее состояние и параметры.

---

## 5. Дополнительная информация

- Для конфигурации внешних IP внутри организации необходимо предварительно создать:
-- хотя бы один **Виртуальный датацентр (VDC)** привязанный к этой организации;
-- хотя бы один **Сетевой шлюз периметра (Edge Gateway)**, привязанный к vdc выше.
- Удаление организации возможно только через 14 дней после перевода в состояние suspend.
- Удаление организации приводит к удалению всех связанных сущностей: VDC, Edge Gateway, пулов IP, сетей, ВМок и иных объектов.
- Операция suspend не останавливает виртуальные машины, но блокирует любые изменения инфраструктуры внутри организации.
- Для iaas-организаций доступ осуществляется через Keycloak.","dtUpdated":"2025-12-11T12:31:16.343+0300","version":"1.0.1","sort":201,"dtCreated":"2025-08-18T16:45:06.350+0300","svcShort":"vcOrg","resourceRealmTypeId":2,"code":null,"orchestratorName":"Org","svcId":19,"svc":"Организация в Cloud Director","svcExtendedName":"Организация в Cloud Director vcOrg ","descr":null,"resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":null,"dtUpdated":"2025-08-26T17:48:40.297+0300","version":"1.0.3","sort":110,"dtCreated":"2024-10-17T16:37:15.020+0300","svcShort":"s3bucket","resourceRealmTypeId":1,"code":null,"orchestratorName":"Bucket","svcId":13,"svc":"S3 бакет","svcExtendedName":"S3 бакет s3bucket ","descr":"Бакет в сервисе S3","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":"Руководство доступно по ссылке:
https://docs.s3.msk-1.ngcloud.ru/s3/s3.description.html","dtUpdated":"2025-08-26T14:48:30.886+0300","version":"1.0.5","sort":100,"dtCreated":"2024-05-23T09:31:52.029+0300","svcShort":"s3","resourceRealmTypeId":1,"code":"paas.s3.ceph","orchestratorName":"Ceph","svcId":12,"svc":"S3 Object Storage","svcExtendedName":"S3 Object Storage s3 ","descr":"Масштабируемое хранилище данных","resourceRealmCnt":1,"synonyms":null},{"isProductionReady":true,"man":null,"dtUpdated":"2025-08-26T15:18:55.340+0300","version":"0.0.7","sort":1010,"dtCreated":"2025-02-12T18:35:47.513+0300","svcShort":"template","resourceRealmTypeId":3,"code":null,"orchestratorName":"Template","svcId":2,"svc":"Темплейт k8s","svcExtendedName":"Темплейт k8s template ","descr":"Болванка для тестирования","resourceRealmCnt":2,"synonyms":null},{"isProductionReady":true,"man":"## Болванка для тестов.

А это просто много букв, чтобы посмотреть, как ведет себя текст при переполнении контейнера.


``` json
{
"iamServiceUrl": "https://auth-api-test.ngcloud.ru/api/v1/user",
"idpResponse": {
"prefix": {
"charset": "",
"status_text": "Request Time-out",
"text": true,
"errordetail": "",
"filecontent": "Connection Timeout",
"responseheader": {},
"header": "",
"statuscode": "408 Request Time-out",
"status_code": 408,
"mimetype": "Unable to determine MIME type of file."
}
}
}
```","dtUpdated":"2025-07-09T14:07:44.022+0300","version":"1.0.2","sort":1000,"dtCreated":"2024-05-23T09:31:52.029+0300","svcShort":"dummy","resourceRealmTypeId":7,"code":null,"orchestratorName":"Service","svcId":1,"svc":"Болванка","svcExtendedName":"Болванка dummy ","descr":"Болванка для тестов","resourceRealmCnt":1,"synonyms":null}],"runDurationMs":68} \ No newline at end of file diff --git a/state_keys_instances.json b/state_keys_instances.json deleted file mode 100644 index 1329669..0000000 --- a/state_keys_instances.json +++ /dev/null @@ -1,1254 +0,0 @@ -[ - { - "uid": "70d56396-bc05-4e7e-9e58-6e09dd913652", - "serviceId": 150 - }, - { - "uid": "9d7d37ff-d50f-4660-9247-55a6ce80551b", - "serviceId": 150 - }, - { - "uid": "b1c54787-8729-4264-811a-24b2936fc304", - "serviceId": 150 - }, - { - "uid": "bd1d689e-bce2-4792-9f1f-73cd1d6c7de7", - "serviceId": 116 - }, - { - "uid": "2eb2dc97-6658-4b36-b266-ef5698400158", - "serviceId": 115 - }, - { - "uid": "29f85c8e-430b-4b12-a72a-b5da70424966", - "serviceId": 113 - }, - { - "uid": "87ae9002-4f97-487b-a986-69ad61c9d3dc", - "serviceId": 111 - }, - { - "uid": "b488c8d4-82bc-4991-9844-eaab3859146f", - "serviceId": 111 - }, - { - "uid": "c0e5b324-fecb-4acf-9e21-2de0e7ba08a7", - "serviceId": 111 - }, - { - "uid": "2806c223-08b0-4020-a8c7-681639e7ef24", - "serviceId": 111 - }, - { - "uid": "aa9b8cdd-f7ff-4991-86d5-f0c223e1be2f", - "serviceId": 111 - }, - { - "uid": "958a91e8-57c3-435f-a921-8d29b60e1c96", - "serviceId": 111 - }, - { - "uid": "9cd4f4f0-32b4-408f-acbc-bfeab7617e5c", - "serviceId": 111 - }, - { - "uid": "d1fa455a-19a1-4810-8fb5-3bb2e798ec94", - "serviceId": 111 - }, - { - "uid": "7e759307-3d70-44bc-95ff-2c681c473845", - "serviceId": 111 - }, - { - "uid": "edb88b5c-af1e-4499-9392-e1b8fbca2558", - "serviceId": 111 - }, - { - "uid": "d7e840c4-8ec4-4d58-96bf-331a9588255a", - "serviceId": 111 - }, - { - "uid": "fe45d9c7-90b6-40f6-ac12-8f6708198493", - "serviceId": 111 - }, - { - "uid": "a3e07c4f-e2ba-45ed-b83f-137eb418a4d9", - "serviceId": 111 - }, - { - "uid": "7bdb595f-6bc4-498e-993f-b7edf63ddeba", - "serviceId": 111 - }, - { - "uid": "cc77e244-1854-4bc9-8cbc-da1ec0b85d1f", - "serviceId": 111 - }, - { - "uid": "bbb8ba37-2fd8-4b38-b389-ad4e8f31544d", - "serviceId": 111 - }, - { - "uid": "6ff6d822-7e56-4fc0-83e6-387847b2e110", - "serviceId": 111 - }, - { - "uid": "b926db0d-d0e1-411c-b1b4-77f3b609d9c5", - "serviceId": 111 - }, - { - "uid": "0afbd3cc-70ab-42b4-94da-910dfd38a9a6", - "serviceId": 111 - }, - { - "uid": "f7946b1c-a845-4ed0-9bac-1e38aed56e5b", - "serviceId": 111 - }, - { - "uid": "c1895a53-a8b1-4255-81da-c1225bf890d6", - "serviceId": 111 - }, - { - "uid": "a41979e7-824a-4aea-a707-46b92726ed82", - "serviceId": 111 - }, - { - "uid": "5337bf52-bf5f-4f39-9eb0-2d25c8d881ec", - "serviceId": 111 - }, - { - "uid": "18210b92-0115-420b-b55e-b65cac91def9", - "serviceId": 111 - }, - { - "uid": "0a74f641-e520-4042-91df-a7231d3cdf1d", - "serviceId": 111 - }, - { - "uid": "f89a1419-5bd8-4275-b680-aa7805cf8c89", - "serviceId": 111 - }, - { - "uid": "a2340cca-7be3-4e01-a319-c035ccd173dd", - "serviceId": 111 - }, - { - "uid": "4b203d07-460d-4281-a66e-3da965c077c2", - "serviceId": 111 - }, - { - "uid": "fa34ecf9-4c04-4de1-89bc-ce711f171ba7", - "serviceId": 111 - }, - { - "uid": "1444c498-e81e-4ed7-a768-537a6d490e02", - "serviceId": 111 - }, - { - "uid": "4c431b2c-cace-4334-8c28-2c80d47faced", - "serviceId": 111 - }, - { - "uid": "44f400a0-1bfd-424e-8ded-a384d4fb275c", - "serviceId": 111 - }, - { - "uid": "7846d5d4-929a-4d45-bb88-b56b0084f675", - "serviceId": 111 - }, - { - "uid": "5088d6f0-5891-4bf1-b7e2-a620585b4691", - "serviceId": 111 - }, - { - "uid": "62bc1a4d-2b5a-4dab-953f-e0d7a74799a3", - "serviceId": 111 - }, - { - "uid": "88743d7b-a9e1-4d1e-8930-91b2333fb4c1", - "serviceId": 111 - }, - { - "uid": "2f60c0b3-aeb0-4573-b45a-e3ada125a591", - "serviceId": 111 - }, - { - "uid": "6c3a7195-96c4-4677-8d5d-b7e47fabada5", - "serviceId": 111 - }, - { - "uid": "2ff6e3a8-d6a6-4f0a-a214-f005bb5c939e", - "serviceId": 111 - }, - { - "uid": "ddcb334d-4b5d-4ff8-95ae-0e88e7209c2d", - "serviceId": 111 - }, - { - "uid": "9563e11a-4c17-4ca4-aa29-4d5ebf57f00a", - "serviceId": 111 - }, - { - "uid": "495f7fc8-a73d-4ff0-aec2-3a4dd8c4b414", - "serviceId": 111 - }, - { - "uid": "8709a7eb-837b-4c30-9bca-dcbd18627043", - "serviceId": 111 - }, - { - "uid": "571c1f10-4f1e-4bb1-bfaa-aeaa47aab779", - "serviceId": 111 - }, - { - "uid": "7e759307-3d70-44bc-95ff-2c681c473845", - "serviceId": 111 - }, - { - "uid": "d1fa455a-19a1-4810-8fb5-3bb2e798ec94", - "serviceId": 111 - }, - { - "uid": "f6a24a6a-beb6-4078-b27b-ca92d3237689", - "serviceId": 111 - }, - { - "uid": "958a91e8-57c3-435f-a921-8d29b60e1c96", - "serviceId": 111 - }, - { - "uid": "9cd4f4f0-32b4-408f-acbc-bfeab7617e5c", - "serviceId": 111 - }, - { - "uid": "87ae9002-4f97-487b-a986-69ad61c9d3dc", - "serviceId": 111 - }, - { - "uid": "aa9b8cdd-f7ff-4991-86d5-f0c223e1be2f", - "serviceId": 111 - }, - { - "uid": "b488c8d4-82bc-4991-9844-eaab3859146f", - "serviceId": 111 - }, - { - "uid": "14a93bbb-ea04-41e2-8965-906ab67a9279", - "serviceId": 111 - }, - { - "uid": "b23c8eb4-ca3c-47de-aab1-1a26020de435", - "serviceId": 111 - }, - { - "uid": "f17cfa3e-20df-4e31-8d03-b5743f468e31", - "serviceId": 110 - }, - { - "uid": "0078125d-20a1-4a84-b528-3e036e78aedc", - "serviceId": 110 - }, - { - "uid": "4997c2a2-fbe9-46a8-80f5-601e8e902157", - "serviceId": 110 - }, - { - "uid": "ffc4d520-f61a-4d5a-b5cb-eacf5366e3c2", - "serviceId": 110 - }, - { - "uid": "2e71dcfa-73b2-4db8-b819-e38d8a3cb4a6", - "serviceId": 110 - }, - { - "uid": "40a15fe6-f943-4c1a-aa7a-04186f510e72", - "serviceId": 110 - }, - { - "uid": "094053f9-0acb-49c8-89da-e09e7a06f832", - "serviceId": 110 - }, - { - "uid": "498096ee-903f-48b4-b175-a79ec3efc52b", - "serviceId": 110 - }, - { - "uid": "f5ba0d72-e0d0-47cb-a7ab-19997bbbf62a", - "serviceId": 110 - }, - { - "uid": "e15e66e1-c613-4230-8f5e-263804224b0f", - "serviceId": 99 - }, - { - "uid": "b5d56909-b25c-4baf-8ce4-de2cfb519ca9", - "serviceId": 99 - }, - { - "uid": "119dadd8-7022-427d-8dcd-bc293624b4c6", - "serviceId": 99 - }, - { - "uid": "870c2fa3-5fe9-43eb-985f-7c6d8979968e", - "serviceId": 98 - }, - { - "uid": "40f67188-4f05-4c1b-bbc3-d59817cf250c", - "serviceId": 98 - }, - { - "uid": "a70021b9-902d-40e9-bffa-d41ede78b841", - "serviceId": 97 - }, - { - "uid": "ef20352e-855c-4b41-a74e-e7f95a2f4f17", - "serviceId": 96 - }, - { - "uid": "9d1a26a0-16e9-4c0f-8498-27edfb621e28", - "serviceId": 96 - }, - { - "uid": "738ddfec-f85d-4faf-80f4-80e9593f9457", - "serviceId": 96 - }, - { - "uid": "253c7edd-1472-47e2-b0cf-ea49d9501d61", - "serviceId": 96 - }, - { - "uid": "2d156030-dd85-468f-ac7e-a63e1060fd02", - "serviceId": 96 - }, - { - "uid": "426c5b36-908f-4083-bb00-e1f964802df8", - "serviceId": 96 - }, - { - "uid": "4b41bc20-c615-41ea-b1e6-48c4c97cd5c6", - "serviceId": 96 - }, - { - "uid": "a15cc266-de79-49c8-99b9-b85d2e0b0d1e", - "serviceId": 96 - }, - { - "uid": "490b8255-d0e0-41a3-9eed-f66024e3e4e8", - "serviceId": 96 - }, - { - "uid": "d614dc28-e3d7-4616-b986-fa2277e911c5", - "serviceId": 96 - }, - { - "uid": "6415e199-74d4-4acc-af15-5f406c7b3af3", - "serviceId": 96 - }, - { - "uid": "a0068c9a-ac8c-4792-8423-008211dbd2e8", - "serviceId": 96 - }, - { - "uid": "83914058-d3c3-4f23-bb36-777a824601ec", - "serviceId": 96 - }, - { - "uid": "1857e055-b606-4b90-926a-c5a8ef8ee70a", - "serviceId": 96 - }, - { - "uid": "72a6e474-a0c2-4eb1-8457-a466140e8159", - "serviceId": 96 - }, - { - "uid": "368d1f16-0c66-47b7-8afe-0206fda2e2ba", - "serviceId": 96 - }, - { - "uid": "f5a46223-7d08-4006-85f9-a86bcce20bb7", - "serviceId": 96 - }, - { - "uid": "46436960-1400-468c-83d2-35894cdff415", - "serviceId": 95 - }, - { - "uid": "b9013fe6-f5dc-4f7b-8ddc-5458835d3fd9", - "serviceId": 95 - }, - { - "uid": "b5c58586-a490-4734-8e5f-6a277ca3ae21", - "serviceId": 95 - }, - { - "uid": "b3a75608-d980-448d-9ea3-bb924357d994", - "serviceId": 94 - }, - { - "uid": "7cc7d4c1-4be7-43a7-b354-aaf05b556157", - "serviceId": 94 - }, - { - "uid": "e7ffd0db-f229-434c-8572-fd8137eb957c", - "serviceId": 94 - }, - { - "uid": "873b1a10-593c-4efc-bbdb-00b8daf3fda3", - "serviceId": 93 - }, - { - "uid": "3ec2d96d-a897-45ce-83ac-e7de3de01894", - "serviceId": 92 - }, - { - "uid": "e8f16cae-3668-4b8c-8119-6209d5a87fd7", - "serviceId": 91 - }, - { - "uid": "9835eb50-9ef8-47b2-aaf5-7ff92dbafd29", - "serviceId": 91 - }, - { - "uid": "ad0a2410-fba7-4b0a-9e6a-53b158884b1a", - "serviceId": 90 - }, - { - "uid": "f8bb2525-797e-428f-937a-d16e613f10af", - "serviceId": 90 - }, - { - "uid": "98c9012f-0cfb-40a4-87b0-e15796641e61", - "serviceId": 90 - }, - { - "uid": "5e71c188-e989-42d7-89ff-cc623d04f066", - "serviceId": 90 - }, - { - "uid": "f1e99959-39b7-47f2-8234-ef8bd4e2c14d", - "serviceId": 90 - }, - { - "uid": "3c0d27dc-640d-451d-9a15-d16fdcde4879", - "serviceId": 90 - }, - { - "uid": "70f921b1-12cf-4069-ae38-3aa9a7cf3df1", - "serviceId": 90 - }, - { - "uid": "5b141d60-547f-44fb-8169-f883d0199987", - "serviceId": 90 - }, - { - "uid": "69769b44-10cc-4116-87fa-93239350d5af", - "serviceId": 90 - }, - { - "uid": "48dbc644-9e45-418f-bd09-b65350159057", - "serviceId": 90 - }, - { - "uid": "b1d8ab38-bd8a-4be4-a27c-9b1a74dc0f19", - "serviceId": 90 - }, - { - "uid": "61f581c9-731f-4b7a-85e3-014bc3b7a955", - "serviceId": 90 - }, - { - "uid": "d6a6a6d3-2ed9-49ca-9620-7b9638e8f279", - "serviceId": 90 - }, - { - "uid": "32b7d8bb-80ed-435c-aaf0-d11bd76672bb", - "serviceId": 90 - }, - { - "uid": "3acee9fc-34a7-4c2c-8694-dc90cf2a82a8", - "serviceId": 90 - }, - { - "uid": "e1549cd3-7c0e-426b-937f-9e5aa897a68e", - "serviceId": 90 - }, - { - "uid": "e2b5c211-5b2b-43f0-ad28-cac3b87d4df7", - "serviceId": 90 - }, - { - "uid": "b31737f3-b792-4a43-83a9-fdd6452eb768", - "serviceId": 89 - }, - { - "uid": "55c4914e-7bc5-4bcc-bc71-2444025bc6fc", - "serviceId": 82 - }, - { - "uid": "ef431f65-a287-4af1-8a68-2ab6e808e518", - "serviceId": 82 - }, - { - "uid": "4ca0482e-3a5a-4648-9a39-4a9995c970a9", - "serviceId": 50 - }, - { - "uid": "e3ff4b3a-1236-4df7-ad88-c66142733bdb", - "serviceId": 28 - }, - { - "uid": "220160bb-5482-4912-8605-b5ed18b37de4", - "serviceId": 28 - }, - { - "uid": "905fb694-1db8-46d1-ae4f-0ded5b50648c", - "serviceId": 28 - }, - { - "uid": "fec8e342-9c25-485d-af89-a9dec38782b6", - "serviceId": 28 - }, - { - "uid": "51e4fc4f-74ef-4739-8888-1a08f1e4b1bf", - "serviceId": 28 - }, - { - "uid": "b0100d99-b2a1-4c7d-8f04-429101597860", - "serviceId": 28 - }, - { - "uid": "d5e146f4-be62-4f86-bbcc-e7e8f0567431", - "serviceId": 28 - }, - { - "uid": "df3f3b59-ef89-4efa-af26-f03e8f3158f4", - "serviceId": 28 - }, - { - "uid": "3c7dc106-0d04-4e7b-a91b-2f8243a41b78", - "serviceId": 28 - }, - { - "uid": "637ea670-db14-4f29-ba31-d8f9e562c4f4", - "serviceId": 28 - }, - { - "uid": "7899eda4-2663-4f49-86bd-b4bebc64055d", - "serviceId": 28 - }, - { - "uid": "7bc42ed6-a674-4f0c-a0c9-4a6a63e38ce9", - "serviceId": 28 - }, - { - "uid": "464eb4a0-62dc-42cd-af2b-4305db3c8d1b", - "serviceId": 26 - }, - { - "uid": "92d32358-b8b1-49f5-9f6f-bbc181483afd", - "serviceId": 26 - }, - { - "uid": "b4683403-8858-4bda-b043-1fc6ddd28b7e", - "serviceId": 25 - }, - { - "uid": "a56d623c-0023-4d10-916e-209fd4844b54", - "serviceId": 25 - }, - { - "uid": "408d8af4-2e8b-482b-adc1-fe609a14a98a", - "serviceId": 25 - }, - { - "uid": "ba71b39f-032d-4610-b93a-266916b5d429", - "serviceId": 25 - }, - { - "uid": "461a8fdc-c4c6-43d1-9046-fa9142e2d256", - "serviceId": 25 - }, - { - "uid": "aa8cd28b-34de-4361-aff3-f7954ee443f9", - "serviceId": 25 - }, - { - "uid": "bf82f1b8-0e94-4306-8c12-69878956ba59", - "serviceId": 25 - }, - { - "uid": "09c8a73a-64ef-4afb-86a4-3382f257a618", - "serviceId": 25 - }, - { - "uid": "ba6d6e55-0189-40a2-a989-ff3151a7e7bc", - "serviceId": 22 - }, - { - "uid": "7fd32ee2-3e2e-4198-b2fe-bcf63b5f17ac", - "serviceId": 22 - }, - { - "uid": "7ecf6b01-7493-4c83-af5d-9fb19385f904", - "serviceId": 21 - }, - { - "uid": "a2431648-77e3-49b2-82bd-925602f462bf", - "serviceId": 21 - }, - { - "uid": "7996ca02-906b-4538-a344-9fd67d554909", - "serviceId": 21 - }, - { - "uid": "6e2ae6d9-99a2-4a91-ace0-c3cff0c8ba9b", - "serviceId": 21 - }, - { - "uid": "6ed05a80-ff39-4acf-b24d-29369e724bee", - "serviceId": 21 - }, - { - "uid": "99342c70-faf6-4377-b183-4d9c99d702ca", - "serviceId": 21 - }, - { - "uid": "e3034741-5884-4ba4-b536-cc3444e4627d", - "serviceId": 21 - }, - { - "uid": "9b9d79a3-9037-4c1a-8bfd-29529ef5a345", - "serviceId": 21 - }, - { - "uid": "d77f6416-73b8-4e83-be4d-2a19a3e1e3bb", - "serviceId": 21 - }, - { - "uid": "eb2d0ebe-e0fc-4f70-8c73-8f204c8a05a0", - "serviceId": 21 - }, - { - "uid": "68af03fb-4fac-4bc8-b048-2054e9f29cca", - "serviceId": 21 - }, - { - "uid": "450e4996-e61c-4a9b-93f4-a7ecb77fabf8", - "serviceId": 21 - }, - { - "uid": "6dd644c2-030a-4b06-959f-3287ae970a31", - "serviceId": 21 - }, - { - "uid": "110d33f6-efb6-4788-82cb-0aaa25a22ea7", - "serviceId": 21 - }, - { - "uid": "e5375174-36ec-4512-bba9-b56f9eeba0bd", - "serviceId": 19 - }, - { - "uid": "ba0dbcf7-8c89-4ded-b180-482643846d85", - "serviceId": 13 - }, - { - "uid": "0f374d4b-b64d-4083-aef3-91e3e05c4c68", - "serviceId": 13 - }, - { - "uid": "34eb044d-e777-44b1-9187-e3c18e9d53db", - "serviceId": 13 - }, - { - "uid": "7f77570f-ce8b-484d-b464-9e19ec268ef6", - "serviceId": 13 - }, - { - "uid": "a8100363-9467-44b2-be74-b20a2248a187", - "serviceId": 13 - }, - { - "uid": "043565b5-990d-4684-8682-4741df22c582", - "serviceId": 13 - }, - { - "uid": "c76b101f-25aa-443c-979b-12571327766c", - "serviceId": 13 - }, - { - "uid": "f95a3390-49b1-4a43-ac53-af4be0ae5fc2", - "serviceId": 13 - }, - { - "uid": "1289f0ac-7a39-4d3e-884e-a7778b996977", - "serviceId": 13 - }, - { - "uid": "79d805d2-3ef7-4dfb-8fd2-622ba051d888", - "serviceId": 13 - }, - { - "uid": "78a050b5-2687-4fdb-ad4c-0f94517f6569", - "serviceId": 13 - }, - { - "uid": "148bfa14-03f3-4a8e-b9c7-0d83c923fe10", - "serviceId": 13 - }, - { - "uid": "dccf8f96-950f-41da-8fc2-70f1aa6467c6", - "serviceId": 13 - }, - { - "uid": "ec77d776-82e8-4e13-9bac-b5070daee472", - "serviceId": 13 - }, - { - "uid": "dcb0127c-620c-4f87-8fa7-6660ca3b1bc8", - "serviceId": 13 - }, - { - "uid": "4cd1f066-6411-4fd9-bf0e-3ebb8cab50a6", - "serviceId": 13 - }, - { - "uid": "b8f07f18-8933-4733-bd97-acaca80483a2", - "serviceId": 13 - }, - { - "uid": "b1fe53f4-228b-409b-a100-76c065516858", - "serviceId": 13 - }, - { - "uid": "fbfc8167-9b77-4863-bd21-ff53a623f5dc", - "serviceId": 13 - }, - { - "uid": "aceecd79-786b-405b-ab8d-57c248c2a3f2", - "serviceId": 13 - }, - { - "uid": "4e393388-1b0f-4bb3-a4bb-abee1694d926", - "serviceId": 13 - }, - { - "uid": "3fbfac81-f9d1-4f08-aa49-eec61518404a", - "serviceId": 13 - }, - { - "uid": "06eb5ec0-02f1-4f0a-a545-e489759a26d2", - "serviceId": 13 - }, - { - "uid": "c64bd0fe-3737-466d-ac03-d0eaa241cf81", - "serviceId": 13 - }, - { - "uid": "e6ebac9c-f3fd-4eaf-9fea-1f42755fa3a9", - "serviceId": 13 - }, - { - "uid": "f1b59ff6-ccb2-4dc4-8e92-2f96f4d5576a", - "serviceId": 13 - }, - { - "uid": "73169443-94ba-4ad5-922b-04b5c054bd03", - "serviceId": 13 - }, - { - "uid": "b5425937-dea6-4d16-af90-58bc681a3548", - "serviceId": 13 - }, - { - "uid": "1220cb96-9e7c-4c5f-b92a-948c8ede21ca", - "serviceId": 13 - }, - { - "uid": "01fe8a24-f1be-4b1d-9560-a0dfd54eeb0c", - "serviceId": 13 - }, - { - "uid": "ce808c1a-2890-4ecd-b310-42f63bf832d9", - "serviceId": 13 - }, - { - "uid": "3c2475c6-8a44-4053-8a4a-8a77d5b53a21", - "serviceId": 13 - }, - { - "uid": "b06fa560-9f86-46f7-917c-612717876703", - "serviceId": 13 - }, - { - "uid": "417e66f3-3c7c-4853-a96b-8df506a8f939", - "serviceId": 13 - }, - { - "uid": "97c7c361-8be8-4df8-986a-92d1b4ce1c2c", - "serviceId": 13 - }, - { - "uid": "137fb234-fdb6-4c66-82ef-6896cd6db843", - "serviceId": 13 - }, - { - "uid": "a24ef66b-83e4-4e72-a15b-9fb83a73c62f", - "serviceId": 13 - }, - { - "uid": "4d47284f-8be6-4338-b117-fbe03b777ff5", - "serviceId": 13 - }, - { - "uid": "f39aaff1-8ba0-42ba-a3ea-2015b5ba66f4", - "serviceId": 13 - }, - { - "uid": "5647aeb8-731e-4e40-9a81-241c1a6b6351", - "serviceId": 13 - }, - { - "uid": "12983095-2e03-4ee7-bf95-dddbe7f2df34", - "serviceId": 13 - }, - { - "uid": "4b4a7832-9d9a-4f70-a8a3-230346805e87", - "serviceId": 13 - }, - { - "uid": "7d17bd94-c27b-475d-8750-f6ade7b2e375", - "serviceId": 13 - }, - { - "uid": "ec1df65f-906e-4c0b-ad63-7670ea00ced0", - "serviceId": 13 - }, - { - "uid": "6b22a99a-2799-4254-a3c0-df7c27e8369b", - "serviceId": 13 - }, - { - "uid": "32408923-7ae9-46dc-8fd0-30941549ad35", - "serviceId": 13 - }, - { - "uid": "985176d4-b49f-412a-a98d-1feaef7e75ce", - "serviceId": 13 - }, - { - "uid": "0785e2cb-4f72-41e9-bf31-008fae28fe12", - "serviceId": 13 - }, - { - "uid": "50447cb4-64b0-4ada-acb3-654e4176932c", - "serviceId": 13 - }, - { - "uid": "5100521d-74ac-485d-b725-6dafc54ddbc6", - "serviceId": 13 - }, - { - "uid": "623af87b-4098-4017-b4e5-d97006c63cfa", - "serviceId": 13 - }, - { - "uid": "1975ee78-33fa-49a4-871f-2ad52d90fd44", - "serviceId": 13 - }, - { - "uid": "65362661-7c8e-4cd3-8ccf-c05e2cd07468", - "serviceId": 13 - }, - { - "uid": "d40807e0-918b-4ccc-b3b0-f45f104d086e", - "serviceId": 13 - }, - { - "uid": "b218455d-e349-4762-b433-245ff9e95630", - "serviceId": 13 - }, - { - "uid": "8d13f5f0-50fc-4440-a6b4-1a320ffe3f10", - "serviceId": 13 - }, - { - "uid": "599434e7-c43a-4234-977a-66db80fedc96", - "serviceId": 13 - }, - { - "uid": "b23d894d-edda-48f2-8347-a978fac2d1b3", - "serviceId": 13 - }, - { - "uid": "15751c22-bd8b-4d00-a705-ac0083dd51d3", - "serviceId": 13 - }, - { - "uid": "c32b513c-692c-42db-b7e9-ce86703235a2", - "serviceId": 13 - }, - { - "uid": "e6d5d062-7fe7-430e-8563-1d5cee7b13f4", - "serviceId": 13 - }, - { - "uid": "528fd58d-0301-4e07-b4e9-9fbae5a362f6", - "serviceId": 13 - }, - { - "uid": "75978a8a-96d7-404d-80a8-15d276a58847", - "serviceId": 13 - }, - { - "uid": "801662d3-d54c-4d65-85ea-1a78863b88ea", - "serviceId": 13 - }, - { - "uid": "864a5390-5c66-430a-af2a-079281692bbc", - "serviceId": 13 - }, - { - "uid": "e6a7a61e-bcf6-446b-89dd-ee662f0fb014", - "serviceId": 13 - }, - { - "uid": "80ee2387-807b-4934-ae82-8b85d6a028b3", - "serviceId": 13 - }, - { - "uid": "664ea816-320c-45d3-bb36-e4bd6028cca1", - "serviceId": 13 - }, - { - "uid": "6efc7c08-68fd-47b5-b33b-5d2f7e6d094a", - "serviceId": 12 - }, - { - "uid": "e62bda93-6931-40c5-8e71-7be416de289f", - "serviceId": 12 - }, - { - "uid": "6d6061cb-b0c1-44b9-8969-a70f08fe673c", - "serviceId": 12 - }, - { - "uid": "d964b164-15a0-4f61-a0b0-743759cf0123", - "serviceId": 12 - }, - { - "uid": "0fbef337-660c-4d36-835b-1226969050ce", - "serviceId": 12 - }, - { - "uid": "30124241-644c-4925-b525-a8c981a44023", - "serviceId": 1 - }, - { - "uid": "476a5f44-00a5-40ae-9f58-8fa5546ac297", - "serviceId": 1 - }, - { - "uid": "d4bd7c9c-40f8-4a56-92fd-1c8f271f8521", - "serviceId": 1 - }, - { - "uid": "de882fbc-b3dc-48f1-8bc7-b3da852334d2", - "serviceId": 1 - }, - { - "uid": "37fba19d-4c54-4275-8922-3170b5f8ad21", - "serviceId": 1 - }, - { - "uid": "604665ce-1d00-4540-ba8c-8a7519475714", - "serviceId": 1 - }, - { - "uid": "a5a90de9-0546-465b-b4ea-0b7376cd3ab4", - "serviceId": 1 - }, - { - "uid": "bed18a0b-aa07-4762-b583-0f32d65e0ea1", - "serviceId": 1 - }, - { - "uid": "c5a44ee3-75c1-40b5-9f5f-ce77fb57e7a4", - "serviceId": 1 - }, - { - "uid": "fff45eb2-7c46-4831-b54f-8fc4fa6e7634", - "serviceId": 1 - }, - { - "uid": "31faf848-3592-4b69-bbac-cb36a2f35a8e", - "serviceId": 1 - }, - { - "uid": "74304b53-2fff-4b72-a2a5-3619a0675e4a", - "serviceId": 1 - }, - { - "uid": "697f70a4-8c06-4c3f-88dd-daa0195c9506", - "serviceId": 1 - }, - { - "uid": "3b057081-d555-461b-b8c0-d88ac75fe098", - "serviceId": 1 - }, - { - "uid": "f1aca37b-a885-48f4-b602-572ddfca39e6", - "serviceId": 1 - }, - { - "uid": "48475ea2-cbfc-4250-b6ab-1afc0825cafd", - "serviceId": 1 - }, - { - "uid": "c768f8ad-41ae-44bb-a5a3-2c489bb298c7", - "serviceId": 1 - }, - { - "uid": "1f63df37-6dbb-47f4-bc49-18a5b383bcdc", - "serviceId": 1 - }, - { - "uid": "4647b83d-d768-43b4-bdd6-4a818cdb2cfe", - "serviceId": 1 - }, - { - "uid": "e69247f7-68f1-43de-adcb-b212548b2bb1", - "serviceId": 1 - }, - { - "uid": "f990c3b7-b3a9-4927-bfa5-fe495aa3fd36", - "serviceId": 1 - }, - { - "uid": "e0c54fa7-faca-4204-b832-b79075a7ced4", - "serviceId": 1 - }, - { - "uid": "95d31162-e801-405f-b36d-3bb236c4e3bd", - "serviceId": 1 - }, - { - "uid": "18f488a6-47dd-48d3-a358-e8957d9a9bfa", - "serviceId": 1 - }, - { - "uid": "eca82072-dfca-459d-a1cc-1c743eee1534", - "serviceId": 1 - }, - { - "uid": "d55e1118-35eb-4c6a-a4f3-8805bf15c5ea", - "serviceId": 1 - }, - { - "uid": "e7e73478-0da8-4cce-ae6b-08f59f664cc2", - "serviceId": 1 - }, - { - "uid": "a47155c6-ea21-402c-93cd-af5f80655666", - "serviceId": 1 - }, - { - "uid": "62b0d8f5-4b53-4927-826d-b1adc5264159", - "serviceId": 1 - }, - { - "uid": "b3b42a1d-5b06-45c6-8730-1e1840e1814f", - "serviceId": 1 - }, - { - "uid": "76efd6eb-6e98-4884-b69c-6cada86dc1a0", - "serviceId": 1 - }, - { - "uid": "992bb470-231c-4f75-9e8d-f3c178dabafd", - "serviceId": 1 - }, - { - "uid": "d4697977-843c-4e84-8c1f-2217c6979999", - "serviceId": 1 - }, - { - "uid": "10f8a05e-b0c7-4c32-bd19-2c0b6fb400bb", - "serviceId": 1 - }, - { - "uid": "943780ab-0f77-473a-92e2-08d3581d9320", - "serviceId": 1 - }, - { - "uid": "28144fe0-90b2-4107-82e2-7064aebe6e08", - "serviceId": 1 - }, - { - "uid": "f3ee56a5-ad91-442b-82d1-84b66f63fc6c", - "serviceId": 1 - }, - { - "uid": "a9c570d7-45d0-44ab-ade5-5ca6aaa4b5e7", - "serviceId": 1 - }, - { - "uid": "fe5263a9-c600-453f-8cfd-0f5214a54084", - "serviceId": 1 - }, - { - "uid": "f6ff074d-f3af-4b44-93fd-73fc617bc1bb", - "serviceId": 1 - }, - { - "uid": "716fb257-4235-493c-9e00-afe2fc62ddac", - "serviceId": 1 - }, - { - "uid": "c7be7901-ce86-4432-9b1f-699ddefb1f99", - "serviceId": 1 - }, - { - "uid": "d52cc072-e39d-49d8-9fed-6891d7cb5898", - "serviceId": 1 - }, - { - "uid": "a7e5d218-bdff-4e54-b480-31556f6e4a15", - "serviceId": 1 - }, - { - "uid": "9880a0d5-dd6e-4352-ab27-b7e2e307b3ad", - "serviceId": 1 - }, - { - "uid": "41d43664-ae4b-4e06-a535-e11e39b3b091", - "serviceId": 1 - }, - { - "uid": "c1931bf2-ea98-4ca3-ab6e-ea8846154c14", - "serviceId": 1 - }, - { - "uid": "d5021a14-3b97-4755-b808-dcfa7719c155", - "serviceId": 1 - }, - { - "uid": "05a7708c-ba37-4be2-987c-0b73347db6a6", - "serviceId": 1 - }, - { - "uid": "6c4060c4-c05f-4700-b258-0ee1b1c2c14a", - "serviceId": 1 - }, - { - "uid": "de588d9d-d097-4758-a1e9-c08e71250391", - "serviceId": 1 - }, - { - "uid": "ba79c918-603c-41c7-b75b-7b5fab98fc0d", - "serviceId": 1 - }, - { - "uid": "942b52c4-74cd-4cce-88d0-5d7d5846bb97", - "serviceId": 1 - }, - { - "uid": "eb25c267-4106-4cc2-afb9-79dec1774ab0", - "serviceId": 1 - }, - { - "uid": "37e7a813-a518-4ca4-9a08-6609188b3ffb", - "serviceId": 1 - }, - { - "uid": "573fcba1-112d-428f-9e13-c8e00e52e8a6", - "serviceId": 1 - }, - { - "uid": "f185d8b1-d937-4ade-8c4b-62fe86d2e3af", - "serviceId": 1 - }, - { - "uid": "966411ec-7b64-453e-9a54-72a6e4405902", - "serviceId": 1 - }, - { - "uid": "c32b5811-1ee5-40f6-af6c-282bc1d69e4c", - "serviceId": 1 - }, - { - "uid": "3a021b21-c922-4daf-82dd-d937ec0bc0e7", - "serviceId": 1 - }, - { - "uid": "c1f8f8a5-222f-4816-8adc-f0d94ffb0661", - "serviceId": 1 - }, - { - "uid": "21c9ecca-96eb-4250-925a-57b34b21564d", - "serviceId": 1 - }, - { - "uid": "6b1444f8-ad02-4be5-beea-ff619a2fa919", - "serviceId": 1 - }, - { - "uid": "38c3d4a1-0c3f-4847-9da0-0b61724c280e", - "serviceId": 1 - }, - { - "uid": "9999e249-afa6-477e-abe6-9e55c5a34036", - "serviceId": 1 - }, - { - "uid": "2f9a0b2c-7bd2-4c5b-a764-e6bbdb744404", - "serviceId": 1 - }, - { - "uid": "0906f325-9986-42e5-8fdf-73dd9f65b44f", - "serviceId": 1 - }, - { - "uid": "d69ca7e8-86af-40cb-8901-4a615e6087bc", - "serviceId": 1 - }, - { - "uid": "632c66b9-268b-4c1d-90d8-f8fb515c56ab", - "serviceId": 1 - }, - { - "uid": "04b5944f-9d34-4d3a-bfd7-2909d9ee36d5", - "serviceId": 1 - }, - { - "uid": "d38a479e-7e1d-4e71-8da4-1853766e64c8", - "serviceId": 1 - }, - { - "uid": "7ecccd3d-551a-43db-8453-74f2ba78f063", - "serviceId": 1 - }, - { - "uid": "e83f5946-a034-4f34-879a-f6625af45c88", - "serviceId": 1 - }, - { - "uid": "91b963bf-6d67-49d7-9a70-8bbd20a0cd93", - "serviceId": 1 - }, - { - "uid": "31994863-e06c-40fe-808a-9bd28dc4d4c2", - "serviceId": 1 - }, - { - "uid": "28a5b6ce-5dac-4845-81ad-93a8afb4e550", - "serviceId": 1 - }, - { - "uid": "6782ace6-0139-4df6-9949-d692631aee70", - "serviceId": 1 - }, - { - "uid": "fc6ab87e-6ceb-4f8e-9c7b-503c422502c6", - "serviceId": 1 - } -] \ No newline at end of file diff --git a/state_keys_progress.json b/state_keys_progress.json deleted file mode 100644 index d750f4b..0000000 --- a/state_keys_progress.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "index": 30, - "params": { - "150": [ - "accessIpListAPI", - "accessIpListIngress", - "appVersion", - "clusterName", - "controlPlaneCount", - "controlPlaneSizingDisk", - "controlPlaneSizingPolicy", - "needExternalAddressAPI", - "needExternalAddressIngress", - "nsxtUid", - "resourceRealm", - "workersCount", - "workersSizingDisk", - "workersSizingPolicy" - ], - "116": [ - "ipSpaceNameMaster", - "needExternalAddressMaster", - "resourceCPU", - "resourceDisk", - "resourceInstances", - "resourceMemory", - "resourceRealm" - ], - "115": [], - "113": [], - "111": [ - "recordInput", - "recordName", - "recordTTL", - "recordType", - "zoneName", - "zoneUid" - ] - }, - "out": { - "150": [ - "ingressAddress", - "kubernetesApiAddress", - "webUrl" - ], - "116": [ - "externalConnect", - "internalConnect", - "monitoring" - ], - "115": [], - "113": [], - "111": [ - "dnsServers", - "record" - ] - } -} \ No newline at end of file diff --git a/state_keys_progress.txt b/state_keys_progress.txt deleted file mode 100644 index 0dc3b8f..0000000 --- a/state_keys_progress.txt +++ /dev/null @@ -1 +0,0 @@ -processed 25/313 diff --git a/state_vault_progress.json b/state_vault_progress.json deleted file mode 100644 index 5242cfe..0000000 --- a/state_vault_progress.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "index": 28, - "found": { - "82": [ - "adminPass" - ], - "90": [ - "adminPass", - "adminUser", - "standbyPass", - "standbyUser" - ], - "93": [ - "adminPass", - "adminUser" - ], - "99": [ - "adminPass", - "adminUser" - ] - } -} \ No newline at end of file diff --git a/state_vault_services.json b/state_vault_services.json deleted file mode 100644 index 5f33ad8..0000000 --- a/state_vault_services.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "150": "b1c54787-8729-4264-811a-24b2936fc304", - "116": "bd1d689e-bce2-4792-9f1f-73cd1d6c7de7", - "115": "2eb2dc97-6658-4b36-b266-ef5698400158", - "113": "29f85c8e-430b-4b12-a72a-b5da70424966", - "111": "c0e5b324-fecb-4acf-9e21-2de0e7ba08a7", - "110": "f17cfa3e-20df-4e31-8d03-b5743f468e31", - "99": "e15e66e1-c613-4230-8f5e-263804224b0f", - "98": "870c2fa3-5fe9-43eb-985f-7c6d8979968e", - "97": "a70021b9-902d-40e9-bffa-d41ede78b841", - "96": "ef20352e-855c-4b41-a74e-e7f95a2f4f17", - "95": "46436960-1400-468c-83d2-35894cdff415", - "94": "b3a75608-d980-448d-9ea3-bb924357d994", - "93": "873b1a10-593c-4efc-bbdb-00b8daf3fda3", - "92": "3ec2d96d-a897-45ce-83ac-e7de3de01894", - "91": "e8f16cae-3668-4b8c-8119-6209d5a87fd7", - "90": "ad0a2410-fba7-4b0a-9e6a-53b158884b1a", - "89": "b31737f3-b792-4a43-83a9-fdd6452eb768", - "82": "55c4914e-7bc5-4bcc-bc71-2444025bc6fc", - "50": "4ca0482e-3a5a-4648-9a39-4a9995c970a9", - "28": "e3ff4b3a-1236-4df7-ad88-c66142733bdb", - "26": "464eb4a0-62dc-42cd-af2b-4305db3c8d1b", - "25": "b4683403-8858-4bda-b043-1fc6ddd28b7e", - "22": "ba6d6e55-0189-40a2-a989-ff3151a7e7bc", - "21": "7ecf6b01-7493-4c83-af5d-9fb19385f904", - "19": "e5375174-36ec-4512-bba9-b56f9eeba0bd", - "13": "ba0dbcf7-8c89-4ded-b180-482643846d85", - "12": "6efc7c08-68fd-47b5-b33b-5d2f7e6d094a", - "1": "30124241-644c-4925-b525-a8c981a44023" -} \ No newline at end of file diff --git a/svcs.json b/svcs.json deleted file mode 100644 index 5095ce2..0000000 --- a/svcs.json +++ /dev/null @@ -1,411 +0,0 @@ -{ - "1": { - "svc": "Болванка", - "ops": [ - "create", - "delete", - "modify", - "redeploy", - "resume", - "suspend" - ] - }, - "2": { - "svc": "Темплейт k8s", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "12": { - "svc": "S3 Object Storage", - "ops": [ - "create", - "create_sub_user", - "delete", - "delete_sub_user", - "modify", - "resume", - "suspend" - ] - }, - "13": { - "svc": "S3 бакет", - "ops": [ - "create", - "delete" - ] - }, - "19": { - "svc": "Организация в Cloud Director", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "20": { - "svc": "Организация [DEPRECATED]", - "ops": [ - "create", - "delete", - "resume", - "suspend" - ] - }, - "21": { - "svc": "Виртуальный датацентр (vDC)", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "22": { - "svc": "Сетевой шлюз периметра (Edge)", - "ops": [ - "create", - "delete", - "modify" - ] - }, - "23": { - "svc": "VM в Cloud Director (старый формат) (vc_vm)", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "24": { - "svc": "DEPRECATED Правила маршрутизации для VM (vc_nat)", - "ops": [] - }, - "25": { - "svc": "Публичные IP адреса", - "ops": [ - "create", - "delete", - "modify" - ] - }, - "26": { - "svc": "Виртуальный каталог ВМ (vApp)", - "ops": [ - "create", - "delete", - "resume", - "suspend" - ] - }, - "27": { - "svc": "VM в Cloud Director (vc_vmV2)", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "28": { - "svc": "Виртуальная машина", - "ops": [ - "create", - "delete", - "modify", - "redeploy", - "resume", - "suspend" - ] - }, - "29": { - "svc": "Группа датацентров", - "ops": [ - "add_vdc", - "create", - "delete", - "remove_vdc" - ] - }, - "32": { - "svc": "vc_vm_postgresql_std", - "ops": [ - "create", - "delete" - ] - }, - "50": { - "svc": "Nextcloud", - "ops": [ - "create", - "delete", - "modify", - "restart", - "resume", - "suspend" - ] - }, - "81": { - "svc": "Apache Superset", - "ops": [ - "create", - "delete" - ] - }, - "82": { - "svc": "Container Registry", - "ops": [ - "create", - "delete" - ] - }, - "88": { - "svc": "k8sZitiController", - "ops": [ - "create", - "delete" - ] - }, - "89": { - "svc": "Flask", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "90": { - "svc": "PostgreSQL", - "ops": [ - "create", - "delete", - "modify", - "recovery", - "restart", - "resume", - "suspend" - ] - }, - "91": { - "svc": "Redis", - "ops": [ - "create", - "delete" - ] - }, - "92": { - "svc": "MongoDB", - "ops": [ - "create", - "create_user", - "delete", - "delete_user", - "modify_user", - "resume", - "suspend" - ] - }, - "93": { - "svc": "RabbitMQ", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "94": { - "svc": "Lucee", - "ops": [ - "create", - "delete", - "modify", - "redeploy", - "restart", - "resume", - "suspend" - ] - }, - "95": { - "svc": "NodeJS", - "ops": [ - "create", - "delete", - "modify", - "redeploy", - "resume", - "suspend" - ] - }, - "96": { - "svc": "pgAdmin", - "ops": [ - "create", - "delete", - "modify", - "redeploy", - "resume", - "suspend" - ] - }, - "97": { - "svc": "NodeRed", - "ops": [ - "create", - "delete" - ] - }, - "98": { - "svc": "Простой HTTP контейнер", - "ops": [ - "create", - "delete", - "modify", - "redeploy", - "resume", - "suspend" - ] - }, - "99": { - "svc": "Gitea", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "100": { - "svc": "Serverless Openwhisk (openwhisk_tenant)", - "ops": [ - "create", - "delete" - ] - }, - "110": { - "svc": "DNS зона", - "ops": [ - "create", - "delete" - ] - }, - "111": { - "svc": "DNS запись", - "ops": [ - "create", - "delete", - "modify" - ] - }, - "112": { - "svc": "Тенант в Grafana", - "ops": [ - "create", - "delete", - "modify" - ] - }, - "113": { - "svc": "Быстрый старт", - "ops": [ - "create" - ] - }, - "114": { - "svc": "Комплексная услуга по созданию gitea", - "ops": [] - }, - "115": { - "svc": "Mariadb", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "116": { - "svc": "ApacheKafka", - "ops": [ - "create", - "create_topic", - "create_user", - "delete", - "delete_topic", - "delete_user", - "modify", - "modify_topic", - "modify_user", - "resume", - "suspend" - ] - }, - "117": { - "svc": "Nifi", - "ops": [ - "create", - "delete", - "modify" - ] - }, - "119": { - "svc": "Akhq", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "120": { - "svc": "ClickHouse", - "ops": [ - "create", - "create_database", - "create_user", - "delete", - "delete_database", - "delete_user", - "modify", - "resume", - "suspend" - ] - }, - "149": { - "svc": "VALO Cloud", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend" - ] - }, - "150": { - "svc": "Kubernetes кластер Штурвал", - "ops": [ - "create", - "delete", - "modify", - "resume", - "suspend", - "update_secrets" - ] - } -} \ No newline at end of file