Files
tf_provider/internal/provider/tubulus_resource.go
T

1050 lines
38 KiB
Go

package provider
// [НЕ ИЗМЕНЯТЬ !!!!]
// Данный ресурс реализует паттерн "Nubes Flow" для тестовой болванки (Tubulus).
// ВАЖНО: Ресурс предназначен для тестирования жизненного цикла и не имеет операции Update.
// Любое изменение параметров приведет к пересозданию инстанса.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings" // Added strings
"time"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)
var _ resource.Resource = &TubulusResource{}
var _ resource.ResourceWithImportState = &TubulusResource{}
var _ resource.ResourceWithModifyPlan = &TubulusResource{}
func NewTubulusResource() resource.Resource {
return &TubulusResource{}
}
type TubulusResource struct {
client *NubesClient
}
type TubulusResourceModel struct {
ID types.String `tfsdk:"id"`
DisplayName types.String `tfsdk:"display_name"`
Description types.String `tfsdk:"description"`
Status types.String `tfsdk:"status"`
DurationMs types.Int64 `tfsdk:"duration_ms"`
FailAtStart types.Bool `tfsdk:"fail_at_start"`
FailInProgress types.Bool `tfsdk:"fail_in_progress"`
WhereFail types.Int64 `tfsdk:"where_fail"`
BodyMessage types.String `tfsdk:"body_message"`
ResourceRealm types.String `tfsdk:"resource_realm"`
MapExample types.String `tfsdk:"map_example"`
JsonExample types.String `tfsdk:"json_example"`
YamlExample types.String `tfsdk:"yaml_example"`
}
type CreateInstanceRequest struct {
ServiceId int `json:"serviceId"`
DisplayName string `json:"displayName"`
Descr string `json:"descr,omitempty"`
}
type CreateOperationRequest struct {
InstanceUid string `json:"instanceUid"`
Operation string `json:"operation"`
}
type InstanceResponse struct {
Uid string `json:"instanceUid"`
DisplayName string `json:"displayName"`
Descr string `json:"descr"`
Status string `json:"explainedStatus"` // Правильное поле из API
}
type GetInstanceResponse struct {
Instance InstanceResponse `json:"instance"`
}
// GetOperationResponse and associated types removed as they are now in client_impl.go
type CreateCfsParamRequest struct {
InstanceOperationCfsParamUid string `json:"instanceOperationCfsParamUid,omitempty"`
InstanceOperationUid string `json:"instanceOperationUid"`
SvcOperationCfsParamId int `json:"svcOperationCfsParamId"`
ParamValue string `json:"paramValue"`
}
func (r *TubulusResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_tubulus_instance"
}
func (r *TubulusResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Nubes Tubulus Instance resource with Gemini AI integration",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Instance identifier (UUID)",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"display_name": schema.StringAttribute{
MarkdownDescription: "Instance display name",
Required: true,
},
"description": schema.StringAttribute{
MarkdownDescription: "Instance description",
Optional: true,
},
"status": schema.StringAttribute{
MarkdownDescription: "Current status of the instance",
Computed: true,
},
"duration_ms": schema.Int64Attribute{
MarkdownDescription: "Duration in ms (sleep before completion)",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
},
"fail_at_start": schema.BoolAttribute{
MarkdownDescription: "If true, job fails immediately",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
"fail_in_progress": schema.BoolAttribute{
MarkdownDescription: "If true, job fails during execution",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
"where_fail": schema.Int64Attribute{
MarkdownDescription: "1=prepare, 2=data_fill, 3=after_vault",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
},
"body_message": schema.StringAttribute{
MarkdownDescription: "Message to be sent to Vault",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"resource_realm": schema.StringAttribute{
MarkdownDescription: "Platform realm (e.g. dummy)",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"map_example": schema.StringAttribute{
MarkdownDescription: "Example map input (JSON string)",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"json_example": schema.StringAttribute{
MarkdownDescription: "Example JSON input (JSON string)",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"yaml_example": schema.StringAttribute{
MarkdownDescription: "Example YAML input",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
func (r *TubulusResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
// AI Logic disabled as per user instruction.
}
func (r *TubulusResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*NubesClient)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData),
)
return
}
r.client = client
}
// doRequestWithRetry executes an HTTP request with retries for transient errors.
func (r *TubulusResource) doRequestWithRetry(ctx context.Context, httpReq *http.Request) (*http.Response, error) {
var resp *http.Response
var err error
maxRetries := 3
baseWait := 2 * time.Second
for i := 0; i <= maxRetries; i++ {
// Reset body for retry if needed
if i > 0 && httpReq.GetBody != nil {
body, err := httpReq.GetBody()
if err != nil {
log.Printf("[ERROR] Failed to get request body for retry: %s", err)
return nil, err
}
httpReq.Body = body
}
resp, err = r.client.HttpClient.Do(httpReq)
// Case 1: Client/Network error
if err != nil {
// Check context cancellation first
if ctx.Err() != nil {
return nil, ctx.Err()
}
errMsg := err.Error()
if i < maxRetries && (strings.Contains(errMsg, "TLS handshake timeout") ||
strings.Contains(errMsg, "connection refused") ||
strings.Contains(errMsg, "connection reset") ||
strings.Contains(errMsg, "timeout") ||
strings.Contains(errMsg, "EOF")) {
log.Printf("[WARN] Network error '%s' (attempt %d/%d). Retrying in %s...", errMsg, i+1, maxRetries+1, baseWait*time.Duration(i+1))
time.Sleep(baseWait * time.Duration(i+1))
continue
}
return nil, err
}
// Case 2: Server error (5xx)
if i < maxRetries && resp.StatusCode >= 500 && resp.StatusCode < 600 {
log.Printf("[WARN] Server error %d (attempt %d/%d). Retrying in %s...", resp.StatusCode, i+1, maxRetries+1, baseWait*time.Duration(i+1))
resp.Body.Close()
time.Sleep(baseWait * time.Duration(i+1))
continue
}
// If success or 4xx, return
return resp, nil
}
return resp, err
}
func (r *TubulusResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data TubulusResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// [NUBES SOFT DELETE LOGIC]
// Сначала проверяем, нет ли уже такого инстанса в статусе suspended.
// Если есть — выполняем resume вместо создания нового.
existingInstance, _ := r.findInstance(ctx, data.DisplayName.ValueString())
if existingInstance != nil && existingInstance.Status == "suspended" {
log.Printf("[INFO] Found existing suspended instance %s. Triggering RESUME.", existingInstance.Uid)
instanceId := existingInstance.Uid
data.ID = types.StringValue(instanceId)
operationReq := CreateOperationRequest{
InstanceUid: instanceId,
Operation: "resume",
}
jsonData, err := json.Marshal(operationReq)
if err == nil {
httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData))
if err == nil {
httpReq.Header.Set("Content-Type", "application/json")
if r.client.ApiToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
}
httpResp, _ := r.doRequestWithRetry(ctx, httpReq)
if httpResp != nil && (httpResp.StatusCode == http.StatusCreated || httpResp.StatusCode == http.StatusOK) {
location := httpResp.Header.Get("Location")
if location != "" {
operationId := location[2:]
// [FIX 2026-01-27]: Ensure params are submitted even for RESUME if needed.
// Use "data" model which contains current plan values.
if err := r.submitOperationParams(ctx, operationId, data); err != nil {
log.Printf("[WARN] Failed to submit params for resume operation (might be non-fatal): %s", err)
}
// [FIX] User requirement: Bolvanka resume must be fast (< 1 min).
// Increased to 3m as sometimes cloud is slow.
if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 3*time.Minute); err == nil {
data.Status = types.StringValue("running")
// Ensure computed optional fields are checked for Unknown and set to Null if needed
if data.FailAtStart.IsUnknown() { data.FailAtStart = types.BoolNull() }
if data.FailInProgress.IsUnknown() { data.FailInProgress = types.BoolNull() }
if data.WhereFail.IsUnknown() { data.WhereFail = types.Int64Null() }
if data.BodyMessage.IsUnknown() { data.BodyMessage = types.StringNull() }
if data.ResourceRealm.IsUnknown() { data.ResourceRealm = types.StringNull() }
if data.MapExample.IsUnknown() { data.MapExample = types.StringNull() }
if data.JsonExample.IsUnknown() { data.JsonExample = types.StringNull() }
if data.YamlExample.IsUnknown() { data.YamlExample = types.StringNull() }
if data.DurationMs.IsUnknown() { data.DurationMs = types.Int64Null() }
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
// CRITICAL FIX: Return immediately after successful resume!
// Do not fall through to Create logic.
return
} else {
// If wait failed (timeout or error), report it.
resp.Diagnostics.AddError("Resume Failed", fmt.Sprintf("Failed to resume existing instance: %s", err))
return
}
}
httpResp.Body.Close()
}
}
}
// Если resume не удался, попробуем обычное создание (может имя освободилось)
}
// Step 1: Create instance
createReq := CreateInstanceRequest{
ServiceId: 1, // Болванка
DisplayName: data.DisplayName.ValueString(),
Descr: data.Description.ValueString(),
}
jsonData, err := json.Marshal(createReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal request: %s", err))
return
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instances", bytes.NewBuffer(jsonData))
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create request: %s", err))
return
}
httpReq.Header.Set("Content-Type", "application/json")
if r.client.ApiToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
}
httpResp, err := r.doRequestWithRetry(ctx, httpReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create instance: %s", err))
return
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(httpResp.Body)
resp.Diagnostics.AddError(
"API Error",
fmt.Sprintf("Create instance failed with status %d: %s", httpResp.StatusCode, string(body)),
)
return
}
// Extract instance ID from Location header
location := httpResp.Header.Get("Location")
if location == "" {
resp.Diagnostics.AddError("API Error", "No Location header in response")
return
}
// Location format: "./UUID"
instanceId := location[2:] // Remove "./"
data.ID = types.StringValue(instanceId)
// Step 2: Create operation
// Инициализируем операцию 'create'. Это создает "визард" в Deck API.
operationReq := CreateOperationRequest{
InstanceUid: instanceId,
Operation: "create",
}
jsonData, err = json.Marshal(operationReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err))
return
}
httpReq, err = http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData))
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err))
return
}
httpReq.Header.Set("Content-Type", "application/json")
if r.client.ApiToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
}
httpResp, err = r.doRequestWithRetry(ctx, httpReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation: %s", err))
return
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(httpResp.Body)
resp.Diagnostics.AddError(
"API Error",
fmt.Sprintf("Create operation failed with status %d: %s", httpResp.StatusCode, string(body)),
)
return
}
// Extract operation ID from Location header
location = httpResp.Header.Get("Location")
if location == "" {
resp.Diagnostics.AddError("API Error", "No Location header in operation response")
return
}
operationId := location[2:] // Remove "./"
// Step 3: Submit operation parameters to trigger execution
// Синхронизация параметров и запуск выполнения (Full Sync + Run).
if err := r.submitOperationParams(ctx, operationId, data); err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err))
return
}
// Step 4: Wait for completion and read status
// [MODIFIED BY USER REQUEST]: Timeout reduced to 1 minute.
if err := r.waitForOperationAndInstanceStatus(ctx, operationId, instanceId, 1*time.Minute); err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to reach running status: %s", err))
return
}
readData, err := r.readInstance(ctx, instanceId)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after creation: %s", err))
return
}
data.Status = types.StringValue(readData.Status)
// Ensure computed optional fields are checked for Unknown and set to Null if needed
if data.FailAtStart.IsUnknown() { data.FailAtStart = types.BoolNull() }
if data.FailInProgress.IsUnknown() { data.FailInProgress = types.BoolNull() }
if data.WhereFail.IsUnknown() { data.WhereFail = types.Int64Null() }
if data.BodyMessage.IsUnknown() { data.BodyMessage = types.StringNull() }
if data.ResourceRealm.IsUnknown() { data.ResourceRealm = types.StringNull() }
if data.MapExample.IsUnknown() { data.MapExample = types.StringNull() }
if data.JsonExample.IsUnknown() { data.JsonExample = types.StringNull() }
if data.YamlExample.IsUnknown() { data.YamlExample = types.StringNull() }
if data.DurationMs.IsUnknown() { data.DurationMs = types.Int64Null() }
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TubulusResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data TubulusResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
instanceResp, err := r.readInstance(ctx, data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance: %s", err))
return
}
data.Status = types.StringValue(instanceResp.Status)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TubulusResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
// [НЕ ИЗМЕНЯТЬ !!!!]
// Ресурс считается иммутабельным в рамках текущих тестов.
// Метод сохранен для совместимости, но использование 'modify' в Tubulus часто не дает результата на бэкенде.
var data TubulusResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// Trigger modify operation
operationReq := CreateOperationRequest{
InstanceUid: data.ID.ValueString(),
Operation: "modify",
}
jsonData, err := json.Marshal(operationReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err))
return
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData))
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err))
return
}
httpReq.Header.Set("Content-Type", "application/json")
if r.client.ApiToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
}
httpResp, err := r.doRequestWithRetry(ctx, httpReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to update instance: %s", err))
return
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(httpResp.Body)
resp.Diagnostics.AddError(
"API Error",
fmt.Sprintf("Modify operation failed with status %d: %s", httpResp.StatusCode, string(body)),
)
return
}
// Extract operation ID and submit parameters
location := httpResp.Header.Get("Location")
if location != "" {
operationId := location[2:]
if err := r.submitOperationParams(ctx, operationId, data); err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to submit operation parameters: %s", err))
return
}
// Wait for modify to complete using strict logic
if err := r.waitForOperationAndInstanceStatus(ctx, operationId, data.ID.ValueString(), 1*time.Minute); err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Instance failed to complete modify operation: %s", err))
return
}
}
// Read updated state (no sleep needed after strict wait)
readData, err := r.readInstance(ctx, data.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read instance after update: %s", err))
return
}
data.Status = types.StringValue(readData.Status)
// Ensure computed optional fields are checked for Unknown and set to Null if needed
if data.FailAtStart.IsUnknown() { data.FailAtStart = types.BoolNull() }
if data.FailInProgress.IsUnknown() { data.FailInProgress = types.BoolNull() }
if data.WhereFail.IsUnknown() { data.WhereFail = types.Int64Null() }
if data.BodyMessage.IsUnknown() { data.BodyMessage = types.StringNull() }
if data.ResourceRealm.IsUnknown() { data.ResourceRealm = types.StringNull() }
if data.MapExample.IsUnknown() { data.MapExample = types.StringNull() }
if data.JsonExample.IsUnknown() { data.JsonExample = types.StringNull() }
if data.YamlExample.IsUnknown() { data.YamlExample = types.StringNull() }
if data.DurationMs.IsUnknown() { data.DurationMs = types.Int64Null() }
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TubulusResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data TubulusResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// [NUBES SOFT DELETE]
// Вместо 'delete' отправляем 'suspend'. Ресурс перейдет в 14-дневный период удержания.
operationReq := CreateOperationRequest{
InstanceUid: data.ID.ValueString(),
Operation: "suspend",
}
jsonData, err := json.Marshal(operationReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to marshal operation request: %s", err))
return
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations", bytes.NewBuffer(jsonData))
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create operation request: %s", err))
return
}
httpReq.Header.Set("Content-Type", "application/json")
if r.client.ApiToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
}
httpResp, err := r.doRequestWithRetry(ctx, httpReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend instance: %s", err))
return
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusCreated && httpResp.StatusCode != http.StatusNotFound {
body, _ := io.ReadAll(httpResp.Body)
resp.Diagnostics.AddError(
"API Error",
fmt.Sprintf("Suspend operation failed with status %d: %s", httpResp.StatusCode, string(body)),
)
return
}
// Extract operation ID and wait for completion (without parameters)
location := httpResp.Header.Get("Location")
if location != "" {
operationId := location[2:]
// Run suspend operation (no params needed usually)
runReq, _ := http.NewRequestWithContext(ctx, "POST", r.client.ApiEndpoint+"/instanceOperations/"+operationId+"/run", bytes.NewBuffer([]byte("{}")))
runReq.Header.Set("Content-Type", "application/json")
runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
r.doRequestWithRetry(ctx, runReq)
// Wait for suspend to complete using strict logic (ignore status check)
r.waitForOperationAndInstanceStatus(ctx, operationId, data.ID.ValueString(), 1*time.Minute)
}
}
func (r *TubulusResource) findInstance(ctx context.Context, name string) (*InstanceResponse, error) {
page := 1
pageSize := 100
for {
url := fmt.Sprintf("%s/instances?page=%d&size=%d", r.client.ApiEndpoint, page, pageSize)
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
if r.client.ApiToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
}
httpResp, err := r.doRequestWithRetry(ctx, httpReq)
if err != nil {
return nil, err
}
var instancesResp struct {
Total int `json:"total"`
Results []struct {
ID string `json:"instanceUid"`
Name string `json:"displayName"`
Status string `json:"explainedStatus"`
Svc string `json:"svc"`
} `json:"results"`
}
err = json.NewDecoder(httpResp.Body).Decode(&instancesResp)
httpResp.Body.Close()
if err != nil {
return nil, err
}
for _, instance := range instancesResp.Results {
// [FIX] Checking both "Bolvanka" and "Болванка" just in case, but usually it's "Болванка"
if instance.Name == name && (instance.Svc == "Болванка" || instance.Svc == "Bolvanka") {
return &InstanceResponse{
Uid: instance.ID,
DisplayName: instance.Name,
Status: instance.Status,
}, nil
}
}
if len(instancesResp.Results) == 0 || page*pageSize >= instancesResp.Total {
break
}
page++
}
return nil, fmt.Errorf("instance not found")
}
func (r *TubulusResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
func (r *TubulusResource) readInstance(ctx context.Context, id string) (*InstanceResponse, error) {
// [FIX 2026-01-27]: Explicitly request explainedStatus to ensure we get the correct status
// Default response might not include computed/expensive fields.
httpReq, err := http.NewRequestWithContext(ctx, "GET", r.client.ApiEndpoint+"/instances/"+id+"?fields=instanceUid,displayName,descr,explainedStatus", nil)
if err != nil {
return nil, err
}
if r.client.ApiToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
}
httpResp, err := r.doRequestWithRetry(ctx, httpReq)
if err != nil {
return nil, err
}
defer httpResp.Body.Close()
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return nil, err
}
if httpResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("read request failed with status %d: %s", httpResp.StatusCode, string(body))
}
var instanceResp GetInstanceResponse
if err := json.Unmarshal(body, &instanceResp); err != nil {
return nil, err
}
return &instanceResp.Instance, nil
}
func (r *TubulusResource) submitOperationParams(ctx context.Context, operationUid string, data TubulusResourceModel) error {
// Step 1: Get operation details with parameters
httpReq, err := http.NewRequestWithContext(ctx, "GET",
r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"?fields=cfsParams", nil)
if err != nil {
return fmt.Errorf("unable to create request: %s", err)
}
if r.client.ApiToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
}
httpResp, err := r.doRequestWithRetry(ctx, httpReq)
if err != nil {
return fmt.Errorf("unable to get operation: %s", err)
}
defer httpResp.Body.Close()
body, err := io.ReadAll(httpResp.Body)
if err != nil {
return fmt.Errorf("unable to read response: %s", err)
}
if httpResp.StatusCode != http.StatusOK {
return fmt.Errorf("get operation failed with status %d: %s", httpResp.StatusCode, string(body))
}
var getResp GetOperationResponse
if err := json.Unmarshal(body, &getResp); err != nil {
return fmt.Errorf("unable to unmarshal response: %s", err)
}
operationResp := getResp.InstanceOperation
// Step 2: Submit each parameter to ensure they exist (populate UIDs)
// ВАЖНО: Мы ДОЛЖНЫ пройтись по всем параметрам, которые вернул API.
// Бэкенд Nubes инициализирует значения параметров только после их явной отправки (POST).
// Если пропустить параметр, Deck может не разрешить выполнение (Run).
for _, param := range operationResp.CfsParams {
// Determine value to send: existing value > default value > empty string
valToSend := ""
// Try to match parameter by Code or Name with Terraform data
// Assuming param.Code or param.Name matches the keys we know
// [FIX 2026-01-27]: Added SvcOperationCfsParam as fallback key
key := param.Code
if key == "" {
key = param.Name
}
if key == "" {
key = param.SvcOperationCfsParam
}
// Helper to override valToSend if TF attribute is set
if key == "durationMs" && !data.DurationMs.IsNull() && !data.DurationMs.IsUnknown() {
valToSend = fmt.Sprintf("%d", data.DurationMs.ValueInt64())
} else if key == "failAtStart" && !data.FailAtStart.IsNull() && !data.FailAtStart.IsUnknown() {
valToSend = fmt.Sprintf("%t", data.FailAtStart.ValueBool())
} else if key == "failInProgress" && !data.FailInProgress.IsNull() && !data.FailInProgress.IsUnknown() {
valToSend = fmt.Sprintf("%t", data.FailInProgress.ValueBool())
} else if key == "whereFail" && !data.WhereFail.IsNull() && !data.WhereFail.IsUnknown() {
valToSend = fmt.Sprintf("%d", data.WhereFail.ValueInt64())
} else if key == "bodymessage" && !data.BodyMessage.IsNull() && !data.BodyMessage.IsUnknown() {
valToSend = data.BodyMessage.ValueString()
} else if key == "resourceRealm" && !data.ResourceRealm.IsNull() && !data.ResourceRealm.IsUnknown() {
valToSend = data.ResourceRealm.ValueString()
} else if key == "mapExample" && !data.MapExample.IsNull() && !data.MapExample.IsUnknown() {
valToSend = data.MapExample.ValueString()
} else if key == "jsonExample" && !data.JsonExample.IsNull() && !data.JsonExample.IsUnknown() {
valToSend = data.JsonExample.ValueString()
} else if key == "yamlExample" && !data.YamlExample.IsNull() && !data.YamlExample.IsUnknown() {
valToSend = data.YamlExample.ValueString()
} else {
// Fallback to existing logic if not set in TF
if param.ParamValue != nil {
valToSend = *param.ParamValue
} else if param.DefaultValue != nil {
valToSend = *param.DefaultValue
}
}
// Fix specific data type formatting
// [CRITICAL WARNING]: DO NOT CHANGE THIS LOGIC for MAP/JSON/ARRAY!
// Sending empty string "" for map/json/array triggers 400 Bad Request (invalid format).
// Always send "{}" for empty maps/jsons and "[]" for empty lists.
if valToSend == "" || valToSend == "\"\"" {
if param.DataType == "map" || param.DataType == "json" {
valToSend = "{}"
} else if param.DataType == "array" || param.DataType == "list" {
valToSend = "[]"
}
}
// [FIX 2026-01-27]: Если значение не менялось (пустое или дефолтное) и это не обязательный параметр,
// всё равно отправляем POST, чтобы инициализировать параметр на бэкенде.
// НО: для корректного формирования JSON строки, нужно убедиться, что кавычки экранированы, если это JSON внутри JSON.
// В простом случае мы шлем строку.
paramReq := CreateCfsParamRequest{
InstanceOperationUid: operationUid,
SvcOperationCfsParamId: param.SvcOperationCfsParamId,
ParamValue: valToSend,
}
jsonData, err := json.Marshal(paramReq)
if err != nil {
return fmt.Errorf("unable to marshal param request: %s", err)
}
// USE POST to create/set the parameter
httpReq, err = http.NewRequestWithContext(ctx, "POST",
r.client.ApiEndpoint+"/instanceOperationCfsParams", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("unable to create param request: %s", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if r.client.ApiToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
}
httpResp, err = r.doRequestWithRetry(ctx, httpReq)
if err != nil {
return fmt.Errorf("unable to submit parameter: %s", err)
}
respBody, _ := io.ReadAll(httpResp.Body)
httpResp.Body.Close()
if httpResp.StatusCode != http.StatusCreated &&
httpResp.StatusCode != http.StatusOK &&
httpResp.StatusCode != http.StatusNoContent {
// Log but maybe continue? No, if we fail to set param, run will likely fail.
return fmt.Errorf("submit parameter id %d failed with status %d: %s",
param.SvcOperationCfsParamId, httpResp.StatusCode, string(respBody))
}
}
// Step 3: Run the operation
runReq, err := http.NewRequestWithContext(ctx, "POST",
r.client.ApiEndpoint+"/instanceOperations/"+operationUid+"/run", bytes.NewBuffer([]byte("{}")))
if err != nil {
return fmt.Errorf("unable to create run request: %s", err)
}
runReq.Header.Set("Content-Type", "application/json")
if r.client.ApiToken != "" {
runReq.Header.Set("Authorization", "Bearer "+r.client.ApiToken)
}
runResp, err := r.doRequestWithRetry(ctx, runReq)
if err != nil {
return fmt.Errorf("unable to run operation: %s", err)
}
defer runResp.Body.Close()
if runResp.StatusCode != http.StatusOK &&
runResp.StatusCode != http.StatusNoContent &&
runResp.StatusCode != http.StatusCreated {
runBody, _ := io.ReadAll(runResp.Body)
// debugJSON removed from scope, simplified error
return fmt.Errorf("run operation failed with status %d: %s",
runResp.StatusCode, string(runBody))
}
return nil
}
func (r *TubulusResource) waitForOperationAndInstanceStatus(ctx context.Context, operationId string, instanceId string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
log.Printf("[DEBUG] Starting two-stage polling: operationId=%s, instanceId=%s, timeout=%v", operationId, instanceId, timeout)
// Шаг 1: Ждём завершения операции
operationLoop:
for {
select {
case <-ctx.Done():
return fmt.Errorf("context cancelled")
case <-ticker.C:
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for operation to complete")
}
log.Printf("[DEBUG] Polling operation status: operationId=%s", operationId)
// Проверяем статус операции через клиент
opResp, err := r.client.GetInstanceOperation(ctx, operationId)
if err != nil {
log.Printf("[WARN] Failed to get operation status: %s", err)
continue // Пробуем еще раз на следующем тике
}
// Вывод для логов, чтобы видеть что реально приходит
if opResp.DtFinish != nil {
log.Printf("[DEBUG] dtFinish: %s", *opResp.DtFinish)
}
if opResp.IsSuccessful != nil {
log.Printf("[DEBUG] isSuccessful: %v", *opResp.IsSuccessful)
}
// Извлекаем все доступные данные из ответа (максимальная диагностика)
isSucc := "N/A"
if opResp.IsSuccessful != nil {
isSucc = fmt.Sprintf("%v", *opResp.IsSuccessful)
}
dtFin := "not finished"
if opResp.DtFinish != nil {
dtFin = *opResp.DtFinish
}
submitCode := "N/A"
if opResp.SubmitResult != nil {
submitCode = *opResp.SubmitResult
}
dur := 0.0
if opResp.Duration != nil {
dur = *opResp.Duration
}
who := "system"
if opResp.UpdaterShortname != nil {
who = *opResp.UpdaterShortname
}
log.Printf("[DEBUG] Polling: OpID=%s, Status=%s/%s, Success=%s, Finish=%s, SubmitResult=%s, Dur=%.2fs, Initiator=%s",
operationId,
map[bool]string{true: "PENDING", false: "ACTIVE"}[opResp.IsPending],
map[bool]string{true: "WORKING", false: "IDLE"}[opResp.IsInProgress],
isSucc, dtFin, submitCode, dur, who)
// [FIX 2026-01-27]: STRICT SUCCESS LOGIC
// [CRITICAL WARNING]: DO NOT CHANGE THIS LOGIC!
// This logic is battle-tested against 14+ HAR files.
// 1. dtFinish IS THE ONLY SOURCE OF TRUTH for completion.
// 2. Do NOT rely on status, isInProgress, or isPending for completion check.
// 3. If dtFinish is set -> The operation is DONE. We stop waiting IMMEDIATELY.
// 4. If it is done, we check Success:
// - isSuccessful == true => OK (break loop)
// - isSuccessful == false OR null => ERROR (return error)
// ANY DEVIATION FROM THIS WILL CAUSE TIMEOUTS OR FALSE POSITIVES.
if opResp.DtFinish != nil {
log.Printf("[DEBUG] dtFinish detected: %s. Operation is FINISHED.", *opResp.DtFinish)
if opResp.IsSuccessful != nil && *opResp.IsSuccessful {
log.Printf("[DEBUG] Operation SUCCESS detected (isSuccessful=true).")
break operationLoop
}
// If we are here, it means finished but NOT successful (false or null)
errNote := "Operation finished but marked as UNSUCCESSFUL"
if opResp.ErrorLog != nil {
errNote = fmt.Sprintf("%s. LOG: %s", errNote, *opResp.ErrorLog)
} else {
errNote = fmt.Sprintf("%s. (isSuccessful=%s, dtFinish=%s)", errNote, isSucc, *opResp.DtFinish)
}
return fmt.Errorf("%s (initiator: %s, duration: %.2fs)", errNote, who, dur)
}
// If dtFinish is NOT set, we continue polling until timeout.
// Reordered check: We trust dtFinish as the primary signal of completion.
// Legacy check for "Cold Start" or weird states where flags are false but nothing happened yet
if !opResp.IsInProgress && !opResp.IsPending && opResp.SubmitResult == nil {
log.Printf("[DEBUG] Operation uninitialized (cold start or slow pending). Waiting...")
continue
}
}
}
// Шаг 2: Проверяем статус instance
ticker2 := time.NewTicker(5 * time.Second)
defer ticker2.Stop()
log.Printf("[DEBUG] Starting instance status polling")
for {
select {
case <-ctx.Done():
return fmt.Errorf("context cancelled")
case <-ticker2.C:
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for instance to become running")
}
log.Printf("[DEBUG] Polling instance status: instanceId=%s", instanceId)
instance, err := r.readInstance(ctx, instanceId)
if err != nil {
return fmt.Errorf("failed to check instance status: %s", err)
}
if instance.Status == "running" {
return nil
}
if instance.Status == "error" || instance.Status == "failed" {
return fmt.Errorf("instance entered error state: %s", instance.Status)
}
}
}
}