add: internal package (provider, core, registrykeys)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user