chore: remove 18 junk files + stale root internal/ (old provider code)
Deleted: check_ops.py, s.sh, instance_fca3f912.json, name_check.txt, prompt_for_ds_pro.md, prompt_for_opus48.md, state_keys_*.json, state_keys_progress.txt, state_vault_*.json, svcs.json, services.*, REPO_CONTENTS.md, REPO_INVENTORY.md, 01_generate_yamls.sh (duplicate), internal/ (old provider, migrated to universal_rebuild/)
This commit is contained in:
@@ -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 - <<PY
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
sid = "${sid}"
|
||||
endpoint = "${API_ENDPOINT}"
|
||||
token = "${NUBES_API_TOKEN}"
|
||||
|
||||
# Auto-detect: "index.cfm" → legacy proxy, otherwise → REST gateway.
|
||||
if "index.cfm" in endpoint:
|
||||
params = urllib.parse.urlencode({"endpoint": f"/services/{sid}"})
|
||||
url = f"{endpoint}?{params}"
|
||||
else:
|
||||
url = f"{endpoint}/services/{sid}"
|
||||
|
||||
req = urllib.request.Request(url)
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
svc = data.get("svc", {})
|
||||
name = svc.get("svcShort") or svc.get("name") or svc.get("title") or f"service_{sid}"
|
||||
print(name)
|
||||
PY
|
||||
)
|
||||
|
||||
echo "Generating YAML for ${sid} (${svc_name})"
|
||||
(
|
||||
cd "$PROVIDER_DIR"
|
||||
NUBES_API_TOKEN="$NUBES_API_TOKEN" \
|
||||
NUBES_SERVICE_ID="$sid" \
|
||||
NUBES_SERVICE_NAME="$svc_name" \
|
||||
NUBES_OUTPUT="${PROVIDER_DIR}/resources_yaml/${svc_name}.yaml" \
|
||||
NUBES_INSTRUCTION_OUTPUT="/dev/null" \
|
||||
go run ./tools/service_params_gen/main.go
|
||||
)
|
||||
|
||||
done < "$SERVICES_FILE"
|
||||
|
||||
echo "Done. YAML files are in ${PROVIDER_DIR}/resources_yaml"
|
||||
@@ -1,102 +0,0 @@
|
||||
# Repository Contents
|
||||
|
||||
Updated: 2026-03-01
|
||||
Purpose: single-file map of repository structure for quick onboarding and orientation.
|
||||
|
||||
## How to use this file
|
||||
- Read this file at the start of a new chat/session to understand repo structure quickly.
|
||||
- Re-read this file when context is unclear, requirements conflict, or there are navigation uncertainties.
|
||||
- For active build/deploy work, treat `devops/` as the primary operational source of truth.
|
||||
|
||||
## Mandatory policy for new chats
|
||||
- Для `instance`-ресурсов с операциями `suspend/resume` обязательный источник правил: `docs/60_strategy/provider_philosophy.md` (разделы 7-9).
|
||||
- Применять только каноничные флаги: `adopt_existing_on_create` (default `false`) и `suspend_on_destroy` (default `true`).
|
||||
- Логика apply/destroy/modify должна следовать status-matrix из стратегии (`deleted`, `suspend`, `running`, `not created`, `creating`).
|
||||
- Упоминания `resume_if_exists` и `delete_mode` считать legacy и не использовать как норматив для новой реализации.
|
||||
|
||||
## Build/Deploy priority
|
||||
- Основной источник инструкций для сборки и деплоя: `devops/`.
|
||||
- При любых задачах публикации, генерации, сборки и релизов сначала проверять:
|
||||
- `devops/README.md`
|
||||
- `devops/ARCHITECTURE.md`
|
||||
- `devops/01_generate_yamls.sh`
|
||||
- `devops/02_generate_resources_and_docs_template_v2.sh`
|
||||
- `devops/03_build_and_upload_provider.sh`
|
||||
|
||||
---
|
||||
|
||||
## Top-level summary
|
||||
- `docs/` — **Единый корень документации** (overview, discovery, registry, analysis, history, strategy, API).
|
||||
- `tools/har/` — Scripts used to analyze HAR files and extract parameters/stages.
|
||||
- `har/` — Collected HAR files captured from browser/API traffic (raw evidence files).
|
||||
- `artifacts/` — JSON artifacts produced by live API queries and intermediate outputs.
|
||||
- `internal/provider/` — Terraform provider implementation (resources and data-sources).
|
||||
- `operator/`, `k8s/` — Operator manifests and Kubernetes overlays.
|
||||
- `examples/` & `tests/` — Usage examples and test scenarios for the provider.
|
||||
---
|
||||
|
||||
## Docs publication (CI) & Cloud S3 Storage
|
||||
- **Cloud Storage:** The project uses Nubes Cloud S3 (`s3.msk-1.ngcloud.ru`) with `S3_*` variables.
|
||||
- **Infrastructure:** Storage is external S3; Kubernetes resources are S3-only.
|
||||
- **Docs Location:** Published static HTML is stored in the `terraform-registry` bucket.
|
||||
- **Path Pattern:** `/docs/nubes/nubes/<version>/` (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".
|
||||
@@ -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) — не используется, версия ничему не соответствует
|
||||
@@ -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]}")
|
||||
@@ -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}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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, "./")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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=<YOUR_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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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{}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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-запросы выполнить).
|
||||
|
||||
**Важно:** Ничего НЕ ДЕЛАЙ в файловой системе. Никаких изменений кода, файлов, конфигов. Только анализ.
|
||||
@@ -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+ сгенерированных файлов подряд — они генерируются и не содержат уникальной логики.**
|
||||
@@ -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}/"
|
||||
@@ -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
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
processed 25/313
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"index": 28,
|
||||
"found": {
|
||||
"82": [
|
||||
"adminPass"
|
||||
],
|
||||
"90": [
|
||||
"adminPass",
|
||||
"adminUser",
|
||||
"standbyPass",
|
||||
"standbyUser"
|
||||
],
|
||||
"93": [
|
||||
"adminPass",
|
||||
"adminUser"
|
||||
],
|
||||
"99": [
|
||||
"adminPass",
|
||||
"adminUser"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user