add: internal package (provider, core, registrykeys)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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, "./")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)...)
|
||||
}
|
||||
@@ -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
@@ -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{}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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