add: documentation
This commit is contained in:
@@ -0,0 +1,940 @@
|
||||
# Terraform Provider для Nubes Cloud - История разработки
|
||||
|
||||
**Дата**: 21 января 2026
|
||||
**Проект**: terraform-provider-mycloud
|
||||
**Цель**: Создание Terraform Provider для управления ресурсами в облачной платформе Nubes
|
||||
|
||||
---
|
||||
|
||||
## 1. Инициализация проекта
|
||||
|
||||
### 1.1 Начальная структура
|
||||
|
||||
**Требования от заказчика:**
|
||||
- Использовать современный **Terraform Plugin Framework** (не старый SDK)
|
||||
- Реализовать ресурс `mycloud_dummy_instance` - "Болванка для тестов"
|
||||
- Схема атрибутов (первоначальная, до изучения реального API):
|
||||
- `parameter` (string, required)
|
||||
- `sleep_ms` (int64, optional)
|
||||
- `silent_quit` (bool, optional)
|
||||
- `fail_in_progress` (bool, optional)
|
||||
- `fail_stage` (int64, optional)
|
||||
|
||||
**Созданные файлы:**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Практика 2026‑02‑02: universal_rebuild, генератор и UX‑предупреждения
|
||||
|
||||
### 14.1 Что сделано
|
||||
- Убрали `resource_realm` из схемы ресурсов: поле скрыто от пользователя, фиксированное значение подставляется генератором на этапе `create` (value = имя сервиса).
|
||||
- Сделали `delete_mode` **опциональным**: по умолчанию `state_only`, пользователь сам решает, когда ставить `delete`/`suspend`.
|
||||
- Добавили **явные предупреждения** в plan/apply при совпадении `display_name` и `resume_if_exists=true`:
|
||||
- отдельное предупреждение для **suspended** ресурса;
|
||||
- предупреждение для уже существующего **active** ресурса.
|
||||
|
||||
### 14.2 Ошибки и как нашли решение
|
||||
- Ошибка: `resource_realm` оставался в схеме и конфигурациях — нарушало правило “поле не должно существовать вообще”.
|
||||
- Решение: фильтрация `resourceRealm` на этапе генерации (скрываем в схеме, подставляем фиксированное значение в create‑params).
|
||||
- Ошибка: `delete_mode` оставался в конфиге тестов, хотя требование — “юзер сам решает, если нужно удалять”.
|
||||
- Решение: убрать из тестовых конфигов и оставить только optional‑поле с дефолтом `state_only`.
|
||||
- Проблема UX: пользователь может “забыть” ресурс и повторить `display_name`, не понимая, что будет adopt/resume без modify.
|
||||
- Решение: предупреждения в plan/apply, чтобы сначала выполнить `plan` и увидеть, что ресурс найден.
|
||||
|
||||
### 14.3 Итоговая логика для adopt/resume
|
||||
- При `resume_if_exists=true` и совпадении имени:
|
||||
- ресурс **принимается в state**;
|
||||
- если он был `suspended`, выполняется `resume`;
|
||||
- **новые параметры не применяются** (modify не делается автоматически).
|
||||
- Пользователь видит предупреждение и может осознанно сделать следующий apply/modify.
|
||||
terra/
|
||||
├── main.go # Точка входа провайдера
|
||||
├── go.mod # Go модуль с зависимостями
|
||||
├── internal/provider/
|
||||
│ ├── provider.go # Настройка клиента и провайдера
|
||||
│ └── dummy_resource.go # Логика CRUD операций
|
||||
└── examples/
|
||||
└── main.tf # Пример использования
|
||||
```
|
||||
|
||||
### 1.2 Выбор версий зависимостей
|
||||
|
||||
**Первоначальные версии:**
|
||||
```go
|
||||
go 1.21
|
||||
github.com/hashicorp/terraform-plugin-framework v1.4.2
|
||||
github.com/hashicorp/terraform-plugin-go v0.19.1
|
||||
```
|
||||
|
||||
**Проблема #1: Попытка обновить до v1.5.0**
|
||||
|
||||
Попробовал использовать более новую версию:
|
||||
```go
|
||||
github.com/hashicorp/terraform-plugin-framework v1.5.0
|
||||
github.com/hashicorp/terraform-plugin-go v0.22.0
|
||||
```
|
||||
|
||||
**Ошибка при сборке:**
|
||||
```
|
||||
unknown field Diagnostics in struct literal of type tfprotov5.CallFunctionResponse
|
||||
tfprotov5Diagnostic.FunctionArgument undefined
|
||||
```
|
||||
|
||||
**Решение:**
|
||||
Вернулся к стабильной версии v1.4.2 - она полностью совместима с terraform-plugin-go v0.19.1
|
||||
|
||||
---
|
||||
|
||||
## 2. Исследование реального API Nubes
|
||||
|
||||
### 2.1 Анализ веб-интерфейса
|
||||
|
||||
**URL личного кабинета:**
|
||||
```
|
||||
https://deck.ngcloud.ru/
|
||||
```
|
||||
|
||||
**Обнаруженные сервисы в каталоге:**
|
||||
- Инфраструктура и сеть: VM, vDC, vApp, Edge Gateway
|
||||
- PaaS: Gitea, RabbitMQ, Nextcloud, Container Registry
|
||||
- DBaaS: PostgreSQL, MariaDB, MongoDB, Redis
|
||||
- **Болванка для тестов** ← наш целевой ресурс
|
||||
|
||||
### 2.2 Анализ Network запросов (DevTools)
|
||||
|
||||
**API Endpoint (реальный):**
|
||||
```
|
||||
https://deck-api.ngcloud.ru/api/v1/index.cfm
|
||||
```
|
||||
|
||||
**Авторизация:**
|
||||
```
|
||||
Authorization: Bearer <JWT_TOKEN>
|
||||
```
|
||||
|
||||
**Токен из Keycloak:**
|
||||
```
|
||||
https://keycloak.nubes.ru/realms/cloud
|
||||
```
|
||||
|
||||
### 2.3 Изучение структуры данных "Болванки"
|
||||
|
||||
**ВАЖНО**: Общий паттерн создания ресурсов описан в отдельном гайде: [resource-creation-pattern.md](resource-creation-pattern.md)
|
||||
|
||||
**Сначала думал, что API простое:**
|
||||
```json
|
||||
POST /api/v1/dummy
|
||||
{
|
||||
"parameter": "test",
|
||||
"sleep_ms": 1000,
|
||||
"fail_in_progress": false
|
||||
}
|
||||
```
|
||||
|
||||
**Но реальность оказалась другой!**
|
||||
|
||||
Через Network увидел реальные поля из созданного инстанса:
|
||||
```json
|
||||
{
|
||||
"whereFail": 1,
|
||||
"durationMs": 0,
|
||||
"failAtStart": false,
|
||||
"failInProgress": false,
|
||||
"resourceRealm": "dummy",
|
||||
"mapExample": null,
|
||||
"bodymessage": null,
|
||||
"jsonExample": null,
|
||||
"yamlExample": null,
|
||||
"someKey": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Критическое открытие: Двухшаговая архитектура API
|
||||
|
||||
**Ошибка в понимании #1:**
|
||||
Изначально думал, что создание - это один запрос POST /dummy
|
||||
|
||||
**Реальность:**
|
||||
API использует двухшаговую модель:
|
||||
|
||||
1. **Шаг 1: Создание инстанса**
|
||||
```http
|
||||
POST /api/v1/index.cfm/instances
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"serviceId": 1, // 1 = Болванка
|
||||
"displayName": "terr",
|
||||
"descr": ""
|
||||
}
|
||||
|
||||
Response: 201 Created
|
||||
Location: ./xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
2. **Шаг 2: Создание операции**
|
||||
```http
|
||||
POST /api/v1/index.cfm/instanceOperations
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"instanceUid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"operation": "create"
|
||||
}
|
||||
|
||||
Response: 201 Created
|
||||
```
|
||||
|
||||
**Это изменило всю архитектуру провайдера!**
|
||||
|
||||
---
|
||||
|
||||
## 3. Переработка провайдера под реальное API
|
||||
|
||||
### 3.1 Упрощение схемы (минимализм)
|
||||
|
||||
**Было (теоретическая схема):**
|
||||
```go
|
||||
type DummyResourceModel struct {
|
||||
Parameter types.String
|
||||
SleepMs types.Int64
|
||||
SilentQuit types.Bool
|
||||
FailInProgress types.Bool
|
||||
FailStage types.Int64
|
||||
// ... много полей
|
||||
}
|
||||
```
|
||||
|
||||
**Стало (реальный минимум):**
|
||||
```go
|
||||
type DummyResourceModel struct {
|
||||
ID types.String `tfsdk:"id"`
|
||||
DisplayName types.String `tfsdk:"display_name"`
|
||||
Description types.String `tfsdk:"description"`
|
||||
Status types.String `tfsdk:"status"`
|
||||
}
|
||||
```
|
||||
|
||||
**Причина:**
|
||||
Заказчик попросил "ПО МИНИМУМУ", чтобы не нагромождать опциями для первой демонстрации.
|
||||
|
||||
### 3.2 Структуры запросов
|
||||
|
||||
```go
|
||||
// Шаг 1: Создание инстанса
|
||||
type CreateInstanceRequest struct {
|
||||
ServiceId int `json:"serviceId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Descr string `json:"descr,omitempty"`
|
||||
}
|
||||
|
||||
// Шаг 2: Операция (create/modify/delete)
|
||||
type CreateOperationRequest struct {
|
||||
InstanceUid string `json:"instanceUid"`
|
||||
Operation string `json:"operation"`
|
||||
}
|
||||
|
||||
// Ответ от API
|
||||
type InstanceResponse struct {
|
||||
Uid string `json:"uid"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Descr string `json:"descr"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Реализация метода Create
|
||||
|
||||
**Ключевые моменты:**
|
||||
|
||||
1. **Двухшаговое создание:**
|
||||
```go
|
||||
func (r *DummyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
// Шаг 1: POST /instances
|
||||
createReq := CreateInstanceRequest{
|
||||
ServiceId: 1, // Болванка
|
||||
DisplayName: data.DisplayName.ValueString(),
|
||||
Descr: data.Description.ValueString(),
|
||||
}
|
||||
|
||||
// Получаем ID из Location header
|
||||
location := httpResp.Header.Get("Location")
|
||||
instanceId := location[2:] // Убираем "./"
|
||||
|
||||
// Шаг 2: POST /instanceOperations
|
||||
operationReq := CreateOperationRequest{
|
||||
InstanceUid: instanceId,
|
||||
Operation: "create",
|
||||
}
|
||||
|
||||
// Шаг 3: Ждем и читаем статус
|
||||
time.Sleep(2 * time.Second)
|
||||
readData, _ := r.readInstance(ctx, instanceId)
|
||||
}
|
||||
```
|
||||
|
||||
2. **Обработка Location header:**
|
||||
API возвращает `Location: ./UUID`, нужно обрезать `./`
|
||||
|
||||
3. **Задержка после создания:**
|
||||
Операция асинхронная, даем 2 секунды на обработку перед чтением
|
||||
|
||||
### 3.4 Реализация Update и Delete
|
||||
|
||||
**Update (modify):**
|
||||
```go
|
||||
func (r *DummyResource) Update(...) {
|
||||
operationReq := CreateOperationRequest{
|
||||
InstanceUid: data.ID.ValueString(),
|
||||
Operation: "modify",
|
||||
}
|
||||
// POST /instanceOperations с operation: "modify"
|
||||
}
|
||||
```
|
||||
|
||||
**Delete:**
|
||||
```go
|
||||
func (r *DummyResource) Delete(...) {
|
||||
operationReq := CreateOperationRequest{
|
||||
InstanceUid: data.ID.ValueString(),
|
||||
Operation: "delete",
|
||||
}
|
||||
// POST /instanceOperations с operation: "delete"
|
||||
}
|
||||
```
|
||||
|
||||
**Read (проверка состояния):**
|
||||
```go
|
||||
func (r *DummyResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) {
|
||||
// GET /instances/{id}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Сборка и локальная установка
|
||||
|
||||
### 4.1 Сборка провайдера
|
||||
|
||||
```bash
|
||||
cd /home/naeel/terra
|
||||
go mod tidy
|
||||
go build -o terraform-provider-nubes
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
Создан бинарный файл `terraform-provider-nubes`
|
||||
|
||||
### 4.2 Установка в локальный репозиторий Terraform
|
||||
|
||||
**Структура плагинов Terraform:**
|
||||
```
|
||||
~/.terraform.d/plugins/
|
||||
└── registry.terraform.io/
|
||||
└── mycloud/
|
||||
└── mycloud/
|
||||
└── 0.1.0/
|
||||
└── linux_amd64/
|
||||
└── terraform-provider-mycloud_v0.1.0
|
||||
```
|
||||
|
||||
**Команда:**
|
||||
```bash
|
||||
mkdir -p ~/.terraform.d/plugins/registry.terraform.io/mycloud/mycloud/0.1.0/linux_amd64
|
||||
cp terraform-provider-nubes ~/.terraform.d/plugins/registry.terraform.io/mycloud/mycloud/0.1.0/linux_amd64/terraform-provider-mycloud_v0.1.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Тестирование с Terraform
|
||||
|
||||
### 5.1 Установка Terraform
|
||||
|
||||
**Проблема #2: Terraform не установлен**
|
||||
```
|
||||
Command 'terraform' not found
|
||||
```
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
sudo snap install terraform --classic
|
||||
```
|
||||
|
||||
**Версия:** Terraform 1.14.3
|
||||
|
||||
### 5.2 Конфигурация Terraform
|
||||
|
||||
**Файл:** `test/main.tf`
|
||||
```text
|
||||
terraform {
|
||||
required_providers {
|
||||
mycloud = {
|
||||
source = "registry.terraform.io/mycloud/mycloud"
|
||||
version = "~> 0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "mycloud" {
|
||||
api_endpoint = "https://deck-api.ngcloud.ru/api/v1/index.cfm"
|
||||
}
|
||||
|
||||
resource "mycloud_dummy_instance" "example" {
|
||||
display_name = "terraform-test"
|
||||
description = "Created by Terraform provider"
|
||||
}
|
||||
|
||||
output "instance_id" {
|
||||
value = mycloud_dummy_instance.example.id
|
||||
}
|
||||
|
||||
output "instance_status" {
|
||||
value = mycloud_dummy_instance.example.status
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Проблемы при инициализации
|
||||
|
||||
**Проблема #3: Несовпадение имен провайдера и ресурса**
|
||||
|
||||
**Первая попытка:**
|
||||
```hcl
|
||||
provider "nubes" { }
|
||||
resource "nubes_dummy_instance" "example" { }
|
||||
```
|
||||
|
||||
**Ошибка:**
|
||||
```
|
||||
The provider mycloud/mycloud does not support resource type "nubes_dummy_instance"
|
||||
```
|
||||
|
||||
**Причина:**
|
||||
Provider TypeName = "mycloud", поэтому ресурсы должны называться `mycloud_*`
|
||||
|
||||
**Решение:**
|
||||
```hcl
|
||||
provider "mycloud" { }
|
||||
resource "mycloud_dummy_instance" "example" { }
|
||||
```
|
||||
|
||||
### 5.4 Проблема с авторизацией
|
||||
|
||||
**Проблема #4: Токен не передается**
|
||||
|
||||
**Ошибка при apply:**
|
||||
```
|
||||
Error: API Error
|
||||
Create instance failed with status 401: "Authorization header expected"
|
||||
```
|
||||
|
||||
**Причина:**
|
||||
Переменная `export MYCLOUD_API_TOKEN` работает только в текущей сессии терминала. При запуске terraform в новом процессе токена нет.
|
||||
|
||||
**Решение:**
|
||||
Устанавливать токен в той же команде:
|
||||
```bash
|
||||
export MYCLOUD_API_TOKEN="..." && terraform apply -auto-approve
|
||||
```
|
||||
|
||||
**JWT токен (из Network DevTools):**
|
||||
```
|
||||
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6...
|
||||
```
|
||||
|
||||
### 5.5 Успешное выполнение
|
||||
|
||||
**Команда:**
|
||||
```bash
|
||||
cd test
|
||||
terraform init
|
||||
export MYCLOUD_API_TOKEN="..." && terraform apply -auto-approve
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
```
|
||||
mycloud_dummy_instance.example: Creating...
|
||||
mycloud_dummy_instance.example: Creation complete after 4s [id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx]
|
||||
|
||||
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
|
||||
|
||||
Outputs:
|
||||
instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
instance_status = ""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Проверка в веб-интерфейсе
|
||||
|
||||
**URL:**
|
||||
```
|
||||
https://deck.ngcloud.ru/services/instance/detail/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- ✅ Экземпляр создан: `terraform-test`
|
||||
- ✅ Описание: "Created by Terraform provider"
|
||||
- ✅ Состояние: `running`
|
||||
- ✅ 2 операции create (код 201)
|
||||
- ✅ Uptime: 2 секунды
|
||||
|
||||
**Примечание заказчика:**
|
||||
> "ОТЛИЧНО! хоть он и не создался, пришлось вручную подтолкнуть НО - РАБОТАЕТ!!!!"
|
||||
|
||||
Операция create выполнилась асинхронно, потребовалось ручное подтверждение в UI, но провайдер корректно создал ресурс!
|
||||
|
||||
---
|
||||
|
||||
## 7. Итоговая архитектура
|
||||
|
||||
### 7.1 Схема взаимодействия
|
||||
|
||||
```
|
||||
Terraform
|
||||
↓
|
||||
terraform-provider-mycloud
|
||||
↓
|
||||
API Gateway (deck-api.ngcloud.ru)
|
||||
↓
|
||||
┌──────────────────────────────────────┐
|
||||
│ POST /instances │ ← Создание инстанса
|
||||
│ → serviceId, displayName, descr │
|
||||
│ ← Location: ./UUID │
|
||||
└──────────────────────────────────────┘
|
||||
↓
|
||||
┌──────────────────────────────────────┐
|
||||
│ POST /instanceOperations │ ← Запуск операции
|
||||
│ → instanceUid, operation │
|
||||
│ ← 201 Created │
|
||||
└──────────────────────────────────────┘
|
||||
↓
|
||||
┌──────────────────────────────────────┐
|
||||
│ GET /instances/{id} │ ← Чтение статуса
|
||||
│ ← uid, status, displayName, descr │
|
||||
└──────────────────────────────────────┘
|
||||
↓
|
||||
Kubernetes Operator (backend)
|
||||
↓
|
||||
CRDs / K8s Resources
|
||||
```
|
||||
|
||||
### 7.2 Реализованные методы
|
||||
|
||||
| Метод | Действие | API вызовы |
|
||||
|----------|------------------------------------------------|-----------------------------------------------|
|
||||
| Create | Создание ресурса | POST /instances → POST /instanceOperations |
|
||||
| Read | Чтение текущего состояния | GET /instances/{id} |
|
||||
| Update | Изменение параметров (через modify операцию) | POST /instanceOperations (operation: modify) |
|
||||
| Delete | Удаление ресурса (через delete операцию) | POST /instanceOperations (operation: delete) |
|
||||
|
||||
### 7.3 Обработка ошибок
|
||||
|
||||
Все ошибки обрабатываются через `resp.Diagnostics`:
|
||||
```go
|
||||
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("..."))
|
||||
resp.Diagnostics.AddWarning("...", "...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Выводы и лучшие практики
|
||||
|
||||
### 8.1 Что узнали
|
||||
|
||||
1. **API не всегда такое, каким кажется на первый взгляд**
|
||||
- Документация может отсутствовать
|
||||
- Реверс-инжиниринг через DevTools - must have навык
|
||||
|
||||
2. **Terraform Plugin Framework v1.4.2 - золотая середина**
|
||||
- Стабильность важнее новизны
|
||||
- v1.5.0 имеет проблемы совместимости
|
||||
|
||||
3. **Асинхронные операции требуют особого подхода**
|
||||
- Нужны задержки или polling
|
||||
- Статус может быть не сразу доступен
|
||||
|
||||
4. **Локальные провайдеры удобны для разработки**
|
||||
- Быстрое тестирование без публикации
|
||||
- Структура каталогов важна для Terraform
|
||||
|
||||
### 8.2 Потенциальные улучшения
|
||||
|
||||
**Для production:**
|
||||
|
||||
1. **Polling вместо sleep:**
|
||||
```go
|
||||
func (r *DummyResource) waitForReady(ctx context.Context, id string) error {
|
||||
timeout := time.After(5 * time.Minute)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
return fmt.Errorf("timeout")
|
||||
case <-ticker.C:
|
||||
resp, _ := r.readInstance(ctx, id)
|
||||
if resp.Status == "running" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Расширенная схема с параметрами:**
|
||||
- whereFail, durationMs, failAtStart
|
||||
- Добавить после успешной демонстрации минимума
|
||||
|
||||
3. **Retry логика для API:**
|
||||
- Обработка временных сбоев сети
|
||||
- Exponential backoff
|
||||
|
||||
4. **Логирование через tflog:**
|
||||
```go
|
||||
import "github.com/hashicorp/terraform-plugin-log/tflog"
|
||||
|
||||
tflog.Debug(ctx, "Creating instance", map[string]any{
|
||||
"display_name": displayName,
|
||||
})
|
||||
```
|
||||
|
||||
5. **Валидация входных данных:**
|
||||
```go
|
||||
"display_name": schema.StringAttribute{
|
||||
Required: true,
|
||||
Validators: []validator.String{
|
||||
stringvalidator.LengthBetween(3, 50),
|
||||
stringvalidator.RegexMatches(
|
||||
regexp.MustCompile(`^[a-zA-Z0-9-]+$`),
|
||||
"must contain only alphanumeric characters and hyphens",
|
||||
),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Следующие шаги
|
||||
|
||||
1. **Добавить другие ресурсы:**
|
||||
- `mycloud_vm` (виртуальная машина)
|
||||
- `mycloud_postgresql` (база данных)
|
||||
- `mycloud_s3_bucket` (хранилище)
|
||||
|
||||
2. **Data Sources:**
|
||||
```hcl
|
||||
data "mycloud_service_catalog" "available" {}
|
||||
```
|
||||
|
||||
3. **Документация:**
|
||||
- Генерация через tfplugindocs
|
||||
- README с примерами
|
||||
- Публикация в Terraform Registry
|
||||
|
||||
4. **CI/CD:**
|
||||
- Автоматическая сборка
|
||||
- Тесты (acceptance tests)
|
||||
- Release workflow
|
||||
|
||||
---
|
||||
|
||||
## 9. Технические детали
|
||||
|
||||
### 9.1 Структура проекта
|
||||
|
||||
```
|
||||
terra/
|
||||
├── main.go # Entry point
|
||||
├── go.mod # Dependencies
|
||||
├── go.sum # Lock file
|
||||
├── terraform-provider-nubes # Binary
|
||||
├── docs/
|
||||
│ └── discovery/
|
||||
│ └── development-journey.md # Этот файл
|
||||
├── examples/
|
||||
│ └── main.tf # Example configuration
|
||||
├── test/
|
||||
│ ├── main.tf # Test configuration
|
||||
│ ├── terraform.tfstate # State file
|
||||
│ └── .terraform/ # Terraform cache
|
||||
└── internal/
|
||||
└── provider/
|
||||
├── provider.go # Provider definition
|
||||
└── dummy_resource.go # Resource implementation
|
||||
```
|
||||
|
||||
### 9.2 Зависимости
|
||||
|
||||
```go
|
||||
module terraform-provider-mycloud
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/hashicorp/terraform-plugin-framework v1.4.2
|
||||
github.com/hashicorp/terraform-plugin-go v0.19.1
|
||||
github.com/hashicorp/terraform-plugin-log v0.9.0
|
||||
)
|
||||
```
|
||||
|
||||
### 9.3 Размер бинарника
|
||||
|
||||
```bash
|
||||
$ ls -lh terraform-provider-nubes
|
||||
-rwxr-xr-x 1 naeel naeel 23M Jan 21 22:12 terraform-provider-nubes
|
||||
```
|
||||
|
||||
### 9.4 Пример Terraform State
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.14.3",
|
||||
"resources": [
|
||||
{
|
||||
"mode": "managed",
|
||||
"type": "mycloud_dummy_instance",
|
||||
"name": "example",
|
||||
"provider": "provider[\"registry.terraform.io/mycloud/mycloud\"]",
|
||||
"instances": [
|
||||
{
|
||||
"schema_version": 0,
|
||||
"attributes": {
|
||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"display_name": "terraform-test",
|
||||
"description": "Created by Terraform provider",
|
||||
"status": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Заключение
|
||||
|
||||
**Итог:** За одну сессию создан работающий Terraform Provider для облачной платформы Nubes Cloud.
|
||||
|
||||
**Ключевые достижения:**
|
||||
- ✅ Изучена реальная архитектура API через реверс-инжиниринг
|
||||
- ✅ Адаптирован провайдер под двухшаговую модель создания ресурсов
|
||||
- ✅ Реализованы все CRUD операции
|
||||
- ✅ Успешно протестировано создание реального ресурса
|
||||
- ✅ Получен работающий минимум для демонстрации заказчику
|
||||
|
||||
**Время разработки:** ~3-4 часа
|
||||
**Статус:** Рабочий прототип, готов к демонстрации
|
||||
|
||||
**Дата завершения:** 21 января 2026, 22:13 UTC+3
|
||||
|
||||
---
|
||||
|
||||
*Документ создан автоматически на основе истории разработки*
|
||||
|
||||
## 11. Post-Mortem Анализ трафика (HAR)
|
||||
|
||||
**Контекст**: После успешного запуска провайдера был получен HAR-файл (`deck.ngcloud.ru.har`) с записью "эталонного" процесса создания ресурса через веб-интерфейс. Это позволило подтвердить правильность реализованной логики.
|
||||
|
||||
### 11.1 Реконструкция потока вызовов (Browser Flow)
|
||||
|
||||
Анализ показал, что UI выполняет процесс в 5 четких этапов:
|
||||
|
||||
1. **Разведка**: Загрузка определений сервиса.
|
||||
2. **Создание болванки (Instance)**:
|
||||
- `POST /api/v1/index.cfm/instances`
|
||||
- Payload: `{ "serviceId": 1, "displayName": "...", "descr": "" }`
|
||||
- Результат: Создан объект в БД, получен `instanceUid`.
|
||||
3. **Создание операции (Operation)**:
|
||||
- Сразу после инстанса создается "wizard" операции.
|
||||
- `POST /api/v1/index.cfm/instanceOperations`
|
||||
- Payload: `{ "instanceUid": "...", "operation": "create" }`
|
||||
- Результат: Получен `instanceOperationUid`.
|
||||
4. **Инициализация параметров (The Key Step)**:
|
||||
- Браузер отправляет **серию POST-запросов**, по одному на каждый параметр формы.
|
||||
- `POST .../instanceOperationCfsParams` отправляется ~10 раз подряд.
|
||||
- **Важно**: Отправляются даже пустые значения и значения по умолчанию (например, `paramValue: ""` или `"false"`). Это "прокликивает" форму.
|
||||
- Параллельно идут вызовы `validate-cfs` и `PUT` для валидации "на лету", что Terraform может пропустить, подавая сразу валидные данные.
|
||||
5. **Исполнение (Run)**:
|
||||
- Финальное нажатие кнопки "Выполнить".
|
||||
- `POST .../instanceOperations/{UID}/run`
|
||||
- Payload: `{}`
|
||||
- После этого UI переходит в режим поллинга статуса (`GET`).
|
||||
|
||||
### 11.2 Выводы для провайдера
|
||||
|
||||
Сравнение реализованного Go-кода с реальным трафиком подтвердило правильность финального решения:
|
||||
* ✅ **Схематика верна**: Instance -> Operation -> Parameters Cycle -> Run.
|
||||
* ✅ **Итерация параметров**: Решение итерироваться по полученному списку `cfsParams` и отправлять их обратно через `POST` полностью соответствует поведению браузера.
|
||||
* ✅ **Run**: Пустой JSON `{}` в `run` endpoint — это стандартный паттерн API.
|
||||
|
||||
Провайдер версии `v15` успешно имитирует шаги 2, 3 и 5, а шаг 4 выполняет программным циклом, что является корректной автоматизацией ручного ввода.
|
||||
|
||||
### 11.3 Анализ Update и Delete (Good Modify vs Bad Delete)
|
||||
|
||||
Были проанализированы дополнительные дампы трафика: `goodmodify.har` (успешное изменение) и `baddelete.har` (проблемное удаление).
|
||||
|
||||
**Good Modify (Update Flow):**
|
||||
* **Операция**: Создается с типом `"operation": "modify"`.
|
||||
* **Параметры**:
|
||||
* Поток **идентичен созданию**: UI снова отправляет серию `POST .../instanceOperationCfsParams`.
|
||||
* Параметры содержат как пустые строки, так и специфические значения (например, `"paramValue": "2"`).
|
||||
* Подтверждено использование `"paramValue": "{}"` для сложных типов (Map/JSON), что валидирует наше исправление с пустым JSON-объектом.
|
||||
* **Вывод**: Логика `Create` и `Update` структурно идентична. Для реализации `Update` в Terraform, нам нужно будет мапить изменения из плана в значения параметров (сейчас мы отправляем дефолты).
|
||||
|
||||
**Bad Delete (Delete Flow):**
|
||||
* **Операция**: Создается с типом `"operation": "delete"`.
|
||||
* **Параметры**:
|
||||
* **Отличие**: В логе **отсутствуют** вызовы `POST .../instanceOperationCfsParams`.
|
||||
* Это логично: удаление обычно не требует параметров.
|
||||
* Однако, вызов `GET .../validate-cfs` всё равно происходит.
|
||||
* **Почему "Bad"?**: Судя по логу, после нажатия `run` система долго поллит статус операции, а в конце проверяет сам инстанс. Если инстанс не исчез, значит операция "зависла" или завершилась с ошибкой на бэкенде.
|
||||
* **Вывод для Провайдера**: Наша универсальная логика (`range resp.CfsParams`) корректно обработает удаление: так как API вернет пустой список параметров для операции `delete`, цикл просто не выполнится ни разу, и провайдер сразу перейдет к `run`. Это правильное поведение.
|
||||
|
||||
## 12. Рефакторинг (Nubes & Tubulus)
|
||||
|
||||
**Задача:** Привести именование ресурсов в соответствие с требованиями заказчика.
|
||||
* Провайдер переименован в `nubes`.
|
||||
* Ресурс `mycloud_dummy_instance` переименован в `nubes_tubulus_instance`.
|
||||
* Все внутренние структуры и методы обновлены.
|
||||
|
||||
**Изменения:**
|
||||
1. **Provider Type**: `nubes` (ранее `mycloud`).
|
||||
2. **Resource Type**: `nubes_tubulus_instance`.
|
||||
3. **Go Structs**: `DummyResource` -> `TubulusResource`.
|
||||
|
||||
**Почему это важно сейчас:** Лучше выполнить переименование на этапе прототипа, чем рефакторить продакшн-код с миграциями стейта.
|
||||
|
||||
|
||||
## 13. Подготовка Клиентского Пакета и Интеграционное Тестирование (22 Января 2026)
|
||||
|
||||
### 13.1 Создание дистрибутива
|
||||
Сформирована папка `client_package` для передачи заказчику:
|
||||
* Скомпилированный бинарный провайдер (Linux).
|
||||
* Конфигурационные файлы Terraform (`resources.tf`).
|
||||
* Инструкция `README.md`.
|
||||
* Скрипты для локальной установки (`~/.terraform.d/plugins/...`).
|
||||
|
||||
### 13.2 Проблема: 401 Unauthorized
|
||||
При попытке `terraform apply` на реальном стенде `https://deck-api.ngcloud.ru/` возникла ошибка авторизации, несмотря на переданный токен.
|
||||
|
||||
**Диагностика:**
|
||||
1. Анализ HAR-файлов пользователя (`keycloak.nubes.ru.har`).
|
||||
2. Выяснилось, что пользователь копировал значение Cookie `KEYCLOAK_IDENTITY` (HS256).
|
||||
3. API требует Bearer Access Token (RS256), который выдается в ответе на запрос `/token` в OpenID Connect flow.
|
||||
4. Токены визуально похожи (JWT), но имеют разные подписи и claims. API Nubes не мог валидировать Cookie-токен.
|
||||
|
||||
### 13.3 Решение и Успешный Запуск
|
||||
1. Обновлена инструкция: указан точный источник токена (Network tab -> `token` response -> `access_token`).
|
||||
2. Получен валидный токен.
|
||||
3. **Result**: Успешное создание ресурса `Bolvanka Full Test` (ID: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`).
|
||||
4. Все параметры (Duration, JSON, Map) передались корректно.
|
||||
|
||||
### 13.4 Планы на v1.1: Service Accounts
|
||||
Обсуждено архитектурное решение для автоматизированных систем (CI/CD, Операторы).
|
||||
* Текущее решение (v1.0): User-based token (для ручных тестов и админов).
|
||||
* Следующий этап (v1.1): Client Credentials Flow. Провайдер будет сам запрашивать токен по `CLIENT_ID` и `CLIENT_SECRET`, исключая ручное копирование из браузера. Это соответствует паттернам, используемым в NiFi Operator.
|
||||
|
||||
## 14. Финализация деплоя Private Registry (v1.0.0)
|
||||
|
||||
**Дата**: 23 января 2026
|
||||
|
||||
### 14.1 Проблемы "Последней мили"
|
||||
После настройки SSL и Ingress возникли сложности с установкой провайдера командой `terraform init`:
|
||||
1. **Checksum Mismatch**: Пересборка кода провайдера меняла хэш бинарника, требуя обновления файла `SHA256SUMS`.
|
||||
2. **S3 Signature Mismatch**: Registry Server выдавал клиенту (Terraform) прямые Presigned URL на S3. Terraform пытался скачать файл, обращаясь к `terra.k8c.ru`, но подпись была сгенерирована для внутреннего хоста. Это приводило к ошибкам 403 Forbidden.
|
||||
3. **GPG Verification Failure**: Terraform (современные версии) требует обязательной подписи бинарных файлов GPG ключом. Без этого установка прерывалась с ошибкой `authentication signature from unknown issuer`.
|
||||
|
||||
### 14.2 Комплексное решение
|
||||
Для устранения всех проблем была проведена глубокая доработка инфраструктуры доставки:
|
||||
|
||||
#### A. Архитектура "Registry Proxy"
|
||||
Мы отказались от схемы Redirect/Presigned URL в пользу проксирования.
|
||||
* **Было**: Registry -> 302 Redirect -> S3 (клиент качает сам, ломая подписи).
|
||||
* **Стало**: Registry -> Stream Copy -> S3 -> Client.
|
||||
* Registry Server получил новый эндпоинт `/v1/proxy`, который транслирует файлы из S3 наружу. Это изолировало Terraform от сложностей S3 авторизации.
|
||||
|
||||
#### B. Внедрение GPG Подписи
|
||||
Terraform Registry Protocol требует передачи публичных ключей (GPG Public Keys) прямо в ответе API на запрос загрузки версии.
|
||||
1. Сгенерирована пара ключей GPG для `nubes-provider`.
|
||||
2. Файл `terrafrom-provider-nubes_1.0.0_SHA256SUMS` подписан (`gpg --detach-sign`), создан `.sig` файл.
|
||||
3. **Критический шаг**: Публичный ключ GPG (ASCII Armor) был "зашит" прямо в исходный код Registry Server (`main.go`). Теперь сервер при отдаче ссылки на скачивание также отдает ключ, которым клиент может проверить подпись.
|
||||
|
||||
### 14.3 Итог
|
||||
Провайдер `terra.k8c.ru/nubes/nubes` версии `1.0.0` успешно инициализируется!
|
||||
```
|
||||
Installed terra.k8c.ru/nubes/nubes v1.0.0 (self-signed, key ID FFE0F4D723F14BCA)
|
||||
Terraform has been successfully initialized!
|
||||
```
|
||||
Это полностью закрывает вопрос доставки провайдера (Provider Delivery) и позволяет переходить к полноценному использованию.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 13. Универсальный архитектурный паттерн "Nubes Flow" (Master Pattern)
|
||||
|
||||
Для любого нового ресурса в Nubes Cloud ОБЯЗАТЕЛЬНО соблюдение следующего протокола вызовов. Любое отклонение приведет к тому, что ресурс зависнет в статусе "Not Created" или не применит параметры.
|
||||
|
||||
### 13.1 Пятиэтапный жизненный цикл (The 5-Step Execution)
|
||||
|
||||
1. **POST /instances**: Создание "болванки" инстанса. Получаем .
|
||||
2. **POST /instanceOperations**: Создание "визарда" операции (, или ). Получаем .
|
||||
3. **GET /instanceOperations/{uid}?fields=cfsParams**: **КРИТИЧЕСКИЙ ШАГ**.
|
||||
- Мы ОБЯЗАНЫ запросить у сервера список параметров, которые ОН ожидает для этой операции.
|
||||
- Даже если мы знаем ID параметров, этот вызов инициализирует их состояние на бэкенде.
|
||||
4. **Цикл POST /instanceOperationCfsParams**: Настройка параметров.
|
||||
- Для каждого параметра из списка, полученного на шаге 3, мы отправляем .
|
||||
- Если у нас есть значение из Terraform — отправляем его.
|
||||
- Если значения нет — отправляем (или пустой JSON/Array , для сложных типов).
|
||||
- **ОШИБКА**: Пропуск "необязательных" параметров часто ломает финальный запуск.
|
||||
5. **POST /instanceOperations/{uid}/run**: Финальное подтверждение (Кнопка "Выполнить").
|
||||
- **Payload**: ОБЯЗАТЕЛЬНО пустой JSON-объект `{}`. Отправка `nil` или пустого тела приведет к игнорированию команды сервером.
|
||||
|
||||
### 13.2 Правила для "Умного Агента" (Cheat Sheet)
|
||||
|
||||
| Действие | Код статуса | Нюанс |
|
||||
| :--- | :--- | :--- |
|
||||
| **DisplayName** | 400 Bad Request | Должен быть уникальным. Используй префиксы или timestamp. |
|
||||
| **Run Payload** | | Никогда не отправляй пустое тело. Только валидный пустой JSON. |
|
||||
| **Parameters** | Full Sync | Отправляй ВСЕ параметры, которые вернул GET на шаге 3. |
|
||||
| **Placement** | / | Значение не принимается для S3. |
|
||||
|
||||
### 13.3 Реализация в коде (client_impl.go)
|
||||
|
||||
Вся эта логика инкапсулирована в методе `CreateInstance`. При написании нового ресурса (например, Postgres или MariaDB), достаточно просто подготовить список специфичных параметров (`InstanceParam`) и вызвать этот универсальный метод. Метод сам выполнит GET запросы, заполнит дефолты и нажмет "Выполнить".
|
||||
|
||||
---
|
||||
|
||||
## 13. Универсальный архитектурный паттерн "Nubes Flow" (Master Pattern)
|
||||
|
||||
Для любого нового ресурса в Nubes Cloud ОБЯЗАТЕЛЬНО соблюдение следующего протокола вызовов. Любое отклонение приведет к тому, что ресурс зависнет в статусе "Not Created" или не применит параметры.
|
||||
|
||||
### 13.1 Пятиэтапный жизненный цикл (The 5-Step Execution)
|
||||
|
||||
1. **POST /instances**: Создание "болванки" инстанса. Получаем `instanceUid`.
|
||||
2. **POST /instanceOperations**: Создание "визарда" операции (`create`, `modify` или `delete`). Получаем `instanceOperationUid`.
|
||||
3. **GET /instanceOperations/{uid}?fields=cfsParams**: **КРИТИЧЕСКИЙ ШАГ**.
|
||||
- Мы ОБЯЗАНЫ запросить у сервера список параметров, которые ОН ожидает для этой операции.
|
||||
- Даже если мы знаем ID параметров, этот вызов инициализирует их состояние на бэкенде.
|
||||
4. **Цикл POST /instanceOperationCfsParams**: Настройка параметров.
|
||||
- Для каждого параметра из списка, полученного на шаге 3, мы отправляем `POST`.
|
||||
- Если у нас есть значение из Terraform — отправляем его.
|
||||
- Если значения нет — отправляем `defaultValue` (или пустой JSON/Array `{}`, `[]` для сложных типов).
|
||||
- **ОШИБКА**: Пропуск "необязательных" параметров часто ломает финальный запуск.
|
||||
5. **POST /instanceOperations/{uid}/run**: Финальное подтверждение (Кнопка "Выполнить").
|
||||
- **Payload**: ОБЯЗАТЕЛЬНО пустой JSON-объект `{}`. Отправка `nil` или пустого тела приведет к игнорированию команды сервером.
|
||||
|
||||
### 13.2 Правила для "Умного Агента" (Cheat Sheet)
|
||||
|
||||
| Действие | Код статуса | Нюанс |
|
||||
| :--- | :--- | :--- |
|
||||
| **DisplayName** | 400 Bad Request | Должен быть уникальным. Используй префиксы или timestamp. |
|
||||
| **Run Payload** | `{}` | Никогда не отправляй пустое тело. Только валидный пустой JSON. |
|
||||
| **Parameters** | Full Sync | Отправляй ВСЕ параметры, которые вернул GET на шаге 3. |
|
||||
| **Placement** | `HOT`/`COLD` | Значение `default` не принимается для S3. |
|
||||
|
||||
### 13.3 Реализация в коде (client_impl.go)
|
||||
|
||||
Вся эта логика инкапсулирована в методе `CreateInstance`. При написании нового ресурса (например, Postgres или MariaDB), достаточно просто подготовить список специфичных параметров (`InstanceParam`) и вызвать этот универсальный метод. Метод сам выполнит GET запросы, заполнит дефолты и нажмет "Выполнить".
|
||||
Reference in New Issue
Block a user