add: internal package (provider, core, registrykeys)

This commit is contained in:
“Naeel”
2026-06-30 15:42:35 +04:00
parent eb2db782f3
commit 7f3fbd5041
25 changed files with 10796 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
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
}
+137
View File
@@ -0,0 +1,137 @@
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, "./")
}
+112
View File
@@ -0,0 +1,112 @@
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
}
@@ -0,0 +1,110 @@
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
}
@@ -0,0 +1,192 @@
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"
}
+132
View File
@@ -0,0 +1,132 @@
# 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.
+924
View File
@@ -0,0 +1,924 @@
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")
}
+144
View File
@@ -0,0 +1,144 @@
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),
)
}
+674
View File
@@ -0,0 +1,674 @@
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
}
}
}
+517
View File
@@ -0,0 +1,517 @@
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
}
+359
View File
@@ -0,0 +1,359 @@
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
+197
View File
@@ -0,0 +1,197 @@
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
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,
}
}
+491
View File
@@ -0,0 +1,491 @@
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
}
+255
View File
@@ -0,0 +1,255 @@
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
}
}
@@ -0,0 +1,120 @@
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)...)
}
+133
View File
@@ -0,0 +1,133 @@
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
+155
View File
@@ -0,0 +1,155 @@
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{}
}
+144
View File
@@ -0,0 +1,144 @@
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),
)
}
+639
View File
@@ -0,0 +1,639 @@
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)
}
}
}
}
+146
View File
@@ -0,0 +1,146 @@
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),
)
}
+766
View File
@@ -0,0 +1,766 @@
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)
}
}
}
}
+939
View File
@@ -0,0 +1,939 @@
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)
}
}
}
}